From c613d38b7308effb8d0dd18dfb361d73602b41dd Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 25 Sep 2026 15:11:23 +0530 Subject: [PATCH 01/51] Add unified workspace Connections page Signed-off-by: krishna2323 --- src/CONST/index.ts | 14 +- src/ROUTES.ts | 32 +- src/SCREENS.ts | 2 +- .../FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts | 6 +- .../SearchRouter/useNavigationSuggestions.tsx | 10 +- src/hooks/useDynamicForwardPath.ts | 2 +- src/languages/en.ts | 22 ++ .../ModalStackNavigators/index.tsx | 3 + .../Navigators/WorkspaceSplitNavigator.tsx | 6 +- .../RELATIONS/WORKSPACE_TO_RHP.ts | 49 ++-- src/libs/Navigation/linkingConfig/config.ts | 20 +- src/libs/Navigation/types.ts | 30 +- src/pages/workspace/WorkspaceInitialPage.tsx | 6 +- .../workspace/connections/ConnectionCard.tsx | 81 +++++ .../workspace/connections/ConnectionsGrid.tsx | 44 +++ .../connections/WorkspaceConnectionsPage.tsx | 276 ++++++++++++++++++ src/pages/workspace/connections/types.ts | 44 +++ .../useAccountingConnectionListings.ts | 180 ++++++++++++ .../connections/useMCPConnectionListings.ts | 56 ++++ .../connections/useMergeConnectionListings.ts | 143 +++++++++ .../useReceiptPartnerConnectionListings.ts | 78 +++++ src/pages/workspace/connections/utils.ts | 66 +++++ src/pages/workspace/getWorkspaceMenuItems.ts | 88 +----- 23 files changed, 1082 insertions(+), 176 deletions(-) create mode 100644 src/pages/workspace/connections/ConnectionCard.tsx create mode 100644 src/pages/workspace/connections/ConnectionsGrid.tsx create mode 100644 src/pages/workspace/connections/WorkspaceConnectionsPage.tsx create mode 100644 src/pages/workspace/connections/types.ts create mode 100644 src/pages/workspace/connections/useAccountingConnectionListings.ts create mode 100644 src/pages/workspace/connections/useMCPConnectionListings.ts create mode 100644 src/pages/workspace/connections/useMergeConnectionListings.ts create mode 100644 src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts create mode 100644 src/pages/workspace/connections/utils.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 6bbc0c27e356..b80e13fdad4d 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6669,6 +6669,14 @@ const CONST = { FLAG_FOR_REVIEW: 'flagForReview', AGENTS: 'agents', }, + CONNECTIONS: { + POPULAR: 'popular', + ALL: 'all', + ACCOUNTING: 'accounting', + PEOPLE: 'people', + TRAVEL: 'travel', + AI: 'ai', + }, WORKFLOWS_TAB_TYPE: 'workflowsTabType', WORKFLOWS: { SUBMISSIONS: 'submissions', @@ -9633,10 +9641,7 @@ const CONST = { MEMBERS: 'WorkspaceInitial-Members', ROOMS: 'WorkspaceInitial-Rooms', REPORTS: 'WorkspaceInitial-Reports', - ACCOUNTING: 'WorkspaceInitial-Accounting', - HR: 'WorkspaceInitial-HR', - RECRUITING: 'WorkspaceInitial-Recruiting', - RECEIPT_PARTNERS: 'WorkspaceInitial-ReceiptPartners', + CONNECTIONS: 'WorkspaceInitial-Connections', CATEGORIES: 'WorkspaceInitial-Categories', TAGS: 'WorkspaceInitial-Tags', TAXES: 'WorkspaceInitial-Taxes', @@ -9651,7 +9656,6 @@ const CONST = { INVOICES: 'WorkspaceInitial-Invoices', MORE_FEATURES: 'WorkspaceInitial-MoreFeatures', VENDORS: 'WorkspaceInitial-Vendors', - MCP: 'WorkspaceInitial-MCP', }, OVERVIEW: { AVATAR: 'WorkspaceOverview-Avatar', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 39bec91af5e4..c0613f602f20 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -691,7 +691,7 @@ const DYNAMIC_ROUTES = { }, SAGE_INTACCT_PREREQUISITES: { path: 'sage-intacct/prerequisites', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.ACCOUNTING.EXISTING_SAGE_INTACCT_CONNECTIONS], + entryScreens: [SCREENS.WORKSPACE.CONNECTIONS, SCREENS.WORKSPACE.ACCOUNTING.EXISTING_SAGE_INTACCT_CONNECTIONS], }, POLICY_ACCOUNTING_CERTINIA_EXPORT: { path: 'certinia/export', @@ -1270,14 +1270,14 @@ const DYNAMIC_ROUTES = { // suffix inherits their `:policyID` (same as WORKSPACE_INVITE above). path: 'hr-sync-results', // The results screen opens automatically when an HR sync finishes, and a sync can complete - // while the user is on either the HR page or the members list, so both are entry screens. - entryScreens: [SCREENS.WORKSPACE.HR, SCREENS.WORKSPACE.MEMBERS], + // while the user is on the HR settings, the Connections page, or the members list. + entryScreens: [SCREENS.WORKSPACE.HR, SCREENS.WORKSPACE.CONNECTIONS, SCREENS.WORKSPACE.MEMBERS], }, WORKSPACE_RECRUITING_SYNC_RESULTS: { // The results screen opens automatically when a recruiting sync finishes, and a sync can complete - // while the user is on either the recruiting page or the members list, so both are entry screens. + // while the user is on the recruiting settings, the Connections page, or the members list. path: 'recruiting-sync-results', - entryScreens: [SCREENS.WORKSPACE.RECRUITING, SCREENS.WORKSPACE.MEMBERS], + entryScreens: [SCREENS.WORKSPACE.RECRUITING, SCREENS.WORKSPACE.CONNECTIONS, SCREENS.WORKSPACE.MEMBERS], }, WORKSPACE_OWNER_CHANGE_CHECK: { path: 'change-owner/:policyID/:accountID/:error', @@ -3388,6 +3388,15 @@ const ROUTES = { }, POLICY_ACCOUNTING: { route: 'workspaces/:policyID/accounting', + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING route'); + } + return `workspaces/${policyID}/accounting` as const; + }, + }, + WORKSPACE_CONNECTIONS: { + route: 'workspaces/:policyID/connections', getRoute: ( policyID: string | undefined, newConnectionName?: ConnectionName, @@ -3396,7 +3405,7 @@ const ROUTES = { isIntuitEnterpriseSuite?: boolean, ) => { if (!policyID) { - Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING route'); + Log.warn('Invalid policyID is used to build the WORKSPACE_CONNECTIONS route'); } let queryParams = ''; @@ -3412,7 +3421,7 @@ const ROUTES = { queryParams += `&isIntuitEnterpriseSuite=${isIntuitEnterpriseSuite}`; } } - return `workspaces/${policyID}/accounting${queryParams}` as const; + return `workspaces/${policyID}/connections${queryParams}` as const; }, }, WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED: { @@ -3945,15 +3954,6 @@ const ROUTES = { route: 'policy/:policyID/copy-settings/confirm', getRoute: (policyID: string) => `policy/${policyID}/copy-settings/confirm` as const, }, - WORKSPACE_MCP: { - route: 'workspaces/:policyID/mcp', - getRoute: (policyID: string | undefined) => { - if (!policyID) { - Log.warn('Invalid policyID is used to build the WORKSPACE_MCP route'); - } - return `workspaces/${policyID}/mcp` as const; - }, - }, WORKSPACE_RECEIPT_PARTNERS: { route: 'workspaces/:policyID/receipt-partners', getRoute: (policyID: string | undefined, backTo?: string) => { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 6d09604a6d16..cde0ced23d2e 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -949,7 +949,7 @@ const SCREENS = { UPGRADE: 'Workspace_Upgrade', DYNAMIC_WORKSPACE_DOWNGRADE: 'Dynamic_Workspace_Downgrade', DYNAMIC_PAY_AND_DOWNGRADE: 'Dynamic_Workspace_Pay_And_Downgrade', - MCP: 'Workspace_MCP', + CONNECTIONS: 'Workspace_Connections', RECEIPT_PARTNERS: 'Workspace_Receipt_Partners', DYNAMIC_RECEIPT_PARTNERS_INVITE: 'Dynamic_Workspace_Receipt_Partners_Invite', DYNAMIC_RECEIPT_PARTNERS_INVITE_EDIT: 'Dynamic_Workspace_Receipt_Partners_Invite_Edit', diff --git a/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts b/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts index 9d62d4bc70d3..8e86729fa934 100644 --- a/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts +++ b/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts @@ -18,14 +18,12 @@ const WIDE_LAYOUT_INACTIVE_SCREENS: string[] = [ SCREENS.SETTINGS.ABOUT, SCREENS.WORKSPACES_LIST, SCREENS.SETTINGS.SUBSCRIPTION.ROOT, - SCREENS.WORKSPACE.ACCOUNTING.ROOT, + SCREENS.WORKSPACE.CONNECTIONS, SCREENS.WORKSPACE.INITIAL, SCREENS.WORKSPACE.PROFILE, SCREENS.WORKSPACE.WORKFLOWS, SCREENS.WORKSPACE.INVOICES, SCREENS.WORKSPACE.MEMBERS, - SCREENS.WORKSPACE.HR, - SCREENS.WORKSPACE.RECRUITING, SCREENS.WORKSPACE.CATEGORIES, SCREENS.WORKSPACE.VENDORS, SCREENS.WORKSPACE.MORE_FEATURES, @@ -41,8 +39,6 @@ const WIDE_LAYOUT_INACTIVE_SCREENS: string[] = [ SCREENS.SETTINGS.SAVE_THE_WORLD, SCREENS.WORKSPACE.RULES, SCREENS.WORKSPACE.PER_DIEM, - SCREENS.WORKSPACE.MCP, - SCREENS.WORKSPACE.RECEIPT_PARTNERS, SCREENS.WORKSPACE.TIME_TRACKING, ]; diff --git a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index 17f778062bce..a7c0a29ebc4c 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -77,7 +77,7 @@ const SEARCH_ROUTER_ICON_NAMES = [ ...SEARCH_TYPE_MENU_ICON_NAMES, 'Users', 'Hashtag', - 'Sync', + 'Connect', 'Briefcase', 'Tag', 'Coins', @@ -89,8 +89,6 @@ const SEARCH_ROUTER_ICON_NAMES = [ 'Clock', 'InvoiceGeneric', 'Bolt', - 'Bot', - 'UserPlus', ] as const; // Saved searches are user-defined searches, not canned destinations, so they are excluded from go-to navigation suggestions. @@ -140,9 +138,6 @@ type BuildWorkspaceNavigationItemsParams = { isVendorMatchingBetaEnabled: boolean; - /** Whether the Merge ATS beta gating the Recruiting feature is enabled. */ - isRecruitingBetaEnabled: boolean; - /** Whether navigation should use the narrow-layout Workspace flow. */ shouldUseNarrowLayout: boolean; @@ -290,7 +285,6 @@ function buildWorkspaceNavigationItems({ icons, isOffline, isVendorMatchingBetaEnabled, - isRecruitingBetaEnabled, shouldUseNarrowLayout, convertToDisplayString, getItemText, @@ -309,7 +303,6 @@ function buildWorkspaceNavigationItems({ icons, policyCategories: policyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy.id}`], isVendorMatchingBetaEnabled, - isRecruitingBetaEnabled, convertToDisplayString, }); @@ -440,7 +433,6 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) icons, isOffline: !!isOffline, isVendorMatchingBetaEnabled: isBetaEnabled(CONST.BETAS.VENDOR_MATCHING), - isRecruitingBetaEnabled: isBetaEnabled(CONST.BETAS.MERGE_ATS), shouldUseNarrowLayout, convertToDisplayString, getItemText: (item) => translate(item.translationKey), diff --git a/src/hooks/useDynamicForwardPath.ts b/src/hooks/useDynamicForwardPath.ts index 21438548c21c..e955d8aac546 100644 --- a/src/hooks/useDynamicForwardPath.ts +++ b/src/hooks/useDynamicForwardPath.ts @@ -36,7 +36,7 @@ const FORWARD_TO_MAPPINGS: Record `Synced ${calendarTime}`, + hrisListing: (providerName: string) => `${providerName} (HRIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: "Don't see yours? Suggest an integration, we'll look into it.", + }, receiptPartners: { uber: { subtitle: (organizationName: string) => (organizationName ? `Connected to ${organizationName}` : 'Automate travel and meal delivery expenses across your organization.'), diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index b2eb40655423..76218ef8c90f 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -436,6 +436,9 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/settings/Profile/Contacts/SetDefaultContactMethodConfirmValidateCodePage').default, [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: () => require('../../../../pages/settings/Preferences/PriorityModePage').default, [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: () => require('../../../../pages/workspace/accounting/PolicyAccountingPage').default, + [SCREENS.WORKSPACE.HR]: () => require('../../../../pages/workspace/hr/WorkspaceHRPage').default, + [SCREENS.WORKSPACE.RECRUITING]: () => require('../../../../pages/workspace/recruiting/WorkspaceRecruitingPage').default, + [SCREENS.WORKSPACE.RECEIPT_PARTNERS]: () => require('../../../../pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage').default, [SCREENS.SETTINGS.PREFERENCES.LANGUAGE]: () => require('../../../../pages/settings/Preferences/LanguagePage').default, [SCREENS.SETTINGS.PREFERENCES.THEME]: () => require('../../../../pages/settings/Preferences/ThemePage').default, [SCREENS.SETTINGS.PREFERENCES.PAYMENT_CURRENCY]: () => require('../../../../pages/settings/Preferences/PaymentCurrencyPage').default, diff --git a/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx index b016c03f4ffa..3bf9aa15edd6 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx @@ -24,9 +24,7 @@ const CENTRAL_PANE_WORKSPACE_SCREENS = { [SCREENS.WORKSPACE.INVOICES]: () => require('../../../../pages/workspace/invoices/WorkspaceInvoicesPage').default, [SCREENS.WORKSPACE.MEMBERS]: () => require('../../../../pages/workspace/WorkspaceMembersPage').default, [SCREENS.WORKSPACE.ROOMS]: () => require('../../../../pages/workspace/rooms/WorkspaceRoomsPage').default, - [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: () => require('../../../../pages/workspace/accounting/PolicyAccountingPage').default, - [SCREENS.WORKSPACE.HR]: () => require('../../../../pages/workspace/hr/WorkspaceHRPage').default, - [SCREENS.WORKSPACE.RECRUITING]: () => require('../../../../pages/workspace/recruiting/WorkspaceRecruitingPage').default, + [SCREENS.WORKSPACE.CONNECTIONS]: () => require('../../../../pages/workspace/connections/WorkspaceConnectionsPage').default, [SCREENS.WORKSPACE.CATEGORIES]: () => require('../../../../pages/workspace/categories/WorkspaceCategoriesPage').default, [SCREENS.WORKSPACE.VENDORS]: () => require('../../../../pages/workspace/vendors/WorkspaceVendorsPage').default, [SCREENS.WORKSPACE.MORE_FEATURES]: () => require('../../../../pages/workspace/WorkspaceMoreFeaturesPage').default, @@ -36,8 +34,6 @@ const CENTRAL_PANE_WORKSPACE_SCREENS = { [SCREENS.WORKSPACE.EXPENSIFY_CARD]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardPage').default, [SCREENS.WORKSPACE.COMPANY_CARDS]: () => require('../../../../pages/workspace/companyCards/WorkspaceCompanyCardsPage').default, [SCREENS.WORKSPACE.PER_DIEM]: () => require('../../../../pages/workspace/perDiem/WorkspacePerDiemPage').default, - [SCREENS.WORKSPACE.MCP]: () => require('../../../../pages/workspace/mcp/WorkspaceMCPPage').default, - [SCREENS.WORKSPACE.RECEIPT_PARTNERS]: () => require('../../../../pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage').default, [SCREENS.WORKSPACE.DISTANCE_RATES]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRatesPage').default, [SCREENS.WORKSPACE.TRAVEL]: () => require('../../../../pages/workspace/travel/PolicyTravelPage').default, [SCREENS.WORKSPACE.RULES]: () => require('../../../../pages/workspace/rules/PolicyRulesPageRevamp').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 1df321aef1a2..222093bce6b6 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -51,7 +51,8 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.ACCOUNTING.INTUIT_ENTERPRISE_SUITE_ENTITY_SELECTOR]: { path: ROUTES.POLICY_ACCOUNTING_INTUIT_ENTERPRISE_SUITE_ENTITY_SELECTOR.route, }, + [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: {path: ROUTES.POLICY_ACCOUNTING.route}, + [SCREENS.WORKSPACE.HR]: {path: ROUTES.WORKSPACE_HR.route}, + [SCREENS.WORKSPACE.RECRUITING]: {path: ROUTES.WORKSPACE_RECRUITING.route}, + [SCREENS.WORKSPACE.RECEIPT_PARTNERS]: {path: ROUTES.WORKSPACE_RECEIPT_PARTNERS.route}, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_IMPORT]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_IMPORT.route}, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_CHART_OF_ACCOUNTS]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_CHART_OF_ACCOUNTS.route}, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_CLASSES]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_CLASSES.route}, @@ -2416,11 +2420,8 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.PER_DIEM]: { path: ROUTES.WORKSPACE_PER_DIEM.route, }, - [SCREENS.WORKSPACE.MCP]: { - path: ROUTES.WORKSPACE_MCP.route, - }, - [SCREENS.WORKSPACE.RECEIPT_PARTNERS]: { - path: ROUTES.WORKSPACE_RECEIPT_PARTNERS.route, + [SCREENS.WORKSPACE.CONNECTIONS]: { + path: ROUTES.WORKSPACE_CONNECTIONS.route, }, [SCREENS.WORKSPACE.WORKFLOWS]: { path: ROUTES.WORKSPACE_WORKFLOWS.route, @@ -2434,9 +2435,6 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.ROOMS]: { path: ROUTES.WORKSPACE_ROOMS.route, }, - [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: { - path: ROUTES.POLICY_ACCOUNTING.route, - }, [SCREENS.WORKSPACE.CATEGORIES]: { path: ROUTES.WORKSPACE_CATEGORIES.route, }, @@ -2446,12 +2444,6 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.MORE_FEATURES]: { path: ROUTES.WORKSPACE_MORE_FEATURES.route, }, - [SCREENS.WORKSPACE.HR]: { - path: ROUTES.WORKSPACE_HR.route, - }, - [SCREENS.WORKSPACE.RECRUITING]: { - path: ROUTES.WORKSPACE_RECRUITING.route, - }, [SCREENS.WORKSPACE.TAGS]: { path: ROUTES.WORKSPACE_TAGS.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 1f4af1510bf2..6ec765c13e49 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -797,6 +797,18 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.DISTANCE_RATES_COMMUTER_EXCLUSIONS]: { policyID: string; }; + [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.HR]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RECRUITING]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RECEIPT_PARTNERS]: { + policyID: string; + }; [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_SETUP]: { policyID: string; isIntuitEnterpriseSuite?: string; @@ -3089,11 +3101,12 @@ type WorkspaceSplitNavigatorParamList = { [SCREENS.WORKSPACE.COMPANY_CARDS]: { policyID: string; }; - [SCREENS.WORKSPACE.MCP]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RECEIPT_PARTNERS]: { + [SCREENS.WORKSPACE.CONNECTIONS]: { policyID: string; + newConnectionName?: ConnectionName; + integrationToDisconnect?: ConnectionName; + shouldDisconnectIntegrationBeforeConnecting?: boolean; + isIntuitEnterpriseSuite?: string; }; [SCREENS.WORKSPACE.DYNAMIC_RECEIPT_PARTNERS_INVITE]: { policyID: string; @@ -3193,12 +3206,6 @@ type WorkspaceSplitNavigatorParamList = { [SCREENS.WORKSPACE.MORE_FEATURES]: { policyID: string; }; - [SCREENS.WORKSPACE.HR]: { - policyID: string; - }; - [SCREENS.WORKSPACE.RECRUITING]: { - policyID: string; - }; [SCREENS.WORKSPACE.TAGS]: { policyID: string; // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md @@ -3221,9 +3228,6 @@ type WorkspaceSplitNavigatorParamList = { [SCREENS.WORKSPACE.TRAVEL]: { policyID: string; }; - [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: { - policyID: string; - }; [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_ADVANCED]: { policyID: string; }; diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 5beb127fe3d2..90e816f6cbfd 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -103,17 +103,14 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac 'Gear', 'Hashtag', 'InvoiceGeneric', - 'Receipt', 'Briefcase', - 'Sync', + 'Connect', 'Tag', 'Users', 'Workflows', 'LuggageWithLines', 'Clock', 'Bolt', - 'Bot', - 'UserPlus', ]); const policyName = policy?.name ?? ''; @@ -184,7 +181,6 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac shouldShowEnterCredentialsError, shouldShowRBR, isVendorMatchingBetaEnabled: isBetaEnabled(CONST.BETAS.VENDOR_MATCHING), - isRecruitingBetaEnabled: isBetaEnabled(CONST.BETAS.MERGE_ATS), convertToDisplayString, }).map((item) => ({ ...item, diff --git a/src/pages/workspace/connections/ConnectionCard.tsx b/src/pages/workspace/connections/ConnectionCard.tsx new file mode 100644 index 000000000000..f3c6f1b3ee34 --- /dev/null +++ b/src/pages/workspace/connections/ConnectionCard.tsx @@ -0,0 +1,81 @@ +/** + * A single integration tile on the Connections page. Connected integrations show their status and a Configure or Fix + * button, and the rest show a "+" that starts the connection flow. + */ +import Badge from '@components/Badge'; +import Button from '@components/Button'; +import MenuItem from '@components/MenuItem'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; + +import React from 'react'; +import {View} from 'react-native'; + +import type {ConnectionListing} from './types'; + +type ConnectionCardProps = { + listing: ConnectionListing; +}; + +function ConnectionCard({listing}: ConnectionCardProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const icons = useMemoizedLazyExpensifyIcons(['Building', 'Plus']); + const {title, icon, status, onConnect, onConfigure, registerConnectButton} = listing; + + const statusButton = !!status && ( + + ); + + return ( + + + ) : undefined + } + description={status?.message} + numberOfLinesDescription={1} + wrapperStyle={styles.pv4} + onPress={status ? onConfigure : onConnect} + disabled={!status && isOffline} + shouldShowRightIcon={!status} + iconRight={icons.Plus} + shouldShowRightComponent={!!status} + rightComponent={statusButton} + /> + + ); +} + +export default ConnectionCard; diff --git a/src/pages/workspace/connections/ConnectionsGrid.tsx b/src/pages/workspace/connections/ConnectionsGrid.tsx new file mode 100644 index 000000000000..b74476f255c3 --- /dev/null +++ b/src/pages/workspace/connections/ConnectionsGrid.tsx @@ -0,0 +1,44 @@ +/** + * Lays out connection cards in two columns on wide screens and one column on narrow ones. + */ +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; +import {View} from 'react-native'; + +import type {ConnectionListing} from './types'; + +import ConnectionCard from './ConnectionCard'; + +type ConnectionsGridProps = { + listings: ConnectionListing[]; +}; + +function ConnectionsGrid({listings}: ConnectionsGridProps) { + const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + // An odd number of cards gets an invisible filler so the last card keeps its column width + const needsFiller = !shouldUseNarrowLayout && listings.length % 2 === 1; + + return ( + + {listings.map((listing) => ( + + ))} + {needsFiller && ( + + )} + + ); +} + +export default ConnectionsGrid; diff --git a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx new file mode 100644 index 000000000000..2c4b37524dd6 --- /dev/null +++ b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx @@ -0,0 +1,276 @@ +/** + * The single place where a workspace browses, connects, and manages all of its integrations: accounting, HR, + * receipt partners, and AI assistants. + */ +import ConnectToMergeFlow from '@components/ConnectToMergeFlow'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import CompactSearchBar from '@components/SearchBar/CompactSearchBar'; +import TabSelectorBase from '@components/TabSelector/TabSelectorBase'; +import TabSelectorContextProvider from '@components/TabSelector/TabSelectorContext'; +import type {TabSelectorBaseItem} from '@components/TabSelector/types'; +import TextLink from '@components/TextLink'; + +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOpenConciergeAnywhere from '@hooks/useOpenConciergeAnywhere'; +import usePermissions from '@hooks/usePermissions'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSearchResults from '@hooks/useSearchResults'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; + +import {openPolicyHRPage, openPolicyRecruitingPage} from '@libs/actions/PolicyConnections'; +import Navigation from '@libs/Navigation/Navigation'; +import {isControlPolicy} from '@libs/PolicyUtils'; +import tokenizedSearch from '@libs/tokenizedSearch'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import {AccountingContextProvider, useAccountingActions} from '@pages/workspace/accounting/AccountingContext'; +import MergeInitialSyncingModalListener from '@pages/workspace/merge/MergeInitialSyncingModalListener'; +import MergeSyncResultsListener from '@pages/workspace/merge/MergeSyncResultsListener'; +import type {MergeProviderCardCategory} from '@pages/workspace/merge/types'; +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; + +import {openPolicyReceiptPartnersPage} from '@userActions/Policy/Policy'; + +import CONST from '@src/CONST'; +import type {ConnectionName} from '@src/types/onyx/Policy'; + +import {useFocusEffect, useRoute} from '@react-navigation/native'; +import React, {useEffect, useRef, useState} from 'react'; +import {View} from 'react-native'; + +import type {ConnectionListing, ConnectionsTab} from './types'; + +import ConnectionsGrid from './ConnectionsGrid'; +import useAccountingConnectionListings from './useAccountingConnectionListings'; +import useMCPConnectionListings from './useMCPConnectionListings'; +import useMergeConnectionListings from './useMergeConnectionListings'; +import useReceiptPartnerConnectionListings from './useReceiptPartnerConnectionListings'; +import {getListingsForTab} from './utils'; + +type RouteParams = { + newConnectionName?: ConnectionName; + integrationToDisconnect?: ConnectionName; + shouldDisconnectIntegrationBeforeConnecting?: boolean; + isIntuitEnterpriseSuite?: string; +}; + +type MergeSetupFlow = { + setupLink: string; + category: MergeProviderCardCategory; + key: number; +}; + +const TABS: ConnectionsTab[] = Object.values(CONST.TAB.CONNECTIONS); + +/** HR and recruiting providers are Control-only, so their data is only requested for Control workspaces */ +function fetchConnectionsData(policyID: string | undefined, canUseMergeConnections: boolean, isRecruitingBetaEnabled: boolean) { + if (!policyID) { + return; + } + openPolicyReceiptPartnersPage(policyID); + if (!canUseMergeConnections) { + return; + } + openPolicyHRPage(policyID); + if (isRecruitingBetaEnabled) { + openPolicyRecruitingPage(policyID); + } +} + +function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { + const policyID = policy?.id; + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {isBetaEnabled} = usePermissions(); + const {openConciergeAnywhere} = useOpenConciergeAnywhere(); + const {startIntegrationFlow} = useAccountingActions(); + const {canWrite: canWriteAccounting} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); + const [activeTab, setActiveTab] = useState(CONST.TAB.CONNECTIONS.POPULAR); + const [mergeSetupFlow, setMergeSetupFlow] = useState(); + const route = useRoute(); + const params = route.params as RouteParams | undefined; + useWorkspaceDocumentTitle(policy?.name, 'workspace.common.connections'); + + const isRecruitingBetaEnabled = isBetaEnabled(CONST.BETAS.MERGE_ATS); + const canUseMergeConnections = isControlPolicy(policy); + + useNetwork({onReconnect: () => fetchConnectionsData(policyID, canUseMergeConnections, isRecruitingBetaEnabled)}); + + useEffect(() => { + fetchConnectionsData(policyID, canUseMergeConnections, isRecruitingBetaEnabled); + }, [policyID, canUseMergeConnections, isRecruitingBetaEnabled]); + + const accountingListings = useAccountingConnectionListings(policy); + const mergeListings = useMergeConnectionListings(policy, (setupLink, category) => { + // A random key forces a remount on every press, even for the same provider + setMergeSetupFlow({setupLink, category, key: Math.random()}); + }); + const receiptPartnerListings = useReceiptPartnerConnectionListings(policy); + const mcpListings = useMCPConnectionListings(policy); + const listings: ConnectionListing[] = [...accountingListings, ...mergeListings, ...receiptPartnerListings, ...mcpListings]; + const connectedListings = listings.filter((listing) => !!listing.status); + const availableListings = listings.filter((listing) => !listing.status); + + const [searchValue, setSearchValue, searchResults] = useSearchResults( + availableListings, + (listing, searchInput) => tokenizedSearch([listing], searchInput, (item) => [item.title]).length > 0, + ); + const visibleListings = searchValue.trim() ? searchResults : getListingsForTab(availableListings, activeTab); + + // Upgrade and setup flows that leave the app come back here with the integration to connect in the route params. + // `startIntegrationFlow` changes identity whenever `policy` does, and the params are only cleared in a later render, + // so the guard is keyed on the value to start each flow once. + const startedIntegrationFlowForRef = useRef(undefined); + const newConnectionName = params?.newConnectionName; + const integrationToDisconnect = params?.integrationToDisconnect; + const shouldDisconnectIntegrationBeforeConnecting = params?.shouldDisconnectIntegrationBeforeConnecting; + const shouldConnectToIntuitEnterpriseSuite = params?.isIntuitEnterpriseSuite === 'true'; + + useFocusEffect(() => { + if (!newConnectionName || !isControlPolicy(policy) || !canWriteAccounting) { + if (!newConnectionName) { + startedIntegrationFlowForRef.current = undefined; + } + return; + } + if (startedIntegrationFlowForRef.current === newConnectionName) { + return; + } + startedIntegrationFlowForRef.current = newConnectionName; + + startIntegrationFlow({ + name: newConnectionName, + isIntuitEnterpriseSuite: shouldConnectToIntuitEnterpriseSuite, + integrationToDisconnect, + shouldDisconnectIntegrationBeforeConnecting, + }); + Navigation.setParams({ + newConnectionName: undefined, + isIntuitEnterpriseSuite: undefined, + integrationToDisconnect: undefined, + shouldDisconnectIntegrationBeforeConnecting: undefined, + }); + }); + + const connectedHRConnectionName = CONST.POLICY.CONNECTIONS.HR_CONNECTION_NAMES.find((name) => !!policy?.connections?.[name]); + const connectedRecruitingConnectionName = isRecruitingBetaEnabled ? CONST.POLICY.CONNECTIONS.RECRUITING_CONNECTION_NAMES.find((name) => !!policy?.connections?.[name]) : undefined; + + const tabs: Array> = TABS.map((tab) => ({ + key: tab, + title: translate(`workspace.connections.tabs.${tab}`), + })); + + const searchBar = ( + + ); + + return ( + + {!!policyID && !!connectedHRConnectionName && ( + + )} + {!!policyID && connectedHRConnectionName === CONST.POLICY.CONNECTIONS.NAME.MERGE_HR && ( + + )} + {!!policyID && !!connectedRecruitingConnectionName && ( + <> + + + + )} + {!!mergeSetupFlow && ( + setMergeSetupFlow(undefined)} + /> + )} + Navigation.goBack()} + /> + + {connectedListings.length > 0 && ( + + + + )} + + + + + + + {searchBar} + + + + openConciergeAnywhere({forceConcierge: true})} + > + {translate('workspace.connections.suggestIntegration')} + + + + + ); +} + +function WorkspaceConnectionsPageWrapper(props: WithPolicyConnectionsProps) { + return ( + + + + + + ); +} + +export default withPolicyConnections(WorkspaceConnectionsPageWrapper); diff --git a/src/pages/workspace/connections/types.ts b/src/pages/workspace/connections/types.ts new file mode 100644 index 000000000000..7a293b888bad --- /dev/null +++ b/src/pages/workspace/connections/types.ts @@ -0,0 +1,44 @@ +import type CONST from '@src/CONST'; +import type IconAsset from '@src/types/utils/IconAsset'; + +import type {ComponentRef} from 'react'; +import type {View} from 'react-native'; +import type {ValueOf} from 'type-fest'; + +type ConnectionsTab = ValueOf; + +/** The tabs a listing can belong to. Popular and All are cross-cutting views built from the listings themselves. */ +type ConnectionCategory = Exclude; + +type ConnectionStatus = { + /** A broken connection shows a Fix button instead of Configure */ + isBroken: boolean; + + message: string; +}; + +/** One integration on the Connections page, either available to connect or already connected. */ +type ConnectionListing = { + /** Unique across all categories, also used to decide which listings appear on the Popular tab */ + key: string; + + category: ConnectionCategory; + + title: string; + + /** Local asset, or a remote logo URL for Merge providers */ + icon?: IconAsset | string; + + /** Only set while the integration is connected to the workspace */ + status?: ConnectionStatus; + + onConnect: () => void; + + /** Opens the connection's settings. Only called for connected listings. */ + onConfigure?: () => void; + + /** Receives the element that starts the connection, since some accounting setup flows anchor a popover to it */ + registerConnectButton?: (button: ComponentRef | null) => void; +}; + +export type {ConnectionsTab, ConnectionCategory, ConnectionStatus, ConnectionListing}; diff --git a/src/pages/workspace/connections/useAccountingConnectionListings.ts b/src/pages/workspace/connections/useAccountingConnectionListings.ts new file mode 100644 index 000000000000..36589fa5190f --- /dev/null +++ b/src/pages/workspace/connections/useAccountingConnectionListings.ts @@ -0,0 +1,180 @@ +/** + * Builds the accounting listings for the Connections page and starts the accounting connect flows from them. + * Must be rendered inside an AccountingContextProvider. + */ +import useCardFeeds from '@hooks/useCardFeeds'; +import useCardsLists from '@hooks/useCardsLists'; +import useHasReusablePoliciesConnectedTo from '@hooks/useHasReusablePoliciesConnectedTo'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {isConnectionInProgress, isConnectionUnverified} from '@libs/actions/connections'; +import Navigation from '@libs/Navigation/Navigation'; +import {getConnectedIntegration, getIntegrationLastSuccessfulDate, tryNavigateToSubmitWorkspaceUpgrade} from '@libs/PolicyUtils'; + +import {useAccountingActions, useAccountingState} from '@pages/workspace/accounting/AccountingContext'; +import {getAccountingIntegrationData, getSynchronizationErrorMessage, isIntuitEnterpriseSuiteConnection} from '@pages/workspace/accounting/utils'; + +import {enablePolicyConnections} from '@userActions/Policy/Policy'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Policy} from '@src/types/onyx'; +import type {ConnectionName} from '@src/types/onyx/Policy'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import type {ConnectionListing, ConnectionStatus} from './types'; + +import {getSyncStatusMessage} from './utils'; + +function useAccountingConnectionListings(policy: OnyxEntry): ConnectionListing[] { + const policyID = policy?.id; + const styles = useThemeStyles(); + const {translate, datetimeToCalendarTime, getLocalDateFromDatetime} = useLocalize(); + const {isBetaEnabled} = usePermissions(); + const {startIntegrationFlow} = useAccountingActions(); + const {activeIntegration, popoverAnchorRefs} = useAccountingState(); + const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); + const {canWrite, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); + const accountingIcons = useMemoizedLazyExpensifyIcons([ + 'IntacctSquare', + 'IntuitSquare', + 'QBOSquare', + 'XeroSquare', + 'NetSuiteSquare', + 'QBDSquare', + 'CertiniaSquare', + 'RilletSquare', + 'DualEntrySquare', + 'CampfireSquare', + 'BusinessCentralSquare', + ]); + const existingConnections = { + sageIntacct: useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, policyID), + qbd: useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.QBD, policyID), + certinia: useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CERTINIA, policyID), + rillet: useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.RILLET, policyID), + dualEntry: useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, policyID), + campfire: useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE, policyID), + }; + const [cardFeeds] = useCardFeeds(policyID); + const [cardLists] = useCardsLists(); + + if (!policyID) { + return []; + } + + const canUseCampfireIntegration = isBetaEnabled(CONST.BETAS.CAMPFIRE) || !!policy?.connections?.campfire; + const canUseBusinessCentralIntegration = isBetaEnabled(CONST.BETAS.BUSINESS_CENTRAL) || !!policy?.connections?.businessCentral; + const accountingIntegrations = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES.filter((name) => { + if (name === CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE) { + return canUseCampfireIntegration; + } + if (name === CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL) { + return canUseBusinessCentralIntegration; + } + return true; + }); + + const isSyncInProgress = isConnectionInProgress(connectionSyncProgress, policy); + const syncingIntegration = accountingIntegrations.find((integration) => integration === connectionSyncProgress?.connectionName); + const connectedIntegration = getConnectedIntegration(policy, accountingIntegrations) ?? syncingIntegration; + const isIntuitEnterpriseSuiteSyncInProgress = isSyncInProgress && activeIntegration?.name === CONST.POLICY.CONNECTIONS.NAME.QBO && activeIntegration.isIntuitEnterpriseSuite === true; + const isConnectedToIntuitEnterpriseSuite = + connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBO && (isIntuitEnterpriseSuiteConnection(policy) || isIntuitEnterpriseSuiteSyncInProgress); + + const connect = (name: ConnectionName, isIntuitEnterpriseSuite?: boolean) => { + if (!canWrite) { + showReadOnlyModal(); + return; + } + if (tryNavigateToSubmitWorkspaceUpgrade(policy, true, CONST.UPGRADE_FEATURE_INTRO_MAPPING.accounting.alias, ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))) { + return; + } + if (!policy?.areConnectionsEnabled) { + enablePolicyConnections(policyID, true, false); + } + startIntegrationFlow( + connectedIntegration + ? {name, isIntuitEnterpriseSuite, integrationToDisconnect: connectedIntegration, shouldDisconnectIntegrationBeforeConnecting: true} + : {name, isIntuitEnterpriseSuite}, + ); + }; + + const getConnectedStatus = (name: ConnectionName, title: string | undefined): ConnectionStatus => { + if (getSynchronizationErrorMessage(policy, name, isSyncInProgress, translate, styles)) { + return {isBroken: true, message: translate('workspace.connections.brokenConnection')}; + } + if (isConnectionUnverified(policy, name)) { + return {isBroken: false, message: translate('workspace.accounting.notSync')}; + } + const syncStage = isSyncInProgress ? connectionSyncProgress?.stageInProgress : undefined; + return { + isBroken: false, + message: getSyncStatusMessage({ + syncingMessage: syncStage ? translate('workspace.accounting.connections.syncStageName', syncStage, title) : undefined, + successfulDate: getIntegrationLastSuccessfulDate( + getLocalDateFromDatetime, + policy?.connections?.[name], + name === connectionSyncProgress?.connectionName ? connectionSyncProgress : undefined, + ), + translate, + datetimeToCalendarTime, + }), + }; + }; + + const integrationOptions = accountingIntegrations.flatMap((name) => [ + {name, isIntuitEnterpriseSuite: name === CONST.POLICY.CONNECTIONS.NAME.QBO ? false : undefined}, + ...(name === CONST.POLICY.CONNECTIONS.NAME.QBO ? [{name, isIntuitEnterpriseSuite: true}] : []), + ]); + + return integrationOptions.flatMap(({name, isIntuitEnterpriseSuite}) => { + const integrationData = getAccountingIntegrationData( + name, + policyID, + translate, + existingConnections, + undefined, + undefined, + undefined, + undefined, + undefined, + accountingIcons, + cardFeeds, + cardLists, + isIntuitEnterpriseSuite, + ); + if (!integrationData) { + return []; + } + + const key = isIntuitEnterpriseSuite ? CONST.POLICY.CONNECTIONS.ACCOUNTING_INTEGRATION_ALIASES.INTUIT_ENTERPRISE_SUITE : name; + const isConnected = name === connectedIntegration && !!isIntuitEnterpriseSuite === isConnectedToIntuitEnterpriseSuite; + + return { + key, + category: CONST.TAB.CONNECTIONS.ACCOUNTING, + title: integrationData.title, + icon: integrationData.icon, + status: isConnected ? getConnectedStatus(name, integrationData.title) : undefined, + onConnect: () => connect(name, isIntuitEnterpriseSuite), + onConfigure: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING.getRoute(policyID)), + registerConnectButton: (button) => { + const anchorRef = popoverAnchorRefs?.current?.[key]; + if (!anchorRef) { + return; + } + anchorRef.current = button; + }, + }; + }); +} + +export default useAccountingConnectionListings; diff --git a/src/pages/workspace/connections/useMCPConnectionListings.ts b/src/pages/workspace/connections/useMCPConnectionListings.ts new file mode 100644 index 000000000000..538e268e300f --- /dev/null +++ b/src/pages/workspace/connections/useMCPConnectionListings.ts @@ -0,0 +1,56 @@ +/** + * Builds the AI & MCP listings for the Connections page. These connect outside Expensify, so they never show as connected. + */ +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; + +import {isMCPEnabled} from '@libs/PolicyUtils'; + +import {openExternalLink} from '@userActions/Link'; +import {enablePolicyMCP} from '@userActions/Policy/Policy'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import type {ConnectionListing} from './types'; + +import {MCP_CONNECTOR} from './utils'; + +function useMCPConnectionListings(policy: OnyxEntry): ConnectionListing[] { + const policyID = policy?.id; + const {translate} = useLocalize(); + const {canWrite, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); + const icons = useMemoizedLazyExpensifyIcons(['ChatGPTSquare', 'ClaudeSquare', 'CursorSquare']); + + if (!policyID) { + return []; + } + + const connectors = [ + {key: MCP_CONNECTOR.CLAUDE, icon: icons.ClaudeSquare, title: translate('workspace.mcp.claude.title'), url: CONST.CLAUDE_CONNECT_URL}, + {key: MCP_CONNECTOR.CHATGPT, icon: icons.ChatGPTSquare, title: translate('workspace.mcp.chatgpt.title'), url: CONST.CHATGPT_CONNECT_URL}, + {key: MCP_CONNECTOR.CURSOR, icon: icons.CursorSquare, title: translate('workspace.mcp.cursor.title'), url: CONST.CURSOR_MCP_HELP_URL}, + ]; + + return connectors.map(({key, icon, title, url}) => ({ + key, + category: CONST.TAB.CONNECTIONS.AI, + title, + icon, + onConnect: () => { + if (!canWrite) { + showReadOnlyModal(); + return; + } + if (!isMCPEnabled(policy)) { + enablePolicyMCP(policyID, true); + } + openExternalLink(url); + }, + })); +} + +export default useMCPConnectionListings; diff --git a/src/pages/workspace/connections/useMergeConnectionListings.ts b/src/pages/workspace/connections/useMergeConnectionListings.ts new file mode 100644 index 000000000000..a4e53a2d5664 --- /dev/null +++ b/src/pages/workspace/connections/useMergeConnectionListings.ts @@ -0,0 +1,143 @@ +import useConfirmModal from '@hooks/useConfirmModal'; +/** + * Builds the People listings (HR and, behind the Merge ATS beta, recruiting providers) for the Connections page. + */ +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; + +import Navigation from '@libs/Navigation/Navigation'; +import {isControlPolicy} from '@libs/PolicyUtils'; + +import {getHRCards} from '@pages/workspace/hr/utils'; +import type {MergeProviderCardCategory, MergeProviderCardDescriptor} from '@pages/workspace/merge/types'; +import {getRecruitingCards} from '@pages/workspace/recruiting/utils'; + +import {enablePolicyHR, enablePolicyRecruiting} from '@userActions/Policy/Policy'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import type {ConnectionListing, ConnectionStatus} from './types'; + +import {getSyncStatusMessage} from './utils'; + +const CATEGORY_CONFIG = { + [CONST.POLICY.CONNECTIONS.CATEGORY.HR]: { + featureName: CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED, + upgradeAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.hr.alias, + enableFeature: enablePolicyHR, + getConfigureRoute: ROUTES.WORKSPACE_HR.getRoute, + }, + [CONST.POLICY.CONNECTIONS.CATEGORY.RECRUITING]: { + featureName: CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED, + upgradeAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.recruiting.alias, + enableFeature: enablePolicyRecruiting, + getConfigureRoute: ROUTES.WORKSPACE_RECRUITING.getRoute, + }, +} as const; + +function useMergeConnectionListings(policy: OnyxEntry, onStartSetup: (setupLink: string, category: MergeProviderCardCategory) => void): ConnectionListing[] { + const policyID = policy?.id; + const {translate, getLocalDateFromDatetime, datetimeToCalendarTime, formatPhoneNumber} = useLocalize(); + const {isBetaEnabled} = usePermissions(); + const {showConfirmModal} = useConfirmModal(); + const policyEmployeePersonalDetails = usePersonalDetailsByLogins([...Object.keys(policy?.employeeList ?? {})]); + const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); + const {canWrite, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); + const icons = useMemoizedLazyExpensifyIcons(['GustoSquare', 'TriNetSquare', 'Download']); + + if (!policyID) { + return []; + } + + const isRecruitingBetaEnabled = isBetaEnabled(CONST.BETAS.MERGE_ATS); + const hrCards = getHRCards({ + policy, + policyEmployeePersonalDetails, + connectionSyncProgress, + getLocalDateFromDatetime, + translate, + formatPhoneNumber, + policyID, + gustoIcon: icons.GustoSquare, + trinetIcon: icons.TriNetSquare, + }); + const recruitingCards = isRecruitingBetaEnabled ? getRecruitingCards({policy, policyEmployeePersonalDetails, policyID, icons, translate, formatPhoneNumber}) : []; + + const connect = (card: MergeProviderCardDescriptor, categoryCards: MergeProviderCardDescriptor[]) => { + if (!card.setupLink) { + return; + } + if (!canWrite) { + showReadOnlyModal(); + return; + } + + const config = CATEGORY_CONFIG[card.category]; + if (!isControlPolicy(policy)) { + Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, config.upgradeAlias, ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))); + return; + } + + // At most one provider of a category can be connected to a workspace at a time + if (categoryCards.some((categoryCard) => categoryCard.isConnected)) { + showConfirmModal({ + title: translate(`workspace.${card.category}.alreadyConnectedTitle`), + prompt: translate(`workspace.${card.category}.alreadyConnectedPrompt`), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + return; + } + + if (!policy?.[config.featureName]) { + config.enableFeature(policyID, true); + } + onStartSetup(card.setupLink, card.category); + }; + + const getConnectedStatus = (card: MergeProviderCardDescriptor): ConnectionStatus => { + if (card.needsReconnect || card.hasError) { + return {isBroken: true, message: translate('workspace.connections.brokenConnection')}; + } + if (card.completeSetupRoute) { + return {isBroken: false, message: translate('workspace.merge.completeSetup')}; + } + let syncingMessage; + if (card.isSyncInProgress) { + syncingMessage = card.syncStageInProgress ? translate('workspace.hr.syncStageName', card.syncStageInProgress) : translate(`workspace.${card.category}.syncing`); + } + return {isBroken: false, message: getSyncStatusMessage({syncingMessage, successfulDate: card.successfulDate, translate, datetimeToCalendarTime})}; + }; + + const getTitle = (card: MergeProviderCardDescriptor) => { + // Some providers offer both an HR and an ATS integration, so both listings get a suffix to tell them apart + if (!isRecruitingBetaEnabled) { + return card.displayName; + } + return translate(card.category === CONST.POLICY.CONNECTIONS.CATEGORY.HR ? 'workspace.connections.hrisListing' : 'workspace.connections.atsListing', card.displayName); + }; + + const toListings = (categoryCards: MergeProviderCardDescriptor[]): ConnectionListing[] => + categoryCards.map((card) => ({ + key: card.key, + category: CONST.TAB.CONNECTIONS.PEOPLE, + title: getTitle(card), + icon: card.icon, + status: card.isConnected ? getConnectedStatus(card) : undefined, + onConnect: () => connect(card, categoryCards), + onConfigure: () => Navigation.navigate(CATEGORY_CONFIG[card.category].getConfigureRoute(policyID)), + })); + + return [...toListings(hrCards), ...toListings(recruitingCards)]; +} + +export default useMergeConnectionListings; diff --git a/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts new file mode 100644 index 000000000000..6f6ea577fcaf --- /dev/null +++ b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts @@ -0,0 +1,78 @@ +/** + * Builds the Travel & Delivery listings (receipt partners such as Uber for Business) for the Connections page. + */ +import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; +import useLocalize from '@hooks/useLocalize'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import usePrevious from '@hooks/usePrevious'; + +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import Navigation from '@libs/Navigation/Navigation'; + +import {openExternalLink} from '@userActions/Link'; +import {enablePolicyReceiptPartners} from '@userActions/Policy/Policy'; + +import CONST from '@src/CONST'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {Policy} from '@src/types/onyx'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {useEffect} from 'react'; + +import type {ConnectionListing} from './types'; + +function useReceiptPartnerConnectionListings(policy: OnyxEntry): ConnectionListing[] { + const policyID = policy?.id; + const {translate} = useLocalize(); + const {getReceiptPartnersIntegrationData, shouldShowEnterCredentialsError, isUberConnected} = useGetReceiptPartnersIntegrationData(policyID); + const {canWrite, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); + const prevIsUberConnected = usePrevious(isUberConnected); + + // The Uber connection finishes outside the app, so the invite flow opens once the connection shows up here + useEffect(() => { + if (!policyID || !isUberConnected || prevIsUberConnected || !canWrite) { + return; + } + Navigation.navigate( + createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_RECEIPT_PARTNERS_INVITE.getRoute(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER), ROUTES.WORKSPACE_RECEIPT_PARTNERS.getRoute(policyID)), + ); + }, [prevIsUberConnected, isUberConnected, policyID, canWrite]); + + const uberData = getReceiptPartnersIntegrationData(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER); + if (!policyID || !uberData) { + return []; + } + + const connectUber = () => { + if (!canWrite) { + showReadOnlyModal(); + return; + } + if (!policy?.receiptPartners?.enabled) { + enablePolicyReceiptPartners(policyID, true); + } + openExternalLink(`${CONST.UBER_CONNECT_URL}?${policy?.receiptPartners?.uber?.connectFormData}`); + }; + + const isConnected = isUberConnected || shouldShowEnterCredentialsError; + + return [ + { + key: CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER, + category: CONST.TAB.CONNECTIONS.TRAVEL, + title: uberData.title, + icon: uberData.icon, + status: isConnected + ? { + isBroken: shouldShowEnterCredentialsError, + message: shouldShowEnterCredentialsError ? translate('workspace.connections.brokenConnection') : uberData.description, + } + : undefined, + onConnect: connectUber, + onConfigure: () => Navigation.navigate(ROUTES.WORKSPACE_RECEIPT_PARTNERS.getRoute(policyID)), + }, + ]; +} + +export default useReceiptPartnerConnectionListings; diff --git a/src/pages/workspace/connections/utils.ts b/src/pages/workspace/connections/utils.ts new file mode 100644 index 000000000000..1845366e9241 --- /dev/null +++ b/src/pages/workspace/connections/utils.ts @@ -0,0 +1,66 @@ +/** + * Helpers shared by the Connections page listing builders. + */ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + +import CONST from '@src/CONST'; +import type {MergeHRProviderSlug} from '@src/CONST/MERGE_HR_PROVIDERS'; + +import type {ConnectionListing, ConnectionsTab} from './types'; + +const MCP_CONNECTOR = { + CLAUDE: 'claude', + CHATGPT: 'chatgpt', + CURSOR: 'cursor', +} as const; + +/** Matches the key `getHRCards` gives each Merge HR provider */ +function getMergeHRListingKey(slug: MergeHRProviderSlug) { + return `merge_${slug}`; +} + +/** Listing keys shown on the Popular tab, in display order */ +const POPULAR_LISTING_KEYS: string[] = [ + CONST.POLICY.CONNECTIONS.NAME.QBO, + CONST.POLICY.CONNECTIONS.NAME.XERO, + CONST.POLICY.CONNECTIONS.NAME.NETSUITE, + CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER, + MCP_CONNECTOR.CLAUDE, + MCP_CONNECTOR.CHATGPT, + CONST.POLICY.CONNECTIONS.NAME.GUSTO, + getMergeHRListingKey('bamboohr'), +]; + +type GetSyncStatusMessageParams = { + /** Shown instead of the last sync time while a sync is running */ + syncingMessage?: string; + + /** ISO datetime of the last successful sync, undefined if the connection never synced */ + successfulDate?: string; + + translate: LocaleContextProps['translate']; + + datetimeToCalendarTime: LocaleContextProps['datetimeToCalendarTime']; +}; + +function getSyncStatusMessage({syncingMessage, successfulDate, translate, datetimeToCalendarTime}: GetSyncStatusMessageParams): string { + if (syncingMessage) { + return syncingMessage; + } + if (!successfulDate) { + return translate('workspace.accounting.notSync'); + } + return translate('workspace.connections.synced', datetimeToCalendarTime(successfulDate, false, true)); +} + +function getListingsForTab(listings: ConnectionListing[], tab: ConnectionsTab): ConnectionListing[] { + if (tab === CONST.TAB.CONNECTIONS.ALL) { + return listings; + } + if (tab === CONST.TAB.CONNECTIONS.POPULAR) { + return POPULAR_LISTING_KEYS.map((key) => listings.find((listing) => listing.key === key)).filter((listing): listing is ConnectionListing => !!listing); + } + return listings.filter((listing) => listing.category === tab); +} + +export {MCP_CONNECTOR, getSyncStatusMessage, getListingsForTab}; diff --git a/src/pages/workspace/getWorkspaceMenuItems.ts b/src/pages/workspace/getWorkspaceMenuItems.ts index e2eb8a0b85af..8d6f647ae218 100644 --- a/src/pages/workspace/getWorkspaceMenuItems.ts +++ b/src/pages/workspace/getWorkspaceMenuItems.ts @@ -4,20 +4,17 @@ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; -import {isAnyHRConnected, isMergeHRCompleteSetupNeeded, shouldShowHRConnectionError} from '@libs/merge/HRUtils'; -import {isAnyRecruitingConnected} from '@libs/merge/RecruitingUtils'; +import {isMergeHRCompleteSetupNeeded, shouldShowHRConnectionError} from '@libs/merge/HRUtils'; import {getObjectKeys} from '@libs/ObjectUtils'; import { arePolicyRulesEnabled, canMemberRead, canPolicyAccessFeature, - hasAccountingFeatureConnection, hasPolicyCategoriesError, hasPolicyRulesError, hasVendorFeature, isGroupPolicy, isMatchingVendorListLoaded, - isMCPEnabled, isPerDiemEnabled, isPolicyAdmin, isTimeTrackingEnabled, @@ -51,8 +48,7 @@ type WorkspaceMenuIconMap = Record< | 'Users' | 'Hashtag' | 'Document' - | 'Sync' - | 'Receipt' + | 'Connect' | 'Briefcase' | 'Folder' | 'Tag' @@ -66,9 +62,7 @@ type WorkspaceMenuIconMap = Record< | 'Clock' | 'InvoiceGeneric' | 'Gear' - | 'Bolt' - | 'Bot' - | 'UserPlus', + | 'Bolt', IconAsset >; @@ -108,8 +102,6 @@ type GetWorkspaceMenuItemsParams = { shouldShowRBR?: boolean; /** Whether the vendor matching beta is enabled. */ isVendorMatchingBetaEnabled?: boolean; - /** Whether the Merge ATS beta gating the Recruiting feature is enabled. */ - isRecruitingBetaEnabled?: boolean; /** Formats the invoice account balance for its menu badge. */ convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; }; @@ -125,7 +117,6 @@ function getWorkspaceMenuItems({ shouldShowEnterCredentialsError = false, shouldShowRBR = false, isVendorMatchingBetaEnabled = false, - isRecruitingBetaEnabled = false, convertToDisplayString, }: GetWorkspaceMenuItemsParams): WorkspaceMenuItem[] { const canReadPolicyFeature = (policyFeature: PolicyFeature) => canMemberRead(policy, currentUserLogin ?? '', policyFeature); @@ -148,8 +139,8 @@ function getWorkspaceMenuItems({ const accountingConnectionNames = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES; const hasSyncError = shouldShowSyncError(policy, isConnectionInProgress, accountingConnectionNames); const hasHRError = shouldShowHRConnectionError(policy, isConnectionInProgress, isPolicyAdmin(policy)); - const getHRBrickRoadIndicator = () => { - if (hasHRError) { + const getConnectionsBrickRoadIndicator = () => { + if (hasSyncError || shouldShowQBOReimbursableExportDestinationAccountError(policy) || hasHRError || shouldShowEnterCredentialsError) { return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; } if (isMergeHRCompleteSetupNeeded(policy)) { @@ -171,19 +162,11 @@ function getWorkspaceMenuItems({ [CONST.POLICY.MORE_FEATURES.ARE_TAGS_ENABLED]: policy?.areTagsEnabled, [CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED]: policy?.tax?.trackingEnabled, [CONST.POLICY.MORE_FEATURES.ARE_COMPANY_CARDS_ENABLED]: policy?.areCompanyCardsEnabled, - [CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED]: !!policy?.areConnectionsEnabled || hasAccountingFeatureConnection(policy), - [CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED]: (policy?.isHREnabled === true || isAnyHRConnected(policy)) && canPolicyAccessFeature(policy, CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED), - [CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED]: - isRecruitingBetaEnabled && - (policy?.isRecruitingEnabled === true || isAnyRecruitingConnected(policy)) && - canPolicyAccessFeature(policy, CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED), [CONST.POLICY.MORE_FEATURES.ARE_EXPENSIFY_CARDS_ENABLED]: policy?.areExpensifyCardsEnabled, [CONST.POLICY.MORE_FEATURES.ARE_REPORT_FIELDS_ENABLED]: policy?.areReportFieldsEnabled, [CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED]: arePolicyRulesEnabled(policy, policyCategories), [CONST.POLICY.MORE_FEATURES.ARE_INVOICES_ENABLED]: policy?.areInvoicesEnabled, [CONST.POLICY.MORE_FEATURES.ARE_PER_DIEM_RATES_ENABLED]: isPerDiemEnabled(policy) && canPolicyAccessFeature(policy, CONST.POLICY.MORE_FEATURES.ARE_PER_DIEM_RATES_ENABLED), - [CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED]: policy?.receiptPartners?.enabled ?? false, - [CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED]: isMCPEnabled(policy), [CONST.POLICY.MORE_FEATURES.IS_TRAVEL_ENABLED]: policy?.isTravelEnabled, [CONST.POLICY.MORE_FEATURES.IS_TIME_TRACKING_ENABLED]: isTimeTrackingEnabled(policy), }; @@ -226,61 +209,14 @@ function getWorkspaceMenuItems({ }); } - if (policyFeatureStates[CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED] && canReadPolicyFeature(CONST.POLICY.POLICY_FEATURE.ACCOUNTING)) { + if (canReadPolicyFeature(CONST.POLICY.POLICY_FEATURE.ACCOUNTING) || canReadMoreFeatures) { items.push({ - translationKey: 'workspace.common.accounting', - icon: icons.Sync, - getRoute: () => ROUTES.POLICY_ACCOUNTING.getRoute(policyID), - brickRoadIndicator: hasSyncError || shouldShowQBOReimbursableExportDestinationAccountError(policy) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - screenName: SCREENS.WORKSPACE.ACCOUNTING.ROOT, - sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.ACCOUNTING, - highlighted: highlightedPolicyFeature === CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED, - }); - } - - if (policyFeatureStates[CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED] && canReadMoreFeatures) { - items.push({ - translationKey: 'workspace.common.hr', - icon: icons.Users, - getRoute: () => ROUTES.WORKSPACE_HR.getRoute(policyID), - brickRoadIndicator: getHRBrickRoadIndicator(), - screenName: SCREENS.WORKSPACE.HR, - sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.HR, - highlighted: highlightedPolicyFeature === CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED, - }); - } - - if (policyFeatureStates[CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED] && canReadMoreFeatures) { - items.push({ - translationKey: 'workspace.common.recruiting', - icon: icons.UserPlus, - getRoute: () => ROUTES.WORKSPACE_RECRUITING.getRoute(policyID), - screenName: SCREENS.WORKSPACE.RECRUITING, - sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.RECRUITING, - highlighted: highlightedPolicyFeature === CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED, - }); - } - - if (policyFeatureStates[CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED] && canReadMoreFeatures) { - items.push({ - translationKey: 'workspace.common.receiptPartners', - icon: icons.Receipt, - getRoute: () => ROUTES.WORKSPACE_RECEIPT_PARTNERS.getRoute(policyID), - brickRoadIndicator: shouldShowEnterCredentialsError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - screenName: SCREENS.WORKSPACE.RECEIPT_PARTNERS, - sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.RECEIPT_PARTNERS, - highlighted: highlightedPolicyFeature === CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED, - }); - } - - if (policyFeatureStates[CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED] && canReadMoreFeatures) { - items.push({ - translationKey: 'workspace.common.mcp', - icon: icons.Bot, - getRoute: () => ROUTES.WORKSPACE_MCP.getRoute(policyID), - screenName: SCREENS.WORKSPACE.MCP, - sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.MCP, - highlighted: highlightedPolicyFeature === CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED, + translationKey: 'workspace.common.connections', + icon: icons.Connect, + getRoute: () => ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID), + brickRoadIndicator: getConnectionsBrickRoadIndicator(), + screenName: SCREENS.WORKSPACE.CONNECTIONS, + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.CONNECTIONS, }); } From 94bfd5bd8ad9c992dbd1947a65e967f805e9c970 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 25 Sep 2026 15:11:39 +0530 Subject: [PATCH 02/51] Turn integration pages into Configure panels Signed-off-by: krishna2323 --- .../accounting/PolicyAccountingPage.tsx | 420 ++---------------- src/pages/workspace/hr/WorkspaceHRPage.tsx | 4 +- src/pages/workspace/mcp/WorkspaceMCPPage.tsx | 128 ------ .../merge/MergeConnectionsPageBase.tsx | 150 ++----- .../workspace/merge/MergeProviderCard.tsx | 6 +- .../WorkspaceReceiptPartnersPage.tsx | 229 ++++------ .../recruiting/WorkspaceRecruitingPage.tsx | 19 +- 7 files changed, 166 insertions(+), 790 deletions(-) delete mode 100644 src/pages/workspace/mcp/WorkspaceMCPPage.tsx diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index 57dcfe3870d4..8e34d5ad7f7a 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -1,18 +1,11 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import Button from '@components/Button'; -import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; -import CollapsibleSection from '@components/CollapsibleSection'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import Icon from '@components/Icon'; import MenuItem from '@components/MenuItem'; -import MenuItemList from '@components/MenuItemList'; -import type {MenuItemWithLink} from '@components/MenuItemList'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; -import Section from '@components/Section'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import ThreeDotsMenu from '@components/ThreeDotsMenu'; @@ -30,8 +23,6 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; @@ -40,7 +31,6 @@ import {isAuthenticationError, isConnectionInProgress, isConnectionUnverified, r import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; import {isExpensifyCardFullySetUp} from '@libs/CardUtils'; import {getOldDotURLFromEnvironment} from '@libs/Environment/Environment'; -import getPlatform from '@libs/getPlatform'; import { areSettingsInErrorFields, findCurrentXeroOrganization, @@ -51,7 +41,6 @@ import { getXeroTenants, hasAccountingConnections, hasSupportedOnlyOnOldDotIntegration, - isControlPolicy, settingsPendingAction, shouldShowSyncError, } from '@libs/PolicyUtils'; @@ -67,11 +56,9 @@ import {openPolicyExpensifyCardsPage} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {ConnectionName} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {useFocusEffect, useRoute} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useMemo, useState} from 'react'; import {View} from 'react-native'; import type {MenuItemData, PolicyAccountingPageProps} from './types'; @@ -80,13 +67,7 @@ import {AccountingContextProvider, useAccountingActions, useAccountingState} fro import {getCertiniaSelectedCompanyID, isCertiniaFFAConnection} from './certinia/utils'; import {getAccountingIntegrationData, getAccountingIntegrationDisplayName, getSynchronizationErrorMessage, isIntuitEnterpriseSuiteConnection} from './utils'; -type RouteParams = { - newConnectionName?: ConnectionName; - integrationToDisconnect?: ConnectionName; - shouldDisconnectIntegrationBeforeConnecting?: boolean; - isIntuitEnterpriseSuite?: string; -}; - +/** Settings for the workspace's connected accounting integration, opened from the Connections page. */ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { useWorkspaceDocumentTitle(policy?.name, 'workspace.common.accounting'); const hasReusablePoliciesConnectedToSageIntacct = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, policy?.id); @@ -96,32 +77,21 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { const hasReusablePoliciesConnectedToDualEntry = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, policy?.id); const hasReusablePoliciesConnectedToCampfire = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE, policy?.id); const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy?.id}`); - const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); - const theme = useTheme(); const styles = useThemeStyles(); const {translate, datetimeToRelative: getDatetimeToRelative, getLocalDateFromDatetime} = useLocalize(); const {environment} = useEnvironment(); const oldDotEnvironmentURL = getOldDotURLFromEnvironment(environment); const {isOffline} = useNetwork(); const {isBetaEnabled} = usePermissions(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); const {showConfirmModal} = useConfirmModal(); const [datetimeToRelative, setDateTimeToRelative] = useState(''); - const {activeIntegration, popoverAnchorRefs} = useAccountingState(); + const {activeIntegration} = useAccountingState(); const {startIntegrationFlow} = useAccountingActions(); - const [account] = useOnyx(ONYXKEYS.ACCOUNT); - const {isLargeScreenWidth} = useResponsiveLayout(); - const route = useRoute(); - const params = route.params as RouteParams | undefined; - const newConnectionName = params?.newConnectionName; - const integrationToDisconnect = params?.integrationToDisconnect; - const shouldDisconnectIntegrationBeforeConnecting = params?.shouldDisconnectIntegrationBeforeConnecting; - const shouldConnectToIntuitEnterpriseSuite = params?.isIntuitEnterpriseSuite === 'true'; const policyID = policy?.id; const workspaceAccountID = useWorkspaceAccountID(policyID); const allCardSettings = useExpensifyCardFeeds(policyID); const isSyncInProgress = isConnectionInProgress(connectionSyncProgress, policy); - const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'CircularArrowBackwards', 'ExpensifyCard', 'Gear', 'Key', 'NewWindow', 'Pencil', 'QuestionMark', 'Send', 'Sync', 'Trashcan']); + const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'CircularArrowBackwards', 'ExpensifyCard', 'Gear', 'Key', 'NewWindow', 'Pencil', 'Send', 'Sync', 'Trashcan']); const accountingIcons = useMemoizedLazyExpensifyIcons([ 'IntacctSquare', 'IntuitSquare', @@ -154,14 +124,6 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { }), [canUseCampfireIntegration, canUseBusinessCentralIntegration], ); - const accountingIntegrationOptions = useMemo( - () => - accountingIntegrations.flatMap((name) => [ - {name, isIntuitEnterpriseSuite: name === CONST.POLICY.CONNECTIONS.NAME.QBO ? false : undefined}, - ...(name === CONST.POLICY.CONNECTIONS.NAME.QBO ? [{name, isIntuitEnterpriseSuite: true}] : []), - ]), - [accountingIntegrations], - ); const syncingAccountingIntegration = accountingIntegrations.find((integration) => integration === connectionSyncProgress?.connectionName); const connectedIntegration = getConnectedIntegration(policy, accountingIntegrations) ?? syncingAccountingIntegration; const isIntuitEnterpriseSuiteSyncInProgress = isSyncInProgress && activeIntegration?.name === CONST.POLICY.CONNECTIONS.NAME.QBO && activeIntegration.isIntuitEnterpriseSuite === true; @@ -169,7 +131,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBO && (isIntuitEnterpriseSuiteConnection(policy) || isIntuitEnterpriseSuiteSyncInProgress); const connectedIntegrationDisplayName = connectedIntegration ? getAccountingIntegrationDisplayName(policy, connectedIntegration, translate) : undefined; const hasAccountingConnection = hasAccountingConnections(policy); - const {canWrite: canWriteAccounting, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); + const {canWrite: canWriteAccounting} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); const synchronizationError = connectedIntegration && getSynchronizationErrorMessage(policy, connectedIntegration, isSyncInProgress, translate, styles); const isSageIntacct = connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT; @@ -255,6 +217,8 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { if (action !== ModalActions.CONFIRM || !connectedIntegration || !policyID) { return; } + // These settings have nothing to show once the integration is gone + Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)); removePolicyConnection(policy, connectedIntegration); }); }, @@ -283,42 +247,6 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { ], ); - // `startIntegrationFlow` changes identity whenever `policy` does, which re-runs this effect. The - // Navigation.setParams below clears newConnectionName through a navigation state update that lands in a later - // render, so that re-run can still see the param set. Key the guard on the value to start the flow only once. - const startedIntegrationFlowForRef = useRef(undefined); - - useFocusEffect( - useCallback(() => { - if (!newConnectionName || !isControlPolicy(policy) || !canWriteAccounting) { - // Re-arm the guard once the param is gone, so a later round-trip that asks for the same integration - // again is not mistaken for the re-run this guard exists to swallow. - if (!newConnectionName) { - startedIntegrationFlowForRef.current = undefined; - } - return; - } - - if (startedIntegrationFlowForRef.current === newConnectionName) { - return; - } - startedIntegrationFlowForRef.current = newConnectionName; - - startIntegrationFlow({ - name: newConnectionName, - isIntuitEnterpriseSuite: shouldConnectToIntuitEnterpriseSuite, - integrationToDisconnect, - shouldDisconnectIntegrationBeforeConnecting, - }); - Navigation.setParams({ - newConnectionName: undefined, - isIntuitEnterpriseSuite: undefined, - integrationToDisconnect: undefined, - shouldDisconnectIntegrationBeforeConnecting: undefined, - }); - }, [newConnectionName, shouldConnectToIntuitEnterpriseSuite, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting, policy, startIntegrationFlow, canWriteAccounting]), - ); - useEffect(() => { if (successfulDate) { setDateTimeToRelative(getDatetimeToRelative(successfulDate)); @@ -537,93 +465,6 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { ]); const connectionsMenuItems: MenuItemData[] = useMemo(() => { - if (!hasAccountingConnection && !isSyncInProgress && policyID) { - return accountingIntegrationOptions - .map(({name: integration, isIntuitEnterpriseSuite}) => { - const integrationData = getAccountingIntegrationData( - integration, - policyID, - translate, - { - sageIntacct: hasReusablePoliciesConnectedToSageIntacct, - qbd: hasReusablePoliciesConnectedToQBD, - certinia: hasReusablePoliciesConnectedToCertinia, - rillet: hasReusablePoliciesConnectedToRillet, - dualEntry: hasReusablePoliciesConnectedToDualEntry, - campfire: hasReusablePoliciesConnectedToCampfire, - }, - undefined, - undefined, - undefined, - undefined, - undefined, - accountingIcons, - cardFeeds, - cardLists, - isIntuitEnterpriseSuite, - ); - if (!integrationData) { - return undefined; - } - - const isXero = integration === CONST.POLICY.CONNECTIONS.NAME.XERO; - const iconProps = integrationData?.icon - ? { - icon: integrationData.icon, - iconType: CONST.ICON_TYPE_AVATAR, - } - : {}; - - return { - ...iconProps, - interactive: false, - // On native iOS, `accessible={true}` collapses the row and all its descendants into a single accessibility element, - // so VoiceOver focuses the whole row instead of the nested Connect button. Disabling it only on native iOS lets - // VoiceOver focus/activate the button on its own. Other platforms (Android/TalkBack, web, iOS mWeb→WEB) keep grouping. - shouldBeAccessible: getPlatform() !== CONST.PLATFORM.IOS, - wrapperStyle: [styles.sectionMenuItemTopDescription], - shouldShowRightComponent: true, - title: integrationData?.title, - badgeText: isXero ? translate('workspace.accounting.claimOffer.badgeText') : undefined, - onBadgePress: - isXero && canWriteAccounting - ? () => { - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CLAIM_OFFER.getRoute(policyID, CONST.POLICY.CONNECTIONS.NAME.XERO)); - } - : undefined, - badgeStyle: styles.mr3, - isBadgeSuccess: isXero, - shouldShowBadgeBelow: shouldUseNarrowLayout, - rightComponent: ( - { - if (!canWriteAccounting) { - showReadOnlyModal(); - return; - } - startIntegrationFlow({name: integration, isIntuitEnterpriseSuite}); - }} - style={styles.justifyContentCenter} - innerStyles={!canWriteAccounting ? [styles.buttonOpacityDisabled, styles.buttonDisabled] : undefined} - hoverStyles={!canWriteAccounting ? [styles.buttonOpacityDisabled, styles.buttonDisabled] : undefined} - size={CONST.BUTTON_SIZE.SMALL} - ref={(ref) => { - if (!popoverAnchorRefs?.current) { - return; - } - const integrationKey = isIntuitEnterpriseSuite ? CONST.POLICY.CONNECTIONS.ACCOUNTING_INTEGRATION_ALIASES.INTUIT_ENTERPRISE_SUITE : integration; - popoverAnchorRefs.current[integrationKey].current = ref; - }} - sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.ACCOUNTING.SETUP_BUTTON} - > - {translate('workspace.accounting.setup')} - - ), - }; - }) - .filter(Boolean) as MenuItemData[]; - } - if (!connectedIntegration || !policyID) { return []; } @@ -772,20 +613,12 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { styles.pb0, styles.mt5, styles.popoverMenuIcon, - styles.mr3, - styles.justifyContentCenter, - styles.buttonOpacityDisabled, - styles.buttonDisabled, shouldShowCardReconciliationOption, shouldShowSynchronizationError, synchronizationError, overflowMenu, integrationSpecificMenuItems, - accountingIntegrationOptions, isConnectedToIntuitEnterpriseSuite, - shouldUseNarrowLayout, - startIntegrationFlow, - popoverAnchorRefs, datetimeToRelative, hasReusablePoliciesConnectedToSageIntacct, hasReusablePoliciesConnectedToCertinia, @@ -794,130 +627,8 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { hasReusablePoliciesConnectedToCampfire, hasReusablePoliciesConnectedToQBD, canWriteAccounting, - showReadOnlyModal, - ]); - - const otherIntegrationsItems = useMemo(() => { - if ((!hasAccountingConnection && !isSyncInProgress) || !policyID) { - return; - } - const otherIntegrations = accountingIntegrationOptions.filter( - ({name, isIntuitEnterpriseSuite}) => name !== connectedIntegration || !!isIntuitEnterpriseSuite !== isConnectedToIntuitEnterpriseSuite, - ); - return otherIntegrations - .map(({name: integration, isIntuitEnterpriseSuite}) => { - const integrationData = getAccountingIntegrationData( - integration, - policyID, - translate, - { - sageIntacct: hasReusablePoliciesConnectedToSageIntacct, - qbd: hasReusablePoliciesConnectedToQBD, - certinia: hasReusablePoliciesConnectedToCertinia, - rillet: hasReusablePoliciesConnectedToRillet, - dualEntry: hasReusablePoliciesConnectedToDualEntry, - campfire: hasReusablePoliciesConnectedToCampfire, - }, - undefined, - undefined, - undefined, - undefined, - undefined, - accountingIcons, - cardFeeds, - cardLists, - isIntuitEnterpriseSuite, - ); - if (!integrationData) { - return undefined; - } - - const iconProps = integrationData?.icon ? {icon: integrationData.icon, iconType: CONST.ICON_TYPE_AVATAR} : {}; - - return { - ...iconProps, - title: integrationData?.title, - rightComponent: ( - { - if (!canWriteAccounting) { - showReadOnlyModal(); - return; - } - startIntegrationFlow({ - name: integration, - isIntuitEnterpriseSuite, - integrationToDisconnect: connectedIntegration, - shouldDisconnectIntegrationBeforeConnecting: true, - }); - }} - style={styles.justifyContentCenter} - innerStyles={!canWriteAccounting ? [styles.buttonOpacityDisabled, styles.buttonDisabled] : undefined} - hoverStyles={!canWriteAccounting ? [styles.buttonOpacityDisabled, styles.buttonDisabled] : undefined} - size={CONST.BUTTON_SIZE.SMALL} - ref={(r) => { - if (!popoverAnchorRefs?.current) { - return; - } - const integrationKey = isIntuitEnterpriseSuite ? CONST.POLICY.CONNECTIONS.ACCOUNTING_INTEGRATION_ALIASES.INTUIT_ENTERPRISE_SUITE : integration; - popoverAnchorRefs.current[integrationKey].current = r; - }} - sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.ACCOUNTING.SETUP_BUTTON} - > - {translate('workspace.accounting.setup')} - - ), - interactive: false, - // On native iOS, `accessible={true}` collapses the row and all its descendants into a single accessibility element, - // so VoiceOver focuses the whole row instead of the nested Connect button. Disabling it only on native iOS lets - // VoiceOver focus/activate the button on its own. Other platforms (Android/TalkBack, web, iOS mWeb→WEB) keep grouping. - shouldBeAccessible: getPlatform() !== CONST.PLATFORM.IOS, - shouldShowRightComponent: true, - wrapperStyle: styles.sectionMenuItemTopDescription, - }; - }) - .filter(Boolean) as MenuItemWithLink[]; - }, [ - hasAccountingConnection, - isSyncInProgress, - accountingIntegrationOptions, - connectedIntegration, - isConnectedToIntuitEnterpriseSuite, - policyID, - translate, - hasReusablePoliciesConnectedToSageIntacct, - hasReusablePoliciesConnectedToCertinia, - hasReusablePoliciesConnectedToRillet, - hasReusablePoliciesConnectedToDualEntry, - hasReusablePoliciesConnectedToCampfire, - hasReusablePoliciesConnectedToQBD, - styles.justifyContentCenter, - styles.buttonOpacityDisabled, - styles.buttonDisabled, - styles.sectionMenuItemTopDescription, - startIntegrationFlow, - popoverAnchorRefs, - accountingIcons, - cardFeeds, - cardLists, - canWriteAccounting, - showReadOnlyModal, ]); - const [chatTextLink, chatReportID] = useMemo(() => { - // If they have an onboarding specialist assigned display the following and link to the #admins room with the account executive. - if (policy?.chatReportIDAdmins) { - return [translate('workspace.accounting.talkYourOnboardingSpecialist'), policy?.chatReportIDAdmins?.toString()]; - } - - // If not, if they have an account manager assigned display the following and link to the DM with their account manager. - if (account?.accountManagerAccountID) { - return [translate('workspace.accounting.talkYourAccountManager'), account?.accountManagerReportID]; - } - // Else, display the following and link to their Concierge DM. - return [translate('workspace.accounting.talkToConcierge'), conciergeReportID]; - }, [account?.accountManagerAccountID, account?.accountManagerReportID, conciergeReportID, policy?.chatReportIDAdmins, translate]); - const oldDotPolicyConnectionsURL = policyID ? `${oldDotEnvironmentURL}/${CONST.OLDDOT_URLS.POLICY_CONNECTIONS_URL_ENCODED(policyID)}` : ''; return ( @@ -926,96 +637,57 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { policyID={policyID} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} policyFeature={CONST.POLICY.POLICY_FEATURE.ACCOUNTING} + shouldBeBlocked={!connectedIntegration && !hasUnsupportedNDIntegration} > Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} /> - -
- {!hasUnsupportedNDIntegration && - connectionsMenuItems.map((menuItem) => ( - - - - ))} - {hasUnsupportedNDIntegration && hasSyncError && !!policyID && ( - - )} - {hasUnsupportedNDIntegration && !hasSyncError && !!policyID && ( - - - { - // Go to Expensify Classic. - openOldDotLink(CONST.OLDDOT_URLS.POLICY_CONNECTIONS_URL(policyID)); - }} - > - {translate('workspace.accounting.goToODToSettings')} - - - - )} - {!!otherIntegrationsItems && ( - + {!hasUnsupportedNDIntegration && + connectionsMenuItems.map((menuItem) => ( + - - - )} - {!!account?.guideDetails?.email && !hasAccountingConnections(policy) && canWriteAccounting && ( - - - - {translate('workspace.accounting.needAnotherAccounting')} - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(String(chatReportID)))}>{chatTextLink} - - - )} -
+ + ))} + {hasUnsupportedNDIntegration && hasSyncError && !!policyID && ( + + )} + {hasUnsupportedNDIntegration && !hasSyncError && !!policyID && ( + + + { + // Go to Expensify Classic. + openOldDotLink(CONST.OLDDOT_URLS.POLICY_CONNECTIONS_URL(policyID)); + }} + > + {translate('workspace.accounting.goToODToSettings')} + + + + )}
diff --git a/src/pages/workspace/hr/WorkspaceHRPage.tsx b/src/pages/workspace/hr/WorkspaceHRPage.tsx index c93994116dec..a158c9516ef7 100644 --- a/src/pages/workspace/hr/WorkspaceHRPage.tsx +++ b/src/pages/workspace/hr/WorkspaceHRPage.tsx @@ -5,7 +5,7 @@ import {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; import usePolicy from '@hooks/usePolicy'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import MergeConnectionsPageBase from '@pages/workspace/merge/MergeConnectionsPageBase'; @@ -17,7 +17,7 @@ import React from 'react'; import {getHRCards} from './utils'; -type WorkspaceHRPageProps = PlatformStackScreenProps; +type WorkspaceHRPageProps = PlatformStackScreenProps; function WorkspaceHRPage({ route: { diff --git a/src/pages/workspace/mcp/WorkspaceMCPPage.tsx b/src/pages/workspace/mcp/WorkspaceMCPPage.tsx deleted file mode 100644 index 440b26b752ac..000000000000 --- a/src/pages/workspace/mcp/WorkspaceMCPPage.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import Button from '@components/Button'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import Icon from '@components/Icon'; -import MenuItem from '@components/MenuItem'; -import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; -import Section from '@components/Section'; -import Text from '@components/Text'; -import TextLink from '@components/TextLink'; - -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useLocalize from '@hooks/useLocalize'; -import usePolicy from '@hooks/usePolicy'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; - -import Navigation from '@navigation/Navigation'; -import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; -import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; - -import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; - -import variables from '@styles/variables'; - -import {openExternalLink} from '@userActions/Link'; - -import CONST from '@src/CONST'; -import type SCREENS from '@src/SCREENS'; - -import React from 'react'; -import {View} from 'react-native'; - -type WorkspaceMCPPageProps = PlatformStackScreenProps; - -function WorkspaceMCPPage({route}: WorkspaceMCPPageProps) { - const policyID = route.params.policyID; - const {translate} = useLocalize(); - const styles = useThemeStyles(); - const theme = useTheme(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); - const icons = useMemoizedLazyExpensifyIcons(['ChatGPTSquare', 'ClaudeSquare', 'CursorSquare', 'QuestionMark']); - const policy = usePolicy(policyID); - - useWorkspaceDocumentTitle(policy?.name, 'workspace.common.mcp'); - - const connectors = [ - {icon: icons.ChatGPTSquare, title: translate('workspace.mcp.chatgpt.title'), description: translate('workspace.mcp.chatgpt.subtitle'), url: CONST.CHATGPT_CONNECT_URL}, - {icon: icons.ClaudeSquare, title: translate('workspace.mcp.claude.title'), description: translate('workspace.mcp.claude.subtitle'), url: CONST.CLAUDE_CONNECT_URL}, - {icon: icons.CursorSquare, title: translate('workspace.mcp.cursor.title'), description: translate('workspace.mcp.cursor.subtitle'), url: CONST.CURSOR_MCP_HELP_URL}, - ]; - - return ( - - - Navigation.goBack()} - /> - - -
- {connectors.map((connector) => ( - openExternalLink(connector.url)} - style={styles.justifyContentCenter} - size={CONST.BUTTON_SIZE.SMALL} - > - {translate('workspace.mcp.connect')} - - } - /> - ))} - - - - {translate('workspace.mcp.helpPrompt')} openExternalLink(CONST.MCP_HELP_URL)}>{translate('workspace.mcp.helpLink')} - - -
-
-
-
-
- ); -} - -export default WorkspaceMCPPage; diff --git a/src/pages/workspace/merge/MergeConnectionsPageBase.tsx b/src/pages/workspace/merge/MergeConnectionsPageBase.tsx index 5bd34ee2eac4..58ec68beeb57 100644 --- a/src/pages/workspace/merge/MergeConnectionsPageBase.tsx +++ b/src/pages/workspace/merge/MergeConnectionsPageBase.tsx @@ -1,35 +1,25 @@ -import CollapsibleSection from '@components/CollapsibleSection'; import ConnectToMergeFlow from '@components/ConnectToMergeFlow'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; -import CompactSearchBar from '@components/SearchBar/CompactSearchBar'; -import Section from '@components/Section'; -import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import usePolicy from '@hooks/usePolicy'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useSearchResults from '@hooks/useSearchResults'; -import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; import {openPolicyHRPage, openPolicyRecruitingPage} from '@libs/actions/PolicyConnections'; import {isMergeConnectionName} from '@libs/merge/MergeUtils'; import Navigation from '@libs/Navigation/Navigation'; -import tokenizedSearch from '@libs/tokenizedSearch'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import variables from '@styles/variables'; - import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; import React, {useEffect, useState} from 'react'; -import {View} from 'react-native'; import type {MergeProviderCardCategory, MergeProviderCardDescriptor} from './types'; @@ -58,11 +48,8 @@ type MergeConnectionsPageBaseContentProps = { /** Which page this is. Picks the category-specific copy, feature flag, and data-fetching command. */ category: MergeProviderCardCategory; - /** Provider cards to list, already built by the category's `utils`. */ + /** Provider cards built by the category's `utils`. Only the connected one is shown. */ cards: MergeProviderCardDescriptor[]; - - /** Category-specific content rendered under the provider list while nothing is connected, e.g. what to do when the provider isn't listed. */ - footer?: React.ReactNode; }; type MergeConnectionsPageBaseProps = MergeConnectionsPageBaseContentProps & { @@ -70,36 +57,20 @@ type MergeConnectionsPageBaseProps = MergeConnectionsPageBaseContentProps & { shouldBeBlocked?: boolean; }; -function MergeConnectionsPageBaseContent({policyID, category, cards, footer}: MergeConnectionsPageBaseContentProps) { - const {translate, localeCompare} = useLocalize(); +function MergeConnectionsPageBaseContent({policyID, category, cards}: MergeConnectionsPageBaseContentProps) { + const {translate} = useLocalize(); const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); const policy = usePolicy(policyID); const [activeSetupFlow, setActiveSetupFlow] = useState<{setupLink: string; key: number} | undefined>(); - const {showConfirmModal} = useConfirmModal(); const {testID} = PAGE_CONFIG[category]; - const connectedCards: MergeProviderCardDescriptor[] = []; - const disconnectedCards: MergeProviderCardDescriptor[] = []; - for (const card of cards) { - (card.isConnected ? connectedCards : disconnectedCards).push(card); - } // At most one provider of a category can be connected to a workspace at a time. - const connectedConnectionName = connectedCards.at(0)?.connectionName; - const byName = (a: MergeProviderCardDescriptor, b: MergeProviderCardDescriptor) => localeCompare(a.displayName, b.displayName); - connectedCards.sort(byName); - disconnectedCards.sort(byName); - - const filterCard = (card: MergeProviderCardDescriptor, searchInput: string) => { - return tokenizedSearch([card], searchInput, (c) => [c.displayName]).length > 0; - }; - const [inputValue, setInputValue, filteredDisconnectedCards] = useSearchResults(disconnectedCards, filterCard); + const connectedCard = cards.find((card) => card.isConnected); const {canWrite: canWriteMoreFeatures, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); - const handleConnect = (card: MergeProviderCardDescriptor) => { + const handleReconnect = (card: MergeProviderCardDescriptor) => { if (!card.setupLink) { return; } @@ -109,59 +80,25 @@ function MergeConnectionsPageBaseContent({policyID, category, cards, footer}: Me return; } - if (!card.isConnected && connectedCards.length > 0) { - showConfirmModal({ - title: translate(`workspace.${category}.alreadyConnectedTitle`), - prompt: translate(`workspace.${category}.alreadyConnectedPrompt`), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - innerContainerStyle: shouldUseNarrowLayout ? undefined : StyleUtils.getWidthStyle(variables.wideConfirmModalWidth), - }); - return; - } - - // eslint-disable-next-line react-hooks/purity -- random key forces remount on every press, even for the same provider + // A random key forces a remount on every press, even for the same provider setActiveSetupFlow({setupLink: card.setupLink, key: Math.random()}); }; - const maybeSearchBar = disconnectedCards.length >= CONST.STANDARD_LIST_ITEM_LIMIT && ( - - ); - const disconnectedProviderCards = filteredDisconnectedCards.map((card) => ( - handleConnect(card)} - canWriteMoreFeatures={canWriteMoreFeatures} - showReadOnlyModal={showReadOnlyModal} - /> - )); - return ( - {!!connectedConnectionName && ( + {!!connectedCard && ( )} - {!!connectedConnectionName && isMergeConnectionName(connectedConnectionName) && ( + {!!connectedCard && isMergeConnectionName(connectedCard.connectionName) && ( )} {!!activeSetupFlow && ( @@ -173,63 +110,29 @@ function MergeConnectionsPageBaseContent({policyID, category, cards, footer}: Me /> )} Navigation.goBack()} + title={connectedCard?.displayName ?? translate(`workspace.${category}.title`)} + onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} /> - -
- {connectedCards.map((card) => ( - handleConnect(card)} - canWriteMoreFeatures={canWriteMoreFeatures} - showReadOnlyModal={showReadOnlyModal} - /> - ))} - {connectedCards.length === 0 && ( - <> - {maybeSearchBar} - {disconnectedProviderCards} - {footer} - - )} - - {connectedCards.length > 0 && disconnectedCards.length > 0 && !connectedCards.some((c) => c.isInitialSyncInProgress) && ( - - {maybeSearchBar} - {disconnectedProviderCards} - - )} -
-
+ {!!connectedCard && ( + handleReconnect(connectedCard)} + onDisconnect={() => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} + canWriteMoreFeatures={canWriteMoreFeatures} + showReadOnlyModal={showReadOnlyModal} + /> + )}
); } -function MergeConnectionsPageBase({policyID, category, cards, footer, shouldBeBlocked}: MergeConnectionsPageBaseProps) { +function MergeConnectionsPageBase({policyID, category, cards, shouldBeBlocked}: MergeConnectionsPageBaseProps) { const {featureName, openPage} = PAGE_CONFIG[category]; useWorkspaceDocumentTitle(undefined, `workspace.common.${category}`); @@ -246,13 +149,12 @@ function MergeConnectionsPageBase({policyID, category, cards, footer, shouldBeBl policyID={policyID} featureName={featureName} policyFeature={CONST.POLICY.POLICY_FEATURE.MORE_FEATURES} - shouldBeBlocked={shouldBeBlocked} + shouldBeBlocked={(shouldBeBlocked ?? false) || !cards.some((card) => card.isConnected)} > ); diff --git a/src/pages/workspace/merge/MergeProviderCard.tsx b/src/pages/workspace/merge/MergeProviderCard.tsx index 8e6e93b918f3..3234bcbac2fc 100644 --- a/src/pages/workspace/merge/MergeProviderCard.tsx +++ b/src/pages/workspace/merge/MergeProviderCard.tsx @@ -42,6 +42,9 @@ type MergeProviderCardProps = { /** Callback invoked when the user taps the "Connect" or "Reconnect" button. */ handleConnect: () => void; + /** Called once the user confirms disconnecting, right before the connection is removed */ + onDisconnect?: () => void; + /** Whether the current user can edit this Merge connection. */ canWriteMoreFeatures: boolean; @@ -49,7 +52,7 @@ type MergeProviderCardProps = { showReadOnlyModal: () => void; }; -function MergeProviderCard({card, policy, handleConnect, canWriteMoreFeatures, showReadOnlyModal}: MergeProviderCardProps) { +function MergeProviderCard({card, policy, handleConnect, onDisconnect, canWriteMoreFeatures, showReadOnlyModal}: MergeProviderCardProps) { const {translate, datetimeToRelative} = useLocalize(); const styles = useThemeStyles(); const {environmentURL} = useEnvironment(); @@ -144,6 +147,7 @@ function MergeProviderCard({card, policy, handleConnect, canWriteMoreFeatures, s if (result?.action !== ModalActions.CONFIRM || !policy) { return; } + onDisconnect?.(); removePolicyConnection(policy, card.connectionName); }); }, diff --git a/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx b/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx index 750f3f3814f2..1f2e015c42f8 100644 --- a/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx +++ b/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx @@ -1,4 +1,3 @@ -import Button from '@components/Button'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; @@ -7,7 +6,6 @@ import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; -import Section from '@components/Section'; import ThreeDotsMenu from '@components/ThreeDotsMenu'; import useConfirmModal from '@hooks/useConfirmModal'; @@ -17,7 +15,6 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import usePolicy from '@hooks/usePolicy'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; -import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScreenBoundDynamicRoute from '@hooks/useScreenBoundDynamicRoute'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -25,7 +22,7 @@ import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; import Navigation from '@navigation/Navigation'; import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; -import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; +import type {SettingsNavigatorParamList} from '@navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {MenuItemData} from '@pages/workspace/accounting/types'; @@ -46,7 +43,7 @@ import {View} from 'react-native'; import getSynchronizationErrorMessage from './utils'; -type WorkspaceReceiptPartnersPageProps = PlatformStackScreenProps; +type WorkspaceReceiptPartnersPageProps = PlatformStackScreenProps; function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps) { const policyID = route.params.policyID; @@ -67,9 +64,7 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps const isAutoRemove = !!integrations?.uber?.autoRemove; const isAutoInvite = !!integrations?.uber?.autoInvite; const centralBillingAccountEmail = !!integrations?.uber?.centralBillingAccountEmail; - // Track focus and connection change to route to the invite flow once after successful connection - const prevIsUberConnected = usePrevious(isUberConnected); - const {canWrite: canWriteMoreFeatures, showReadOnlyModal, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); + const {canWrite: canWriteMoreFeatures, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); const startIntegrationFlow = useCallback( ({name}: {name: string}) => { @@ -97,14 +92,6 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // When Uber connection status flips from false -> true, navigate to the invite flow once - useEffect(() => { - if (!isUberConnected || prevIsUberConnected || !canWriteMoreFeatures) { - return; - } - Navigation.navigate(buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_RECEIPT_PARTNERS_INVITE.getRoute(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER))); - }, [prevIsUberConnected, isUberConnected, policyID, canWriteMoreFeatures, buildDynamicRoute]); - const calculateAndSetThreeDotsMenuPosition = useCallback(() => { if (shouldUseNarrowLayout) { return Promise.resolve({horizontal: 0, vertical: 0}); @@ -173,6 +160,8 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps if (action !== ModalActions.CONFIRM) { return; } + // These settings have nothing to show once Uber is disconnected + Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)); disconnectPartner(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER); }); }, @@ -183,7 +172,7 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps return []; } }, - [icons.Key, icons.NewWindow, icons.Trashcan, shouldShowEnterCredentialsError, translate, isOffline, startIntegrationFlow, showConfirmModal, disconnectPartner], + [icons.Key, icons.NewWindow, icons.Trashcan, shouldShowEnterCredentialsError, translate, isOffline, startIntegrationFlow, showConfirmModal, disconnectPartner, policyID], ); const connectionsMenuItems: MenuItemData[] = useMemo(() => { @@ -203,9 +192,8 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps } : {}; - const isUber = integration === CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER; let rightComponent: React.ReactNode; - if (canWriteMoreFeatures && (isUberConnected || shouldShowEnterCredentialsError)) { + if (canWriteMoreFeatures) { rightComponent = ( ); - } else { - rightComponent = ( - - ); } return { @@ -248,16 +216,6 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps wrapperStyle: [styles.sectionMenuItemTopDescription], shouldShowRightComponent: !!rightComponent, title: integrationData?.title, - badgeText: isUber ? translate('workspace.accounting.claimOffer.badgeText') : undefined, - onBadgePress: - isUber && canWriteMoreFeatures - ? () => { - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CLAIM_OFFER.getRoute(policyID, CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER)); - } - : undefined, - badgeStyle: styles.mr3, - isBadgeSuccess: isUber, - shouldShowBadgeInSeparateRow: shouldUseNarrowLayout, numberOfLinesDescription: 5, titleContainerStyle: [styles.pr2], description: integrationData?.description, @@ -278,14 +236,7 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps shouldShowEnterCredentialsError, translate, styles, - shouldUseNarrowLayout, - isUberConnected, calculateAndSetThreeDotsMenuPosition, - policy?.receiptPartners?.uber, - policy?.isLoadingReceiptPartners, - isOffline, - startIntegrationFlow, - showReadOnlyModal, ]); return ( @@ -294,6 +245,7 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps policyID={policyID} featureName={CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED} policyFeature={CONST.POLICY.POLICY_FEATURE.MORE_FEATURES} + shouldBeBlocked={!isLoading && !isUberConnected && !shouldShowEnterCredentialsError} > {isLoading ? ( Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} /> - -
- {connectionsMenuItems.map((menuItem) => ( - + {connectionsMenuItems.map((menuItem) => ( + + - + {...menuItem} + /> + + ))} + {isUberConnected && ( + <> + + + + - ))} - {isUberConnected && ( - <> - - - - - - - - - - - {centralBillingAccountEmail && ( - - - Navigation.navigate( - ROUTES.WORKSPACE_RECEIPT_PARTNERS_CHANGE_BILLING_ACCOUNT.getRoute(policyID, CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER), - ) - : undefined - } - /> - - )} - {canWriteMoreFeatures && ( - - Navigation.navigate( - buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_RECEIPT_PARTNERS_INVITE_EDIT.getRoute(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER)), - ) + + + + + + {centralBillingAccountEmail && ( + + + Navigation.navigate( + ROUTES.WORKSPACE_RECEIPT_PARTNERS_CHANGE_BILLING_ACCOUNT.getRoute(policyID, CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER), + ) + : undefined } /> - )} - - )} -
+ + )} + {canWriteMoreFeatures && ( + + Navigation.navigate( + buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_RECEIPT_PARTNERS_INVITE_EDIT.getRoute(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER)), + ) + } + /> + )} + + )}
diff --git a/src/pages/workspace/recruiting/WorkspaceRecruitingPage.tsx b/src/pages/workspace/recruiting/WorkspaceRecruitingPage.tsx index 8e0392cb9da9..7383cec20256 100644 --- a/src/pages/workspace/recruiting/WorkspaceRecruitingPage.tsx +++ b/src/pages/workspace/recruiting/WorkspaceRecruitingPage.tsx @@ -1,15 +1,11 @@ -import RenderHTML from '@components/RenderHTML'; - import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useOpenConciergeAnywhere from '@hooks/useOpenConciergeAnywhere'; import usePermissions from '@hooks/usePermissions'; import {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; import usePolicy from '@hooks/usePolicy'; -import useThemeStyles from '@hooks/useThemeStyles'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import MergeConnectionsPageBase from '@pages/workspace/merge/MergeConnectionsPageBase'; @@ -17,11 +13,10 @@ import CONST from '@src/CONST'; import type SCREENS from '@src/SCREENS'; import React from 'react'; -import {View} from 'react-native'; import {getRecruitingCards} from './utils'; -type WorkspaceRecruitingPageProps = PlatformStackScreenProps; +type WorkspaceRecruitingPageProps = PlatformStackScreenProps; function WorkspaceRecruitingPage({ route: { @@ -29,11 +24,9 @@ function WorkspaceRecruitingPage({ }, }: WorkspaceRecruitingPageProps) { const {translate, formatPhoneNumber} = useLocalize(); - const styles = useThemeStyles(); const icons = useMemoizedLazyExpensifyIcons(['Download']); const policy = usePolicy(policyID); const policyEmployeePersonalDetails = usePersonalDetailsByLogins([...Object.keys(policy?.employeeList ?? {})]); - const {openConciergeAnywhere} = useOpenConciergeAnywhere(); const {isBetaEnabled} = usePermissions(); const cards = getRecruitingCards({policy, policyEmployeePersonalDetails, policyID, icons, translate, formatPhoneNumber}); @@ -44,14 +37,6 @@ function WorkspaceRecruitingPage({ category={CONST.POLICY.CONNECTIONS.CATEGORY.RECRUITING} cards={cards} shouldBeBlocked={!isBetaEnabled(CONST.BETAS.MERGE_ATS)} - footer={ - - openConciergeAnywhere()} - /> - - } /> ); } From fb1112977eb08b709e187fd13cb61475e6d04759 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 25 Sep 2026 15:11:58 +0530 Subject: [PATCH 03/51] Send setup flows and onboarding links to Connections Signed-off-by: krishna2323 --- .../ConnectToSageIntacctFlow/index.tsx | 2 +- src/libs/ReportUtils.ts | 2 +- .../hooks/useGettingStartedItems.ts | 2 +- .../NetSuiteSetupPage/index.native.tsx | 2 +- .../netsuite/NetSuiteSetupPage/index.tsx | 2 +- .../NetSuiteExistingConnectionsPage.tsx | 4 ++-- .../QuickBooksDesktopSetupFlowSyncPage.tsx | 2 +- .../qbo/QuickbooksOnlineSetupPage/index.tsx | 2 +- src/pages/workspace/accounting/utils.tsx | 20 +++++++++---------- .../accounting/xero/XeroSetupPage/index.tsx | 2 +- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/components/ConnectToSageIntacctFlow/index.tsx b/src/components/ConnectToSageIntacctFlow/index.tsx index 9cd1d1434f12..a471bb264049 100644 --- a/src/components/ConnectToSageIntacctFlow/index.tsx +++ b/src/components/ConnectToSageIntacctFlow/index.tsx @@ -25,7 +25,7 @@ function ConnectToSageIntacctFlow({policyID}: ConnectToSageIntacctFlowProps) { return; } if (!hasReusablePoliciesConnectedToSageIntacct) { - Navigation.navigate(`${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.SAGE_INTACCT_PREREQUISITES.path}`); + Navigation.navigate(`${ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)}/${DYNAMIC_ROUTES.SAGE_INTACCT_PREREQUISITES.path}`); return; } Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXISTING_CONNECTIONS.getRoute(policyID)); diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 58732c988706..a10e47119713 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -12542,7 +12542,7 @@ function prepareOnboardingOnyxData({ workspaceMoreFeaturesLink: `${environmentURL}/${ROUTES.WORKSPACE_MORE_FEATURES.getRoute(onboardingPolicyID)}`, workspaceConfirmationLink: `${environmentURL}/${createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CONFIRMATION.path, ROUTES.WORKSPACES_LIST.route)}`, testDriveURL: `${environmentURL}/${testDriveURL}`, - workspaceAccountingLink: `${environmentURL}/${ROUTES.POLICY_ACCOUNTING.getRoute(onboardingPolicyID)}`, + workspaceAccountingLink: `${environmentURL}/${ROUTES.WORKSPACE_CONNECTIONS.getRoute(onboardingPolicyID)}`, corporateCardLink: `${environmentURL}/${ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(onboardingPolicyID)}`, }; diff --git a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts index 0ff73a1d9efd..abdd04a76beb 100644 --- a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts +++ b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts @@ -286,7 +286,7 @@ function useGettingStartedItems(): UseGettingStartedItemsResult { label: integrationName ? translate('homePage.gettingStartedSection.connectAccounting', {integrationName}) : translate('homePage.gettingStartedSection.connectAccountingDefault'), subText: translate('homePage.gettingStartedSection.connectAccountingSubText'), isComplete: !!getValidConnectedIntegration(policy) || Object.values(policy?.connections ?? {}).some((conn) => !!conn?.lastSync?.successfulDate), - route: ROUTES.WORKSPACE_ACCOUNTING.getRoute(activePolicyID), + route: ROUTES.WORKSPACE_CONNECTIONS.getRoute(activePolicyID), }); } else if (policy.areCategoriesEnabled) { items.push({ diff --git a/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.native.tsx b/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.native.tsx index cab7a8add383..64c9f7ce052f 100644 --- a/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.native.tsx +++ b/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.native.tsx @@ -24,7 +24,7 @@ function NetSuiteSetupPage({route}: NetSuiteSetupPageProps) { uri={getNetSuiteSetupLink(policyID, accountID)} testID="NetSuiteSetupPage" shouldAppendShortLivedAuthToken - backTo={ROUTES.POLICY_ACCOUNTING.getRoute(policyID)} + backTo={ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)} /> ); } diff --git a/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.tsx b/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.tsx index 2b30f9db5b6d..d67c4ba8d595 100644 --- a/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.tsx +++ b/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.tsx @@ -18,7 +18,7 @@ function NetSuiteSetupPage({route}: NetSuiteSetupPageProps) { // only reached via a deep link, so just return to the workspace accounting page. We wait for the RHP open // transition to finish first, otherwise goBack fires mid-transition and is dropped. const handle = TransitionTracker.runAfterTransitions({ - callback: () => Navigation.goBack(ROUTES.POLICY_ACCOUNTING.getRoute(policyID)), + callback: () => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)), waitForUpcomingTransition: true, }); return () => handle.cancel(); diff --git a/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteExistingConnectionsPage.tsx b/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteExistingConnectionsPage.tsx index 0714a32bff1c..1c7f5fac6392 100644 --- a/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteExistingConnectionsPage.tsx +++ b/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteExistingConnectionsPage.tsx @@ -37,7 +37,7 @@ function NetSuiteExistingConnectionsPage({route}: ExistingConnectionsPageProps) description: date ? translate('workspace.common.lastSyncDate', CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.netsuite, date) : translate('workspace.accounting.netsuite'), onPress: () => { copyExistingPolicyConnection(policy.id, policyID, CONST.POLICY.CONNECTIONS.NAME.NETSUITE); - Navigation.goBack(ROUTES.WORKSPACE_ACCOUNTING.getRoute(policyID)); + Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)); }, }; }); @@ -53,7 +53,7 @@ function NetSuiteExistingConnectionsPage({route}: ExistingConnectionsPageProps) titleStyle={styles.ph5} shouldLoadForEmptyConnection connectionName={CONST.POLICY.CONNECTIONS.NAME.NETSUITE} - onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_ACCOUNTING.getRoute(policyID))} + onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} > Navigation.goBack(ROUTES.POLICY_ACCOUNTING.getRoute(policyID)), + callback: () => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)), waitForUpcomingTransition: true, }); return () => handle.cancel(); diff --git a/src/pages/workspace/accounting/utils.tsx b/src/pages/workspace/accounting/utils.tsx index 719a92c98aa4..4a7707d8338c 100644 --- a/src/pages/workspace/accounting/utils.tsx +++ b/src/pages/workspace/accounting/utils.tsx @@ -99,16 +99,16 @@ function getAccountingIntegrationData( const netsuiteSelectedSubsidiary = (policy?.connections?.netsuite?.options?.data?.subsidiaryList ?? []).find((subsidiary) => subsidiary.internalID === netsuiteConfig?.subsidiaryID); const getBackToAfterWorkspaceUpgradeRouteForIntacct = () => { if (integrationToDisconnect) { - return ROUTES.POLICY_ACCOUNTING.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); + return ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); } if (existingConnections.sageIntacct) { return ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXISTING_CONNECTIONS.getRoute(policyID); } - return createDynamicRoute(DYNAMIC_ROUTES.SAGE_INTACCT_PREREQUISITES.path, basePath); + return createDynamicRoute(DYNAMIC_ROUTES.SAGE_INTACCT_PREREQUISITES.path, ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)); }; const getBackToAfterWorkspaceUpgradeRouteForQBD = () => { if (integrationToDisconnect) { - return ROUTES.POLICY_ACCOUNTING.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); + return ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); } if (existingConnections.qbd) { return ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXISTING_CONNECTIONS.getRoute(policyID); @@ -117,7 +117,7 @@ function getAccountingIntegrationData( }; const getBackToAfterWorkspaceUpgradeRouteForCertinia = () => { if (integrationToDisconnect) { - return ROUTES.POLICY_ACCOUNTING.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); + return ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); } if (existingConnections.certinia) { return ROUTES.POLICY_ACCOUNTING_CERTINIA_EXISTING_CONNECTIONS.getRoute(policyID); @@ -126,7 +126,7 @@ function getAccountingIntegrationData( }; const getBackToAfterWorkspaceUpgradeRouteForRillet = () => { if (integrationToDisconnect) { - return ROUTES.POLICY_ACCOUNTING.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); + return ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); } if (existingConnections.rillet) { return ROUTES.POLICY_ACCOUNTING_RILLET_EXISTING_CONNECTIONS.getRoute(policyID); @@ -135,7 +135,7 @@ function getAccountingIntegrationData( }; const getBackToAfterWorkspaceUpgradeRouteForDualEntry = () => { if (integrationToDisconnect) { - return ROUTES.POLICY_ACCOUNTING.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); + return ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); } if (existingConnections.dualEntry) { return ROUTES.POLICY_ACCOUNTING_DUALENTRY_EXISTING_CONNECTIONS.getRoute(policyID); @@ -144,7 +144,7 @@ function getAccountingIntegrationData( }; const getBackToAfterWorkspaceUpgradeRouteForCampfire = () => { if (integrationToDisconnect) { - return ROUTES.POLICY_ACCOUNTING.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); + return ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); } if (existingConnections.campfire) { return ROUTES.POLICY_ACCOUNTING_CAMPFIRE_EXISTING_CONNECTIONS.getRoute(policyID); @@ -209,7 +209,7 @@ function getAccountingIntegrationData( ? { workspaceUpgradeNavigationDetails: { integrationAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.intuitEnterpriseSuite.alias, - backToAfterWorkspaceUpgradeRoute: ROUTES.POLICY_ACCOUNTING.getRoute( + backToAfterWorkspaceUpgradeRoute: ROUTES.WORKSPACE_CONNECTIONS.getRoute( policyID, connectionName, integrationToDisconnect, @@ -325,7 +325,7 @@ function getAccountingIntegrationData( workspaceUpgradeNavigationDetails: { integrationAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.netsuite.alias, backToAfterWorkspaceUpgradeRoute: integrationToDisconnect - ? ROUTES.POLICY_ACCOUNTING.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting) + ? ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting) : ROUTES.POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT.getRoute(policyID, getInitialSubPageForNetsuiteTokenInput(policy)), }, pendingFields: {...netsuiteConfig?.pendingFields, ...policy?.connections?.netsuite?.config?.pendingFields, ...policy?.connections?.netsuite?.options?.config?.pendingFields}, @@ -666,7 +666,7 @@ function getAccountingIntegrationData( workspaceUpgradeNavigationDetails: { integrationAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.businessCentral.alias, backToAfterWorkspaceUpgradeRoute: integrationToDisconnect - ? ROUTES.POLICY_ACCOUNTING.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting) + ? ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting) : ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_PREREQUISITES.getRoute(policyID), }, pendingFields: policy?.connections?.businessCentral?.config?.pendingFields, diff --git a/src/pages/workspace/accounting/xero/XeroSetupPage/index.tsx b/src/pages/workspace/accounting/xero/XeroSetupPage/index.tsx index 9dc2a960fefd..49917e324b83 100644 --- a/src/pages/workspace/accounting/xero/XeroSetupPage/index.tsx +++ b/src/pages/workspace/accounting/xero/XeroSetupPage/index.tsx @@ -18,7 +18,7 @@ function XeroSetupPage({route}: XeroSetupPageProps) { // only reached via a deep link, so just return to the workspace accounting page. We wait for the RHP open // transition to finish first, otherwise goBack fires mid-transition and is dropped. const handle = TransitionTracker.runAfterTransitions({ - callback: () => Navigation.goBack(ROUTES.POLICY_ACCOUNTING.getRoute(policyID)), + callback: () => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)), waitForUpcomingTransition: true, }); return () => handle.cancel(); From 26c16a790d02c23480cce453ff0089d86b749325 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 25 Sep 2026 15:12:13 +0530 Subject: [PATCH 04/51] Remove integration toggles from More features Signed-off-by: krishna2323 --- .../WorkspaceMoreFeaturesPage/index.tsx | 230 +----------------- 1 file changed, 1 insertion(+), 229 deletions(-) diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx index 5a2d70e06c83..e93de42d406c 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx @@ -8,7 +8,6 @@ import useCardFeeds from '@hooks/useCardFeeds'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDefaultFundID from '@hooks/useDefaultFundID'; -import useIsPolicyConnectedToUberReceiptPartner from '@hooks/useIsPolicyConnectedToUberReceiptPartner'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -23,10 +22,6 @@ import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; import {enablePolicyTravel} from '@libs/actions/Policy/Travel'; import {filterInactiveCards, getAllCardsForWorkspace, getCardSettings, getCompanyFeeds, isSmartLimitEnabled as isSmartLimitEnabledUtil} from '@libs/CardUtils'; -import {getLatestErrorField} from '@libs/ErrorUtils'; -import {getConnectedHRProvider, isAnyHRConnected, isGustoConnected, isZenefitsConnected} from '@libs/merge/HRUtils'; -import {isMergeConnected} from '@libs/merge/MergeUtils'; -import {getConnectedATSProvider, isAnyRecruitingConnected} from '@libs/merge/RecruitingUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; @@ -38,12 +33,9 @@ import { getDistanceRateCustomUnit, getPerDiemCustomUnit, hasAccountingConnections, - hasAccountingFeatureConnection, hasVendorFeature, isControlPolicy, - isMCPEnabled, isPerDiemEnabled, - isPolicyFeatureEnabled, isPolicyTaxEnabled, isTimeTrackingEnabled, tryNavigateToSubmitWorkspaceUpgrade, @@ -58,15 +50,9 @@ import {enablePolicyCategories} from '@userActions/Policy/Category'; import {enablePolicyDistanceRates} from '@userActions/Policy/DistanceRate'; import {enablePerDiem} from '@userActions/Policy/PerDiem'; import { - clearPolicyErrorField, enableCompanyCards, enableExpensifyCard, - enablePolicyConnections, - enablePolicyHR, enablePolicyInvoicing, - enablePolicyRecruiting, - enablePolicyMCP, - enablePolicyReceiptPartners, enablePolicyRules, enablePolicyTaxes, enablePolicyTimeTracking, @@ -95,15 +81,13 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {translate} = useLocalize(); - const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); + const {isBetaEnabledOrUnknown} = usePermissions(); // Undefined until the betas load. The action calls below need that distinction, the UI gating just treats it as off const isVendorMatchingBetaEnabled = isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const {showConfirmModal} = useConfirmModal(); - const isRecruitingBetaEnabled = isBetaEnabled(CONST.BETAS.MERGE_ATS); const illustrations = useMemoizedLazyIllustrations([ 'FolderOpen', - 'Accounting', 'CompanyCard', 'Workflows', 'InvoiceBlue', @@ -116,21 +100,15 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro 'Luggage', 'Car', 'Gears', - 'ReceiptPartners', 'Clock', - 'Members', - 'AiAutomation', - 'NewUser', ]); const policyID = policy?.id; const workspaceAccountID = policy?.policyAccountID ?? CONST.DEFAULT_NUMBER_ID; const hasAccountingConnection = hasAccountingConnections(policy); - const isAccountingEnabled = !!policy?.areConnectionsEnabled || hasAccountingFeatureConnection(policy); const perDiemCustomUnit = getPerDiemCustomUnit(policy); const distanceRateCustomUnit = getDistanceRateCustomUnit(policy); - const isUberConnected = useIsPolicyConnectedToUberReceiptPartner({policyID}); const [cardFeeds] = useCardFeeds(policyID); const policyData = usePolicyData(policyID); const {categories: policyCategories} = policyData; @@ -216,66 +194,6 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro }); }; - const warnDisconnectAccountingFirst = async () => { - if (!hasAccountingConnection || !policyID) { - return; - } - const {action} = await showConfirmModal({ - title: translate('workspace.moreFeatures.connectionsWarningModal.featureEnabledTitle'), - prompt: translate('workspace.moreFeatures.connectionsWarningModal.disconnectText'), - confirmText: translate('workspace.moreFeatures.connectionsWarningModal.manageSettings'), - cancelText: translate('common.cancel'), - }); - if (action !== ModalActions.CONFIRM) { - return; - } - Navigation.navigate(ROUTES.POLICY_ACCOUNTING.getRoute(policyID)); - }; - - const warnReceiptPartnersStillConnected = async () => { - if (!isUberConnected) { - return; - } - await showConfirmModal({ - title: translate('workspace.moreFeatures.receiptPartnersWarningModal.featureEnabledTitle'), - prompt: translate('workspace.moreFeatures.receiptPartnersWarningModal.disconnectText'), - confirmText: translate('workspace.moreFeatures.receiptPartnersWarningModal.confirmText'), - shouldShowCancelButton: false, - }); - }; - - const warnDisconnectHRFirst = async () => { - if (!isAnyHRConnected(policy)) { - return; - } - let integration = ''; - if (isZenefitsConnected(policy)) { - integration = translate('workspace.hr.zenefits.title'); - } else if (isGustoConnected(policy)) { - integration = translate('workspace.hr.gusto.title'); - } else if (isMergeConnected(policy, CONST.POLICY.CONNECTIONS.NAME.MERGE_HR)) { - integration = getConnectedHRProvider(policy)?.displayName ?? ''; - } - await showConfirmModal({ - title: translate('workspace.distanceRates.oopsNotSoFast'), - prompt: translate('workspace.moreFeatures.hrWarningModal.disconnectText', {integration}), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - }; - - const warnDisconnectRecruitingFirst = async () => { - if (!isAnyRecruitingConnected(policy)) { - return; - } - await showConfirmModal({ - title: translate('workspace.distanceRates.oopsNotSoFast'), - prompt: translate('workspace.moreFeatures.recruitingWarningModal.disconnectText', {integration: getConnectedATSProvider(policy)?.displayName ?? ''}), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - }; - const promptDisableExpensifyCardViaConcierge = async () => { const {action} = await showConfirmModal({ title: translate('workspace.moreFeatures.expensifyCard.disableCardTitle'), @@ -360,152 +278,6 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro {translate('workspace.moreFeatures.subtitle')} - - { - if (!policyID) { - return; - } - if (tryNavigateToSubmitWorkspaceUpgrade(policy, isEnabled, CONST.UPGRADE_FEATURE_INTRO_MAPPING.accounting.alias)) { - return; - } - enablePolicyConnections(policyID, isEnabled); - }} - errors={getLatestErrorField(policy ?? {}, CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED)} - onCloseError={() => { - if (!policyID) { - return; - } - clearPolicyErrorField(policyID, CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED); - }} - onPress={() => { - if (!policyID) { - return; - } - Navigation.navigate(ROUTES.POLICY_ACCOUNTING.getRoute(policyID)); - }} - /> - { - if (!policyID) { - return; - } - if (isEnabled && !isControlPolicy(policy)) { - Navigation.navigate( - ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.hr.alias, ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)), - ); - return; - } - enablePolicyHR(policyID, isEnabled); - }} - onPress={() => { - if (!policyID) { - return; - } - Navigation.navigate(ROUTES.WORKSPACE_HR.getRoute(policyID)); - }} - /> - { - if (!policyID) { - return; - } - enablePolicyReceiptPartners(policyID, isEnabled); - }} - errors={getLatestErrorField(policy ?? {}, CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED)} - onCloseError={() => { - if (!policyID) { - return; - } - clearPolicyErrorField(policyID, CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED); - }} - onPress={() => { - if (!policyID) { - return; - } - Navigation.navigate(ROUTES.WORKSPACE_RECEIPT_PARTNERS.getRoute(policyID)); - }} - /> - {isRecruitingBetaEnabled && ( - { - if (!policyID) { - return; - } - if (isEnabled && !isControlPolicy(policy)) { - Navigation.navigate( - ROUTES.WORKSPACE_UPGRADE.getRoute( - policyID, - CONST.UPGRADE_FEATURE_INTRO_MAPPING.recruiting.alias, - ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID), - ), - ); - return; - } - enablePolicyRecruiting(policyID, isEnabled); - }} - onPress={() => { - if (!policyID) { - return; - } - Navigation.navigate(ROUTES.WORKSPACE_RECRUITING.getRoute(policyID)); - }} - /> - )} - { - if (!policyID) { - return; - } - enablePolicyMCP(policyID, isEnabled); - }} - onPress={() => { - if (!policyID) { - return; - } - Navigation.navigate(ROUTES.WORKSPACE_MCP.getRoute(policyID)); - }} - /> - - Date: Fri, 25 Sep 2026 15:13:48 +0530 Subject: [PATCH 05/51] Update tests for the Connections page Signed-off-by: krishna2323 --- tests/ui/WorkspaceMoreFeaturesPageTest.tsx | 65 ----------- tests/unit/SearchRouterNavigationTest.ts | 18 ++- .../QuickbooksOnlineSetupPageWebTest.tsx | 2 +- .../unit/components/XeroSetupPageWebTest.tsx | 2 +- tests/unit/getWorkspaceMenuItemsTest.ts | 110 ++++-------------- .../unit/hooks/useGettingStartedItems.test.ts | 2 +- .../GettingStartedSectionTest.tsx | 2 +- .../netsuite/NetSuiteSetupPageTest.tsx | 2 +- tests/utils/TestNavigationContainer.tsx | 2 +- 9 files changed, 34 insertions(+), 171 deletions(-) diff --git a/tests/ui/WorkspaceMoreFeaturesPageTest.tsx b/tests/ui/WorkspaceMoreFeaturesPageTest.tsx index 3cea740ab823..ed80a7831b21 100644 --- a/tests/ui/WorkspaceMoreFeaturesPageTest.tsx +++ b/tests/ui/WorkspaceMoreFeaturesPageTest.tsx @@ -6,7 +6,6 @@ import {ModalProvider} from '@components/Modal/Global/ModalContext'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; -import useIsPolicyConnectedToUberReceiptPartner from '@hooks/useIsPolicyConnectedToUberReceiptPartner'; import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; @@ -45,8 +44,6 @@ jest.mock('@components/Modal/ReanimatedModal', () => { return MockReanimatedModal; }); -jest.mock('@hooks/useIsPolicyConnectedToUberReceiptPartner', () => ({__esModule: true, default: jest.fn(() => false)})); - jest.mock('@libs/CardUtils', () => { const actual: typeof CardUtils = jest.requireActual('@libs/CardUtils'); return { @@ -121,7 +118,6 @@ const isSmartLimitEnabledMock = jest.mocked(CardUtils.isSmartLimitEnabled); const getCompanyFeedsMock = jest.mocked(CardUtils.getCompanyFeeds); const hasAccountingConnectionsMock = jest.mocked(PolicyUtils.hasAccountingConnections); const hasAccountingFeatureConnectionMock = jest.mocked(PolicyUtils.hasAccountingFeatureConnection); -const useIsUberConnectedMock = jest.mocked(useIsPolicyConnectedToUberReceiptPartner); const navigateSpy = jest.spyOn(Navigation, 'navigate').mockImplementation(() => undefined); const navigateToConciergeChatSpy = jest.spyOn(ReportActions, 'navigateToConciergeChat').mockImplementation(() => Promise.resolve()); @@ -159,7 +155,6 @@ describe('WorkspaceMoreFeaturesPage', () => { getCompanyFeedsMock.mockReturnValue({}); hasAccountingConnectionsMock.mockReturnValue(false); hasAccountingFeatureConnectionMock.mockReturnValue(false); - useIsUberConnectedMock.mockReturnValue(false); }); afterEach(async () => { @@ -302,41 +297,6 @@ describe('WorkspaceMoreFeaturesPage', () => { }); }); - describe('Accounting toggle (locked when an integration is connected)', () => { - it('locks the Accounting switch when the policy has an active connection', async () => { - await TestHelper.signInWithTestUser(); - hasAccountingConnectionsMock.mockReturnValue(true); - await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildPolicy({id: POLICY_ID})); - }); - - renderPage({policyID: POLICY_ID}); - await waitForBatchedUpdatesWithAct(); - - await expect(findLockedSwitch('workspace.moreFeatures.connections.subtitle')).resolves.toBeOnTheScreen(); - }); - - it('routes confirm to the accounting page', async () => { - await TestHelper.signInWithTestUser(); - hasAccountingConnectionsMock.mockReturnValue(true); - await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildPolicy({id: POLICY_ID})); - }); - - renderPage({policyID: POLICY_ID}); - await waitForBatchedUpdatesWithAct(); - fireEvent.press(await findLockedSwitch('workspace.moreFeatures.connections.subtitle')); - - await waitFor(() => { - expect(screen.getByText(TestHelper.translateLocal('workspace.moreFeatures.connectionsWarningModal.disconnectText'))).toBeOnTheScreen(); - }); - fireEvent.press(await screen.findByLabelText(TestHelper.translateLocal('workspace.moreFeatures.connectionsWarningModal.manageSettings'))); - await waitForBatchedUpdatesWithAct(); - - expect(navigateSpy).toHaveBeenCalledWith(ROUTES.POLICY_ACCOUNTING.getRoute(POLICY_ID)); - }); - }); - describe('Concierge-routed disable flows', () => { it('opens Concierge chat when the user confirms the Expensify Card disable warning', async () => { await TestHelper.signInWithTestUser(); @@ -383,31 +343,6 @@ describe('WorkspaceMoreFeaturesPage', () => { }); }); - describe('Receipt partners (informational warning, no confirm action)', () => { - it('opens the disconnect-Uber info modal without a Cancel button when locked', async () => { - await TestHelper.signInWithTestUser(); - useIsUberConnectedMock.mockReturnValue(true); - await act(async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildPolicy({id: POLICY_ID})); - }); - - renderPage({policyID: POLICY_ID}); - await waitForBatchedUpdatesWithAct(); - fireEvent.press(await findLockedSwitch('workspace.moreFeatures.receiptPartners.subtitle')); - - await waitFor(() => { - expect(screen.getByText(TestHelper.translateLocal('workspace.moreFeatures.receiptPartnersWarningModal.disconnectText'))).toBeOnTheScreen(); - }); - expect(screen.queryByText(TestHelper.translateLocal('common.cancel'))).toBeNull(); - - fireEvent.press(await screen.findByLabelText(TestHelper.translateLocal('workspace.moreFeatures.receiptPartnersWarningModal.confirmText'))); - await waitForBatchedUpdatesWithAct(); - - expect(navigateSpy).not.toHaveBeenCalled(); - expect(navigateToConciergeChatSpy).not.toHaveBeenCalled(); - }); - }); - describe('Vendors row (visibility gated on a supported integration)', () => { const renderWithVendorMatching = async (connections: Record, isBetaEnabled = true) => { await TestHelper.signInWithTestUser(); diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index 8de59663665f..1a4bd475317e 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -203,8 +203,7 @@ const workspaceIcons = { Users: mockIcon, Hashtag: mockIcon, Document: mockIcon, - Sync: mockIcon, - Receipt: mockIcon, + Connect: mockIcon, Briefcase: mockIcon, Folder: mockIcon, Tag: mockIcon, @@ -220,8 +219,6 @@ const workspaceIcons = { InvoiceGeneric: mockIcon, Gear: mockIcon, Bolt: mockIcon, - Bot: mockIcon, - UserPlus: mockIcon, }; function createWorkspacePolicy(id: string, name: string, overrides: Partial = {}): Policy { @@ -712,7 +709,6 @@ describe('Workspace Search Router navigation source', () => { icons: workspaceIcons, isOffline, isVendorMatchingBetaEnabled: false, - isRecruitingBetaEnabled: false, shouldUseNarrowLayout: false, convertToDisplayString: () => '$0.00', getItemText: (item) => { @@ -721,7 +717,7 @@ describe('Workspace Search Router navigation source', () => { ['workspace.common.members', 'Members'], ['workspace.common.rooms', 'Rooms'], ['workspace.common.workflows', 'Workflows'], - ['workspace.common.hr', 'HR'], + ['workspace.common.connections', 'Connections'], ]); return labels.get(item.translationKey) ?? item.translationKey; }, @@ -748,12 +744,12 @@ describe('Workspace Search Router navigation source', () => { expect(items.some((item) => item.keyForList === `workspace_1_${SCREENS.WORKSPACE.WORKFLOWS}`)).toBe(false); }); - it('supports the short HR query and alphabetizes equal-priority Workspace rows', () => { - const items = buildItems([createWorkspacePolicy('1', 'Beta Workspace', {isHREnabled: true}), createWorkspacePolicy('2', 'Alpha Workspace', {isHREnabled: true})]); + it('alphabetizes equal-priority Workspace rows', () => { + const items = buildItems([createWorkspacePolicy('1', 'Beta Workspace'), createWorkspacePolicy('2', 'Alpha Workspace')]); - expect(buildNavigationSuggestions('hr', [items], localeCompare).map((item) => item.keyForList)).toEqual([ - `workspace_2_${SCREENS.WORKSPACE.HR}`, - `workspace_1_${SCREENS.WORKSPACE.HR}`, + expect(buildNavigationSuggestions('connections', [items], localeCompare).map((item) => item.keyForList)).toEqual([ + `workspace_2_${SCREENS.WORKSPACE.CONNECTIONS}`, + `workspace_1_${SCREENS.WORKSPACE.CONNECTIONS}`, ]); }); diff --git a/tests/unit/components/QuickbooksOnlineSetupPageWebTest.tsx b/tests/unit/components/QuickbooksOnlineSetupPageWebTest.tsx index 0ccaa1268ee9..f066bebec8f5 100644 --- a/tests/unit/components/QuickbooksOnlineSetupPageWebTest.tsx +++ b/tests/unit/components/QuickbooksOnlineSetupPageWebTest.tsx @@ -60,7 +60,7 @@ describe('QuickbooksOnlineSetupPage (web)', () => { it('returns to the workspace accounting page', () => { renderQuickbooksOnlineSetupPage(); - expect(mockedGoBack).toHaveBeenCalledWith(ROUTES.POLICY_ACCOUNTING.getRoute(POLICY_ID)); + expect(mockedGoBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_CONNECTIONS.getRoute(POLICY_ID)); }); it('renders nothing', () => { diff --git a/tests/unit/components/XeroSetupPageWebTest.tsx b/tests/unit/components/XeroSetupPageWebTest.tsx index 06c449ace7c3..5dc7886f6d5a 100644 --- a/tests/unit/components/XeroSetupPageWebTest.tsx +++ b/tests/unit/components/XeroSetupPageWebTest.tsx @@ -60,7 +60,7 @@ describe('XeroSetupPage (web)', () => { it('returns to the workspace accounting page', () => { renderXeroSetupPage(); - expect(mockedGoBack).toHaveBeenCalledWith(ROUTES.POLICY_ACCOUNTING.getRoute(POLICY_ID)); + expect(mockedGoBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_CONNECTIONS.getRoute(POLICY_ID)); }); it('renders nothing', () => { diff --git a/tests/unit/getWorkspaceMenuItemsTest.ts b/tests/unit/getWorkspaceMenuItemsTest.ts index 6bbfacfbf7a5..21bb3ce990bb 100644 --- a/tests/unit/getWorkspaceMenuItemsTest.ts +++ b/tests/unit/getWorkspaceMenuItemsTest.ts @@ -16,8 +16,7 @@ const icons = { Users: mockIcon, Hashtag: mockIcon, Document: mockIcon, - Sync: mockIcon, - Receipt: mockIcon, + Connect: mockIcon, Briefcase: mockIcon, Folder: mockIcon, Tag: mockIcon, @@ -33,8 +32,6 @@ const icons = { InvoiceGeneric: mockIcon, Gear: mockIcon, Bolt: mockIcon, - Bot: mockIcon, - UserPlus: mockIcon, }; function buildPolicy(role: Policy['role']): Policy { @@ -180,8 +177,8 @@ describe('getWorkspaceMenuItems', () => { convertToDisplayString: () => '', }); - expect(buildItems(false).find((item) => item.translationKey === 'workspace.common.accounting')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); - expect(buildItems(true).find((item) => item.translationKey === 'workspace.common.accounting')?.brickRoadIndicator).toBeUndefined(); + expect(buildItems(false).find((item) => item.translationKey === 'workspace.common.connections')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); + expect(buildItems(true).find((item) => item.translationKey === 'workspace.common.connections')?.brickRoadIndicator).toBeUndefined(); }); it('shows an error indicator when receipt partner credentials require attention', () => { @@ -196,7 +193,7 @@ describe('getWorkspaceMenuItems', () => { convertToDisplayString: () => '', }); - expect(items.find((item) => item.translationKey === 'workspace.common.receiptPartners')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); + expect(items.find((item) => item.translationKey === 'workspace.common.connections')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); }); it('shows an error indicator when company cards require attention', () => { @@ -314,7 +311,7 @@ describe('getWorkspaceMenuItems', () => { convertToDisplayString: () => '', }); - expect(items.find((item) => item.translationKey === 'workspace.common.hr')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.INFO); + expect(items.find((item) => item.translationKey === 'workspace.common.connections')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.INFO); }); it('shows an error indicator when the Merge HR connection has an authentication error', () => { @@ -337,85 +334,33 @@ describe('getWorkspaceMenuItems', () => { convertToDisplayString: () => '', }); - expect(items.find((item) => item.translationKey === 'workspace.common.hr')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); + expect(items.find((item) => item.translationKey === 'workspace.common.connections')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); }); - it('hides the Recruiting row when the Merge ATS beta is disabled', () => { - const policy = createMock({...buildPolicy(CONST.POLICY.ROLE.ADMIN), isRecruitingEnabled: true}); - - const buildItems = (isRecruitingBetaEnabled: boolean) => - getWorkspaceMenuItems({ - policy, - policyID: policy.id, - currentUserLogin, - icons, - isRecruitingBetaEnabled, - convertToDisplayString: () => '', - }); - - expect(buildItems(false).find((item) => item.translationKey === 'workspace.common.recruiting')).toBeUndefined(); - - const recruitingItem = buildItems(true).find((item) => item.translationKey === 'workspace.common.recruiting'); - expect(recruitingItem?.getRoute()).toBe(ROUTES.WORKSPACE_RECRUITING.getRoute(policy.id)); - expect(recruitingItem?.screenName).toBe(SCREENS.WORKSPACE.RECRUITING); - }); - - it('shows the Recruiting row when an ATS connection exists even without the policy flag', () => { + it('shows a single Connections row even when no integration feature is enabled', () => { + // Given an admin on a workspace that has none of the integration features turned on const policy = createMock({ ...buildPolicy(CONST.POLICY.ROLE.ADMIN), - isRecruitingEnabled: undefined, - connections: { - [CONST.POLICY.CONNECTIONS.NAME.MERGE_ATS]: { - config: {integration: 'greenhouse'}, - lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.DONE}, - }, - }, + areConnectionsEnabled: false, + isHREnabled: false, + isRecruitingEnabled: false, + receiptPartners: {enabled: false}, }); + // When the Workspace menu is built const items = getWorkspaceMenuItems({ policy, policyID: policy.id, currentUserLogin, icons, - isRecruitingBetaEnabled: true, - convertToDisplayString: () => '', - }); - - expect(items.find((item) => item.translationKey === 'workspace.common.recruiting')).toBeDefined(); - }); - - it('hides the Recruiting row on a workspace whose plan cannot access the feature', () => { - const policy = createMock({...buildPolicy(CONST.POLICY.ROLE.ADMIN), type: CONST.POLICY.TYPE.TEAM, isRecruitingEnabled: true}); - - const items = getWorkspaceMenuItems({ - policy, - policyID: policy.id, - currentUserLogin, - icons, - isRecruitingBetaEnabled: true, - convertToDisplayString: () => '', - }); - - expect(items.find((item) => item.translationKey === 'workspace.common.recruiting')).toBeUndefined(); - }); - - it('does not highlight a newly enabled Recruiting feature while the Merge ATS beta is disabled', () => { - const policy = createMock({ - ...buildPolicy(CONST.POLICY.ROLE.ADMIN), - isRecruitingEnabled: true, - pendingFields: {isRecruitingEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, - }); - - const items = getWorkspaceMenuItems({ - policy, - policyID: policy.id, - currentUserLogin, - icons, - previousPendingFields: {}, convertToDisplayString: () => '', }); - expect(items.filter((item) => item.highlighted)).toHaveLength(0); + // Then every integration is reached from the one Connections row, because integrations no longer need a feature toggle + const connectionsItem = items.find((item) => item.translationKey === 'workspace.common.connections'); + expect(connectionsItem?.getRoute()).toBe(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policy.id)); + expect(connectionsItem?.screenName).toBe(SCREENS.WORKSPACE.CONNECTIONS); + expect(items.map((item) => item.translationKey)).not.toEqual(expect.arrayContaining(['workspace.common.accounting', 'workspace.common.hr', 'workspace.common.recruiting'])); }); it('preserves the complete enabled Workspace menu order and presentation data', () => { @@ -453,7 +398,6 @@ describe('getWorkspaceMenuItems', () => { currentUserLogin, icons, isVendorMatchingBetaEnabled: true, - isRecruitingBetaEnabled: true, convertToDisplayString, }); @@ -462,11 +406,7 @@ describe('getWorkspaceMenuItems', () => { 'workspace.common.members', 'workspace.common.rooms', 'common.reports', - 'workspace.common.accounting', - 'workspace.common.hr', - 'workspace.common.recruiting', - 'workspace.common.receiptPartners', - 'workspace.common.mcp', + 'workspace.common.connections', 'workspace.common.categories', 'workspace.common.tags', 'workspace.common.taxes', @@ -487,11 +427,7 @@ describe('getWorkspaceMenuItems', () => { ROUTES.WORKSPACE_MEMBERS.getRoute(policy.id), ROUTES.WORKSPACE_ROOMS.getRoute(policy.id), ROUTES.WORKSPACE_REPORTS.getRoute(policy.id), - ROUTES.POLICY_ACCOUNTING.getRoute(policy.id), - ROUTES.WORKSPACE_HR.getRoute(policy.id), - ROUTES.WORKSPACE_RECRUITING.getRoute(policy.id), - ROUTES.WORKSPACE_RECEIPT_PARTNERS.getRoute(policy.id), - ROUTES.WORKSPACE_MCP.getRoute(policy.id), + ROUTES.WORKSPACE_CONNECTIONS.getRoute(policy.id), ROUTES.WORKSPACE_CATEGORIES.getRoute(policy.id), ROUTES.WORKSPACE_TAGS.getRoute(policy.id), ROUTES.WORKSPACE_TAXES.getRoute(policy.id), @@ -512,11 +448,7 @@ describe('getWorkspaceMenuItems', () => { SCREENS.WORKSPACE.MEMBERS, SCREENS.WORKSPACE.ROOMS, SCREENS.WORKSPACE.REPORTS, - SCREENS.WORKSPACE.ACCOUNTING.ROOT, - SCREENS.WORKSPACE.HR, - SCREENS.WORKSPACE.RECRUITING, - SCREENS.WORKSPACE.RECEIPT_PARTNERS, - SCREENS.WORKSPACE.MCP, + SCREENS.WORKSPACE.CONNECTIONS, SCREENS.WORKSPACE.CATEGORIES, SCREENS.WORKSPACE.TAGS, SCREENS.WORKSPACE.TAXES, diff --git a/tests/unit/hooks/useGettingStartedItems.test.ts b/tests/unit/hooks/useGettingStartedItems.test.ts index 1b129e6ec4ad..6c11989f67ce 100644 --- a/tests/unit/hooks/useGettingStartedItems.test.ts +++ b/tests/unit/hooks/useGettingStartedItems.test.ts @@ -386,7 +386,7 @@ describe('useGettingStartedItems', () => { await waitForBatchedUpdates(); const connectItem = result.current.items.find((item) => item.key === 'connectAccounting'); - expect(connectItem?.route).toBe(ROUTES.WORKSPACE_ACCOUNTING.getRoute(POLICY_ID)); + expect(connectItem?.route).toBe(ROUTES.WORKSPACE_CONNECTIONS.getRoute(POLICY_ID)); }); it('should be not completed when workspace has no accounting connection', async () => { diff --git a/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx b/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx index 4c162387fe76..b41dee3d09bf 100644 --- a/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx +++ b/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx @@ -543,7 +543,7 @@ describe('GettingStartedSection', () => { const row = screen.getByText(/homePage\.gettingStartedSection\.connectAccounting:/); fireEvent.press(row); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_ACCOUNTING.getRoute(TEST_POLICY_ID)); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_CONNECTIONS.getRoute(TEST_POLICY_ID)); }); it('navigates to workspace categories when "Customize categories" row is pressed', async () => { diff --git a/tests/unit/pages/workspace/accounting/netsuite/NetSuiteSetupPageTest.tsx b/tests/unit/pages/workspace/accounting/netsuite/NetSuiteSetupPageTest.tsx index d30d292275d8..8ef19e1ba3cd 100644 --- a/tests/unit/pages/workspace/accounting/netsuite/NetSuiteSetupPageTest.tsx +++ b/tests/unit/pages/workspace/accounting/netsuite/NetSuiteSetupPageTest.tsx @@ -95,7 +95,7 @@ describe('NetSuiteSetupPage', () => { mockHeaderProps.current?.onBackButtonPress?.(); - expect(mockedGoBack).toHaveBeenCalledWith(ROUTES.POLICY_ACCOUNTING.getRoute(POLICY_ID)); + expect(mockedGoBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_CONNECTIONS.getRoute(POLICY_ID)); }); it('renders nothing when the account ID is missing from the route', () => { diff --git a/tests/utils/TestNavigationContainer.tsx b/tests/utils/TestNavigationContainer.tsx index ec4183408b09..89447f5a2c31 100644 --- a/tests/utils/TestNavigationContainer.tsx +++ b/tests/utils/TestNavigationContainer.tsx @@ -109,7 +109,7 @@ function TestWorkspaceSplitNavigator({route}: {route: RouteProp From c7dcdfde63437d7f084a1dd7d561e308e9f31fd2 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 25 Sep 2026 15:25:06 +0530 Subject: [PATCH 06/51] Add side padding to the Connections page Signed-off-by: krishna2323 --- src/pages/workspace/connections/WorkspaceConnectionsPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx index 2c4b37524dd6..c982cb60fea2 100644 --- a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx +++ b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx @@ -225,7 +225,7 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { onBackButtonPress={() => Navigation.goBack()} /> @@ -241,6 +241,7 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { tabs={tabs} activeTabKey={activeTab} onTabPress={setActiveTab} + contentContainerStyles={styles.ph0} /> From 3fa3a5a14354034c40369baeb9d55f923b7ca89e Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 25 Sep 2026 15:29:21 +0530 Subject: [PATCH 07/51] Fix knip and tests after the Connections move Signed-off-by: krishna2323 --- src/pages/workspace/connections/types.ts | 2 +- tests/unit/SearchRouterNavigationTest.ts | 2 -- ...spaceConnectionsPageAutoStartFlowTest.tsx} | 32 +++++++++++-------- 3 files changed, 19 insertions(+), 17 deletions(-) rename tests/unit/{PolicyAccountingPageAutoStartFlowTest.tsx => WorkspaceConnectionsPageAutoStartFlowTest.tsx} (79%) diff --git a/src/pages/workspace/connections/types.ts b/src/pages/workspace/connections/types.ts index 7a293b888bad..bc789b997889 100644 --- a/src/pages/workspace/connections/types.ts +++ b/src/pages/workspace/connections/types.ts @@ -41,4 +41,4 @@ type ConnectionListing = { registerConnectButton?: (button: ComponentRef | null) => void; }; -export type {ConnectionsTab, ConnectionCategory, ConnectionStatus, ConnectionListing}; +export type {ConnectionsTab, ConnectionStatus, ConnectionListing}; diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index 1a4bd475317e..1db32bd10355 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -827,11 +827,9 @@ describe('Workspace Search Router navigation source', () => { expect.objectContaining({ policy: activePolicy, isVendorMatchingBetaEnabled: true, - isRecruitingBetaEnabled: true, }), ); expect(mockIsBetaEnabled).toHaveBeenCalledWith(CONST.BETAS.VENDOR_MATCHING); - expect(mockIsBetaEnabled).toHaveBeenCalledWith(CONST.BETAS.MERGE_ATS); }); }); diff --git a/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx b/tests/unit/WorkspaceConnectionsPageAutoStartFlowTest.tsx similarity index 79% rename from tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx rename to tests/unit/WorkspaceConnectionsPageAutoStartFlowTest.tsx index 7ee37df32f51..8f4027d751af 100644 --- a/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx +++ b/tests/unit/WorkspaceConnectionsPageAutoStartFlowTest.tsx @@ -2,7 +2,7 @@ import {render} from '@testing-library/react-native'; import Navigation from '@libs/Navigation/Navigation'; -import PolicyAccountingPage from '@pages/workspace/accounting/PolicyAccountingPage'; +import WorkspaceConnectionsPage from '@pages/workspace/connections/WorkspaceConnectionsPage'; import CONST from '@src/CONST'; import type {ConnectionName} from '@src/types/onyx/Policy'; @@ -94,12 +94,16 @@ jest.mock('@components/ScrollView', () => ({ })); jest.mock('@components/HeaderWithBackButton', () => ({__esModule: true, default: () => null})); -jest.mock('@components/MenuItemList', () => ({__esModule: true, default: () => null})); -jest.mock('@components/MenuItem', () => ({__esModule: true, default: () => null})); -jest.mock('@components/Section', () => ({__esModule: true, default: ({children}: {children: React.ReactNode}) => children})); -jest.mock('@components/CollapsibleSection', () => ({__esModule: true, default: () => null})); -jest.mock('@components/ThreeDotsMenu', () => ({__esModule: true, default: () => null})); -jest.mock('@components/ActivityIndicator', () => ({__esModule: true, default: () => null})); +jest.mock('@components/SearchBar/CompactSearchBar', () => ({__esModule: true, default: () => null})); +jest.mock('@components/TabSelector/TabSelectorBase', () => ({__esModule: true, default: () => null})); +jest.mock('@components/TextLink', () => ({__esModule: true, default: () => null})); +jest.mock('@pages/workspace/connections/ConnectionsGrid', () => ({__esModule: true, default: () => null})); +jest.mock('@pages/workspace/connections/useAccountingConnectionListings', () => ({__esModule: true, default: () => []})); +jest.mock('@pages/workspace/connections/useMergeConnectionListings', () => ({__esModule: true, default: () => []})); +jest.mock('@pages/workspace/connections/useReceiptPartnerConnectionListings', () => ({__esModule: true, default: () => []})); +jest.mock('@pages/workspace/connections/useMCPConnectionListings', () => ({__esModule: true, default: () => []})); +jest.mock('@libs/actions/PolicyConnections', () => ({openPolicyHRPage: jest.fn(), openPolicyRecruitingPage: jest.fn()})); +jest.mock('@userActions/Policy/Policy', () => ({openPolicyReceiptPartnersPage: jest.fn()})); jest.mock('@libs/PolicyUtils', () => { const actual = jest.requireActual>('@libs/PolicyUtils'); @@ -117,13 +121,13 @@ jest.mock('@hooks/usePolicyFeatureWriteAccess', () => ({ // The real `withPolicyConnections` HOC reads `policy` from Onyx and strips it from the component's public props. It is // mocked to an identity wrapper above, so the component under test takes `policy` directly. // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only: the HOC that would inject `policy` is mocked out, so it is passed as a prop here -const PolicyAccountingPageUnderTest = PolicyAccountingPage as unknown as React.ComponentType<{policy: Policy}>; +const WorkspaceConnectionsPageUnderTest = WorkspaceConnectionsPage as unknown as React.ComponentType<{policy: Policy}>; function buildPolicy(overrides: Partial = {}): Policy { return {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE, 'Test workspace'), id: POLICY_ID, ...overrides}; } -describe('PolicyAccountingPage auto-started connect flow', () => { +describe('WorkspaceConnectionsPage auto-started connect flow', () => { beforeEach(() => { jest.clearAllMocks(); mockPendingParams = undefined; @@ -137,7 +141,7 @@ describe('PolicyAccountingPage auto-started connect flow', () => { it('should start the flow once when the effect re-runs before the cleared param has landed', async () => { // Given the page opened by a route that asks for a connect flow, which starts it and asks for the param to be // cleared so it cannot be acted on twice - const {rerender} = render(); + const {rerender} = render(); await waitForBatchedUpdates(); expect(mockStartIntegrationFlow).toHaveBeenCalledTimes(1); @@ -145,7 +149,7 @@ describe('PolicyAccountingPage auto-started connect flow', () => { // When anything re-creates `startIntegrationFlow` and re-runs the effect before that clear has landed, which a // policy update does. This is the window the guard exists for: `newConnectionName` is still set here. - rerender(); + rerender(); await waitForBatchedUpdates(); // Then the flow is not started again, because a second start is what used to stack a second confirmation @@ -156,13 +160,13 @@ describe('PolicyAccountingPage auto-started connect flow', () => { it('should start the flow again for a later round-trip that asks for the same integration', async () => { // Given a connect flow that was started from the route param and then let the clear land, so nothing is // pending any more - const {rerender} = render(); + const {rerender} = render(); await waitForBatchedUpdates(); expect(mockStartIntegrationFlow).toHaveBeenCalledTimes(1); landPendingParamsUpdate(); - rerender(); + rerender(); await waitForBatchedUpdates(); expect(mockStartIntegrationFlow).toHaveBeenCalledTimes(1); @@ -173,7 +177,7 @@ describe('PolicyAccountingPage auto-started connect flow', () => { integrationToDisconnect: CONST.POLICY.CONNECTIONS.NAME.XERO, shouldDisconnectIntegrationBeforeConnecting: true, }; - rerender(); + rerender(); await waitForBatchedUpdates(); // Then it is honoured rather than swallowed as a repeat of the first run, or the connect flow would silently From 13a7d5755533af261ba4fb8f06349bac25eca8d2 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 25 Sep 2026 22:27:25 +0530 Subject: [PATCH 08/51] Let the Connections page use the full width Signed-off-by: krishna2323 --- src/pages/workspace/connections/WorkspaceConnectionsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx index c982cb60fea2..0a9284bd8b08 100644 --- a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx +++ b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx @@ -225,7 +225,7 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { onBackButtonPress={() => Navigation.goBack()} /> From d9242464f4b09f93fff04cbf2b1c6de5810d1a8c Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Sun, 27 Sep 2026 08:28:41 +0530 Subject: [PATCH 09/51] Update the accounting replace warning and add a Connections moved tooltip Signed-off-by: krishna2323 --- src/CONST/index.ts | 1 + src/components/MenuItem/MenuItem.tsx | 5 + .../ProductTrainingContext/TOOLTIPS.ts | 8 ++ src/languages/en.ts | 4 +- src/pages/workspace/WorkspaceInitialPage.tsx | 91 +++++++++++++++---- .../accounting/AccountingContext/index.tsx | 6 +- src/types/onyx/DismissedProductTraining.ts | 6 ++ .../AccountingConnectionConfirmationTest.tsx | 8 +- 8 files changed, 102 insertions(+), 27 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 1ed4fe46654a..d4a02a88ccf5 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -8944,6 +8944,7 @@ const CONST = { MILEAGE_RATE_AUTO_UPDATED: 'mileageRateAutoUpdated', MARK_ALL_AS_READ: 'markAllAsRead', ACCOUNT_MOVED_TO_TOP_BAR: 'accountMovedToTopBar', + CONNECTIONS_MOVED: 'connectionsMoved', REQUIRE_FIELDS_RULE_RECEIPT_COUPLING_TOOLTIP: 'requireFieldsRuleReceiptCouplingTooltip', REQUIRE_FIELDS_RULE_ITEMIZED_RECEIPT_COUPLING_TOOLTIP: 'requireFieldsRuleItemizedReceiptCouplingTooltip', }, diff --git a/src/components/MenuItem/MenuItem.tsx b/src/components/MenuItem/MenuItem.tsx index 7ea0369e3229..5110c8dbb86d 100644 --- a/src/components/MenuItem/MenuItem.tsx +++ b/src/components/MenuItem/MenuItem.tsx @@ -358,6 +358,9 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Whether the tooltip should hide on scroll */ shouldHideOnScroll?: boolean; + /** Whether the tooltip should hide when the screen loses navigation focus */ + shouldHideTooltipOnNavigate?: boolean; + shouldShowLoadingSpinnerIcon?: boolean; shouldShowRadioButton?: boolean; @@ -534,6 +537,7 @@ function MenuItem({ shouldRenderTooltip = false, shouldDisplayEducationalTooltip, shouldHideOnScroll = false, + shouldHideTooltipOnNavigate = true, tooltipAnchorAlignment, tooltipWrapperStyle = {}, tooltipShiftHorizontal = 0, @@ -799,6 +803,7 @@ function MenuItem({ shouldTeleportPortalToModalLayer={shouldTeleportPortalToModalLayer} onTooltipPress={onEducationTooltipPress} shouldHideOnScroll={shouldHideOnScroll} + shouldHideOnNavigate={shouldHideTooltipOnNavigate} > = { priority: 1700, shouldShow: () => true, }, + [CONNECTIONS_MOVED]: { + content: 'productTrainingTooltip.connectionsMoved', + onHideTooltip: (isDismissedUsingCloseButton = false) => dismissProductTraining(CONNECTIONS_MOVED, isDismissedUsingCloseButton), + name: CONNECTIONS_MOVED, + priority: 1650, + shouldShow: ({isUserPolicyAdmin}) => isUserPolicyAdmin, + }, }; export default TOOLTIPS; diff --git a/src/languages/en.ts b/src/languages/en.ts index 12c5937db830..540ab599d94a 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7458,7 +7458,8 @@ const translations = { disconnect: 'Disconnect', reinstall: 'Reinstall connector', disconnectTitle: (connectionName = 'integration') => `Disconnect ${connectionName}`, - connectTitle: (connectionName: string) => `Connect ${connectionName}`, + alreadyConnectedTitle: 'You already got an active accounting integration', + replaceIntegration: 'Replace integration', syncError: (connectionName: string) => `Can't connect to ${connectionName}`, accounts: 'Chart of accounts', @@ -11458,6 +11459,7 @@ const translations = { markAllAsRead: 'Right-click to mark all as read.', markAllAsReadTouchScreen: 'Long-press to mark all as read.', accountMovedToTopBar: 'Access your account and personal settings.', + connectionsMoved: 'All your connections in one place', }, discardChangesConfirmation: { title: 'Discard changes?', diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 90e816f6cbfd..bbec9806975d 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -4,8 +4,10 @@ import HighlightableMenuItem from '@components/HighlightableMenuItem'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import {useProductTrainingContext} from '@components/ProductTrainingContext'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; import useCardFeedErrors from '@hooks/useCardFeedErrors'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; @@ -20,12 +22,15 @@ import usePolicyConnectionsPrefetch from '@hooks/usePolicyConnectionsPrefetch'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSingleExecution from '@hooks/useSingleExecution'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; import {isConnectionInProgress} from '@libs/actions/connections'; import {clearErrors, openPolicyInitialPage, removeWorkspace} from '@libs/actions/Policy/Policy'; +import {dismissProductTraining} from '@libs/actions/Welcome'; import goBackFromWorkspaceSettingPages from '@libs/Navigation/helpers/goBackFromWorkspaceSettingPages'; import WorkspaceCreationReveal from '@libs/Navigation/helpers/WorkspaceCreationReveal'; import Navigation from '@libs/Navigation/Navigation'; @@ -68,6 +73,8 @@ function dismissError(policyID: string | undefined, pendingAction: PendingAction function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: WorkspaceInitialPageProps) { const styles = useThemeStyles(); + const theme = useTheme(); + const StyleUtils = useStyleUtils(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {translate} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); @@ -186,6 +193,34 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac ...item, action: singleExecution(waitForNavigate(() => Navigation.navigate(item.getRoute()))), })); + const {shouldShowProductTrainingTooltip: shouldShowConnectionsTooltip, hideProductTrainingTooltip: hideConnectionsTooltip} = useProductTrainingContext( + CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.CONNECTIONS_MOVED, + isWorkspacesTabFocused && !hasPolicyCreationError && workspaceMenuItems.some((item) => item.screenName === SCREENS.WORKSPACE.CONNECTIONS), + ); + // Route the Connections tooltip was first shown on, so leaving that page counts as dismissing it + const connectionsTooltipRouteRef = useRef(undefined); + useEffect(() => { + if (shouldShowConnectionsTooltip && !connectionsTooltipRouteRef.current) { + connectionsTooltipRouteRef.current = activeRoute; + } + const shownOnRoute = connectionsTooltipRouteRef.current; + if (!shownOnRoute || (shownOnRoute === activeRoute && isWorkspacesTabFocused)) { + return; + } + connectionsTooltipRouteRef.current = undefined; + dismissProductTraining(CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.CONNECTIONS_MOVED); + }, [activeRoute, isWorkspacesTabFocused, shouldShowConnectionsTooltip]); + const renderConnectionsTooltip = () => ( + + + {translate('common.new')}{' '} + {translate('productTrainingTooltip.connectionsMoved')} + + + ); // Close RHP if we land on a route that no longer exists in the menu const canAccessRoute = activeRoute && (workspaceMenuItems.some((item) => item.screenName === activeRoute) || activeRoute === SCREENS.WORKSPACE.INITIAL); useEffect(() => { @@ -261,25 +296,43 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac Ideally we should use MenuList component for MenuItems with singleExecution/Navigation actions. In this case where user can click on workspace avatar or menu items, we need to have a check for `isExecuting`. So, we are directly mapping menuItems. */} - {workspaceMenuItems.map((item) => ( - - ))} + {workspaceMenuItems.map((item) => { + const isConnectionsItem = item.screenName === SCREENS.WORKSPACE.CONNECTIONS; + const onPress = () => { + if (isConnectionsItem) { + connectionsTooltipRouteRef.current = undefined; + hideConnectionsTooltip(); + } + item.action(); + }; + return ( + + ); + })} diff --git a/src/pages/workspace/accounting/AccountingContext/index.tsx b/src/pages/workspace/accounting/AccountingContext/index.tsx index f20e0bd10a08..8e8be5e6f0ab 100644 --- a/src/pages/workspace/accounting/AccountingContext/index.tsx +++ b/src/pages/workspace/accounting/AccountingContext/index.tsx @@ -174,11 +174,11 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider // `PolicyAccountingPage` re-fires whenever `startIntegrationFlow` is re-created). A stable id keeps the // repeat call updating this prompt in place instead of stacking a second copy behind it. id: ACCOUNTING_CONNECTION_CONFIRMATION_MODAL_ID, - title: translate('workspace.accounting.connectTitle', connectionName), + title: translate('workspace.accounting.alreadyConnectedTitle'), prompt: translate('workspace.accounting.connectPrompt', connectionName), - confirmText: translate('workspace.accounting.setup'), + confirmText: translate('workspace.accounting.replaceIntegration'), cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.SUCCESS, + buttonVariant: CONST.BUTTON_VARIANT.DANGER, }).then((result) => { // A repeat call for the same id is handed back the promise the first call got, so every call's handler // runs on a single user answer. Only the first one may act, or the disconnect would be requested twice. diff --git a/src/types/onyx/DismissedProductTraining.ts b/src/types/onyx/DismissedProductTraining.ts index 39f9f67a127e..d034afa5776b 100644 --- a/src/types/onyx/DismissedProductTraining.ts +++ b/src/types/onyx/DismissedProductTraining.ts @@ -11,6 +11,7 @@ const { MILEAGE_RATE_AUTO_UPDATED, MARK_ALL_AS_READ, ACCOUNT_MOVED_TO_TOP_BAR, + CONNECTIONS_MOVED, REQUIRE_FIELDS_RULE_RECEIPT_COUPLING_TOOLTIP, REQUIRE_FIELDS_RULE_ITEMIZED_RECEIPT_COUPLING_TOOLTIP, } = CONST.PRODUCT_TRAINING_TOOLTIP_NAMES; @@ -101,6 +102,11 @@ type DismissedProductTraining = { */ [ACCOUNT_MOVED_TO_TOP_BAR]: DismissedProductTrainingElement; + /** + * When user dismisses the tooltip pointing at the Connections item in the workspace menu, we store the timestamp here. + */ + [CONNECTIONS_MOVED]: DismissedProductTrainingElement; + /** * When user dismisses the require fields rule tooltip explaining why Receipt is locked while * Itemized receipt is required, we store the timestamp here. diff --git a/tests/unit/AccountingConnectionConfirmationTest.tsx b/tests/unit/AccountingConnectionConfirmationTest.tsx index 71adc21b49de..df918e09cde3 100644 --- a/tests/unit/AccountingConnectionConfirmationTest.tsx +++ b/tests/unit/AccountingConnectionConfirmationTest.tsx @@ -153,11 +153,11 @@ describe('AccountingContextProvider connect-confirmation prompt', () => { // Then the user is asked first, and the setup flow stays unmounted so it cannot start behind the prompt expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); - expect(getShowConfirmModalOption('title')).toBe(`workspace.accounting.connectTitle:${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.quickbooksOnline}`); + expect(getShowConfirmModalOption('title')).toBe('workspace.accounting.alreadyConnectedTitle'); expect(getShowConfirmModalOption('prompt')).toBe(`workspace.accounting.connectPrompt:${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.quickbooksOnline}`); - expect(getShowConfirmModalOption('confirmText')).toBe('workspace.accounting.setup'); + expect(getShowConfirmModalOption('confirmText')).toBe('workspace.accounting.replaceIntegration'); expect(getShowConfirmModalOption('cancelText')).toBe('common.cancel'); - expect(getShowConfirmModalOption('buttonVariant')).toBe(CONST.BUTTON_VARIANT.SUCCESS); + expect(getShowConfirmModalOption('buttonVariant')).toBe(CONST.BUTTON_VARIANT.DANGER); expect(screen.queryByTestId(SETUP_FLOW_TEST_ID)).not.toBeOnTheScreen(); }); @@ -204,7 +204,7 @@ describe('AccountingContextProvider connect-confirmation prompt', () => { }); // Then the prompt names the suite rather than QuickBooks Online, so the user recognises what they bought - expect(getShowConfirmModalOption('title')).toBe('workspace.accounting.connectTitle:workspace.accounting.intuitEnterpriseSuite'); + expect(getShowConfirmModalOption('prompt')).toBe('workspace.accounting.connectPrompt:workspace.accounting.intuitEnterpriseSuite'); }); it('should disconnect the old connection and release the setup flow on confirm', async () => { From c300a5ba625e38717f50f73bf002553530a57d76 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 29 Sep 2026 06:25:32 +0530 Subject: [PATCH 10/51] Match the Connections page layout to the design Signed-off-by: krishna2323 --- src/CONST/index.ts | 3 + .../workspace/connections/ConnectionCard.tsx | 36 ++++++-- .../workspace/connections/ConnectionsGrid.tsx | 11 ++- .../connections/WorkspaceConnectionsPage.tsx | 91 ++++++++++++++----- 4 files changed, 108 insertions(+), 33 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index d4a02a88ccf5..43e01ac370f7 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9729,6 +9729,9 @@ const CONST = { CARD_SECTION_ADD_BUTTON: 'WorkspaceAccounting-CardSectionAddButton', THREE_DOT_MENU: 'WorkspaceAccounting-ThreeDotMenu', }, + CONNECTIONS: { + SUGGEST_INTEGRATION: 'WorkspaceConnections-SuggestIntegration', + }, RULES: { ADD_SPEND_RULE: 'WorkspaceRules-AddSpendRule', INDIVIDUAL_EXPENSES_MENU_ITEM: 'WorkspaceRules-IndividualExpensesMenuItem', diff --git a/src/pages/workspace/connections/ConnectionCard.tsx b/src/pages/workspace/connections/ConnectionCard.tsx index f3c6f1b3ee34..1fc8bb093467 100644 --- a/src/pages/workspace/connections/ConnectionCard.tsx +++ b/src/pages/workspace/connections/ConnectionCard.tsx @@ -4,6 +4,7 @@ */ import Badge from '@components/Badge'; import Button from '@components/Button'; +import Icon from '@components/Icon'; import MenuItem from '@components/MenuItem'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -11,8 +12,11 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import variables from '@styles/variables'; + import CONST from '@src/CONST'; import React from 'react'; @@ -27,12 +31,24 @@ type ConnectionCardProps = { function ConnectionCard({listing}: ConnectionCardProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const theme = useTheme(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); + const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); const icons = useMemoizedLazyExpensifyIcons(['Building', 'Plus']); const {title, icon, status, onConnect, onConfigure, registerConnectButton} = listing; + const connectIcon = ( + + + + ); + const statusButton = !!status && ( diff --git a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx index db02a5d5c98b..f8f5a33d006e 100644 --- a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx +++ b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx @@ -271,13 +271,14 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { activeTabKey={activeTab} onTabPress={setActiveTab} contentContainerStyles={[styles.ph0, styles.pb0]} + tabButtonStyles={styles.connectionsTabButton} />
- {searchBar} + {searchBar} {shouldShowNoResults ? ( - + - {translate('workspace.connections.suggestIntegration')} + {translate('workspace.connections.suggestIntegration')} scrollMarginInline: variables.tabSelectorScrollMarginInline, }, + connectionsTabButton: { + height: 'auto', + paddingVertical: 10, + paddingHorizontal: 16, + }, + tabSelector: { flexDirection: 'row', paddingHorizontal: 20, From dd8bce7d5f6a92125530216602b38ce26f82338e Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 29 Sep 2026 07:25:48 +0530 Subject: [PATCH 13/51] Show connection details in the RHP header Signed-off-by: krishna2323 --- .../accounting/PolicyAccountingPage.tsx | 295 ++++++------------ .../merge/MergeConnectionsPageBase.tsx | 34 +- .../workspace/merge/MergeProviderCard.tsx | 139 ++++----- .../WorkspaceReceiptPartnersPage.tsx | 257 +++++---------- 4 files changed, 245 insertions(+), 480 deletions(-) diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index 8e34d5ad7f7a..5c945d1dc9fe 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -8,7 +8,6 @@ import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; -import ThreeDotsMenu from '@components/ThreeDotsMenu'; import type ThreeDotsMenuProps from '@components/ThreeDotsMenu/types'; import useCardFeeds from '@hooks/useCardFeeds'; @@ -58,7 +57,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useMemo} from 'react'; import {View} from 'react-native'; import type {MenuItemData, PolicyAccountingPageProps} from './types'; @@ -84,7 +83,6 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { const {isOffline} = useNetwork(); const {isBetaEnabled} = usePermissions(); const {showConfirmModal} = useConfirmModal(); - const [datetimeToRelative, setDateTimeToRelative] = useState(''); const {activeIntegration} = useAccountingState(); const {startIntegrationFlow} = useAccountingActions(); const policyID = policy?.id; @@ -144,6 +142,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { connectedIntegration ? policy?.connections?.[connectedIntegration] : undefined, connectedIntegration === connectionSyncProgress?.connectionName ? connectionSyncProgress : undefined, ); + const datetimeToRelative = successfulDate ? getDatetimeToRelative(successfulDate) : ''; const hasSyncError = shouldShowSyncError(policy, isSyncInProgress, accountingIntegrations); const hasUnsupportedNDIntegration = !isEmptyObject(policy?.connections) && hasSupportedOnlyOnOldDotIntegration(policy); @@ -161,99 +160,69 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { credentialsMenuTextKey = 'workspace.accounting.updateCredentials'; } - const overflowMenu: ThreeDotsMenuProps['menuItems'] = useMemo( - () => [ - ...(shouldShowReinstallConnectorMenuItem - ? [ - { - icon: icons.CircularArrowBackwards, - text: translate('workspace.accounting.reinstall'), - onSelected: () => startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.QBD}), - shouldCallAfterModalHide: true, - disabled: isOffline, - iconRight: icons.NewWindow, - }, - ] - : []), - ...(shouldShowEnterCredentials - ? [ - { - icon: icons.Key, - text: translate(credentialsMenuTextKey), - onSelected: () => { - if (isSageIntacct && policyID) { - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTER_CREDENTIALS.getRoute(policyID)); - return; - } - startIntegrationFlow({name: connectedIntegration}); - }, - shouldCallAfterModalHide: true, - disabled: isOffline, - iconRight: icons.NewWindow, - }, - ] - : []), - ...(!hasAuthError - ? [ - { - icon: icons.Sync, - text: translate('workspace.accounting.syncNow'), - onSelected: () => syncConnection(policy, connectedIntegration), - disabled: isOffline, + const overflowMenu: ThreeDotsMenuProps['menuItems'] = [ + ...(shouldShowReinstallConnectorMenuItem + ? [ + { + icon: icons.CircularArrowBackwards, + text: translate('workspace.accounting.reinstall'), + onSelected: () => startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.QBD}), + shouldCallAfterModalHide: true, + disabled: isOffline, + iconRight: icons.NewWindow, + }, + ] + : []), + ...(shouldShowEnterCredentials + ? [ + { + icon: icons.Key, + text: translate(credentialsMenuTextKey), + onSelected: () => { + if (isSageIntacct && policyID) { + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTER_CREDENTIALS.getRoute(policyID)); + return; + } + startIntegrationFlow({name: connectedIntegration}); }, - ] - : []), - { - icon: icons.Trashcan, - text: translate('workspace.accounting.disconnect'), - onSelected: () => { - showConfirmModal({ - title: translate('workspace.accounting.disconnectTitle', connectedIntegrationDisplayName), - prompt: translate('workspace.accounting.disconnectPrompt', connectedIntegrationDisplayName), - confirmText: translate('workspace.accounting.disconnect'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }).then(({action}) => { - if (action !== ModalActions.CONFIRM || !connectedIntegration || !policyID) { - return; - } - // These settings have nothing to show once the integration is gone - Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)); - removePolicyConnection(policy, connectedIntegration); - }); - }, - shouldCallAfterModalHide: true, + shouldCallAfterModalHide: true, + disabled: isOffline, + iconRight: icons.NewWindow, + }, + ] + : []), + ...(!hasAuthError + ? [ + { + icon: icons.Sync, + text: translate('workspace.accounting.syncNow'), + onSelected: () => syncConnection(policy, connectedIntegration), + disabled: isOffline, + }, + ] + : []), + { + icon: icons.Trashcan, + text: translate('workspace.accounting.disconnect'), + onSelected: () => { + showConfirmModal({ + title: translate('workspace.accounting.disconnectTitle', connectedIntegrationDisplayName), + prompt: translate('workspace.accounting.disconnectPrompt', connectedIntegrationDisplayName), + confirmText: translate('workspace.accounting.disconnect'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }).then(({action}) => { + if (action !== ModalActions.CONFIRM || !connectedIntegration || !policyID) { + return; + } + // These settings have nothing to show once the integration is gone + Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)); + removePolicyConnection(policy, connectedIntegration); + }); }, - ], - [ - icons.NewWindow, - icons.CircularArrowBackwards, - icons.Key, - icons.Sync, - icons.Trashcan, - shouldShowEnterCredentials, - shouldShowReinstallConnectorMenuItem, - translate, - isOffline, - policy, - connectedIntegration, - connectedIntegrationDisplayName, - startIntegrationFlow, - isSageIntacct, - hasAuthError, - credentialsMenuTextKey, - policyID, - showConfirmModal, - ], - ); - - useEffect(() => { - if (successfulDate) { - setDateTimeToRelative(getDatetimeToRelative(successfulDate)); - return; - } - setDateTimeToRelative(''); - }, [getDatetimeToRelative, successfulDate]); + shouldCallAfterModalHide: true, + }, + ]; useEffect(() => { if (!policyID || !policy?.areExpensifyCardsEnabled || !workspaceAccountID) { @@ -262,7 +231,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { openPolicyExpensifyCardsPage(policyID, workspaceAccountID); }, [policyID, policy?.areExpensifyCardsEnabled, workspaceAccountID]); - const integrationSpecificMenuItems = useMemo(() => { + const getIntegrationSpecificMenuItems = () => { const sageIntacctEntityList = policy?.connections?.intacct?.data?.entities ?? []; const netSuiteSubsidiaryList = policy?.connections?.netsuite?.options?.data?.subsidiaryList ?? []; const rilletSubsidiaryList = policy?.connections?.rillet?.data?.subsidiaries; @@ -282,7 +251,6 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { description: translate('workspace.xero.organization'), iconRight: icons.ArrowRight, title: getCurrentXeroOrganizationName(policy), - wrapperStyle: [styles.sectionMenuItemTopDescription], titleStyle: styles.fontWeightNormal, shouldShowRightIcon: canWriteAccounting && tenants.length > 1, shouldShowDescriptionOnTop: true, @@ -305,7 +273,6 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { description: translate('workspace.netsuite.subsidiary'), iconRight: icons.ArrowRight, title: policy?.connections?.netsuite?.options?.config?.subsidiary ?? '', - wrapperStyle: [styles.sectionMenuItemTopDescription], titleStyle: styles.fontWeightNormal, shouldShowRightIcon: canWriteAccounting && netSuiteSubsidiaryList?.length > 1, shouldShowDescriptionOnTop: true, @@ -326,7 +293,6 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { description: translate('workspace.intacct.entity'), iconRight: icons.ArrowRight, title: getCurrentSageIntacctEntityName(policy, translate('workspace.common.topLevel')), - wrapperStyle: [styles.sectionMenuItemTopDescription], titleStyle: styles.fontWeightNormal, shouldShowRightIcon: canWriteAccounting, shouldShowDescriptionOnTop: true, @@ -342,7 +308,6 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { description: translate(isConnectedToIntuitEnterpriseSuite ? 'workspace.qbo.entity' : 'workspace.qbo.connectedTo'), iconRight: isConnectedToIntuitEnterpriseSuite ? icons.ArrowRight : undefined, title: policy?.connections?.quickbooksOnline?.config?.companyName, - wrapperStyle: [styles.sectionMenuItemTopDescription], titleStyle: styles.fontWeightNormal, shouldShowRightIcon: isConnectedToIntuitEnterpriseSuite && canWriteAccounting, shouldShowDescriptionOnTop: true, @@ -361,7 +326,6 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { description: translate('workspace.certinia.company'), iconRight: icons.ArrowRight, title: selectedCertiniaCompany?.name ?? certiniaCompanyID ?? translate('common.none'), - wrapperStyle: [styles.sectionMenuItemTopDescription], titleStyle: styles.fontWeightNormal, shouldShowRightIcon: canWriteAccounting, shouldShowDescriptionOnTop: true, @@ -377,7 +341,6 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { description: translate('workspace.rillet.subsidiary'), iconRight: icons.ArrowRight, title: rilletSubsidiaryList?.find((subsidiary) => subsidiary.id === policy?.connections?.rillet?.config?.subsidiaryID)?.tradeName ?? '', - wrapperStyle: [styles.sectionMenuItemTopDescription], titleStyle: styles.fontWeightNormal, shouldShowRightIcon: canWriteAccounting && rilletSubsidiaryList && rilletSubsidiaryList.length > 1, shouldShowDescriptionOnTop: true, @@ -396,7 +359,6 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { description: translate('workspace.dualEntry.subsidiary'), iconRight: icons.ArrowRight, title: dualEntryCompanyList?.find((company) => company.id === policy?.connections?.dualEntry?.config?.subsidiaryID)?.name ?? '', - wrapperStyle: [styles.sectionMenuItemTopDescription], titleStyle: styles.fontWeightNormal, shouldShowRightIcon: canWriteAccounting && dualEntryCompanyList && dualEntryCompanyList.length > 1, shouldShowDescriptionOnTop: true, @@ -415,7 +377,6 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { description: translate('workspace.campfire.subsidiary'), iconRight: icons.ArrowRight, title: campfireSubsidiaryList?.find((subsidiary) => subsidiary.id === policy?.connections?.campfire?.config?.subsidiaryID)?.name ?? '', - wrapperStyle: [styles.sectionMenuItemTopDescription], titleStyle: styles.fontWeightNormal, shouldShowRightIcon: canWriteAccounting && campfireSubsidiaryList && campfireSubsidiaryList.length > 1, shouldShowDescriptionOnTop: true, @@ -434,7 +395,6 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { description: translate('workspace.businessCentral.subsidiary'), iconRight: icons.ArrowRight, title: businessCentralCompanyList.find((company) => company.id === policy?.connections?.businessCentral?.config?.companyID)?.displayName ?? '', - wrapperStyle: [styles.sectionMenuItemTopDescription], titleStyle: styles.fontWeightNormal, shouldShowRightIcon: canWriteAccounting && businessCentralCompanyList.length > 1, shouldShowDescriptionOnTop: true, @@ -450,23 +410,12 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { default: return undefined; } - }, [ - canWriteAccounting, - connectedIntegration, - currentXeroOrganization?.id, - isConnectedToIntuitEnterpriseSuite, - policy, - policyID, - styles.fontWeightNormal, - styles.sectionMenuItemTopDescription, - tenants.length, - translate, - icons.ArrowRight, - ]); + }; + const integrationSpecificMenuItems = getIntegrationSpecificMenuItems(); - const connectionsMenuItems: MenuItemData[] = useMemo(() => { + const getConnectionDetails = () => { if (!connectedIntegration || !policyID) { - return []; + return undefined; } const isConnectionVerified = !isConnectionUnverified(policy, connectedIntegration); const integrationData = getAccountingIntegrationData( @@ -491,8 +440,6 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { cardLists, isConnectedToIntuitEnterpriseSuite, ); - const iconProps = integrationData?.icon ? {icon: integrationData.icon, iconType: CONST.ICON_TYPE_AVATAR} : {}; - let connectionMessage; if (isSyncInProgress && connectionSyncStage) { connectionMessage = translate('workspace.accounting.connections.syncStageName', connectionSyncStage, integrationData?.title); @@ -509,7 +456,6 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { iconRight: icons.ArrowRight, shouldShowRightIcon: true, title: translate('workspace.accounting.import'), - wrapperStyle: [styles.sectionMenuItemTopDescription], onPress: integrationData?.onImportPagePress, brickRoadIndicator: areSettingsInErrorFields(integrationData?.subscribedImportSettings, integrationData?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR @@ -521,7 +467,6 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { iconRight: icons.ArrowRight, shouldShowRightIcon: true, title: translate('workspace.accounting.export'), - wrapperStyle: [styles.sectionMenuItemTopDescription], onPress: integrationData?.onExportPagePress, brickRoadIndicator: areSettingsInErrorFields(integrationData?.subscribedExportSettings, integrationData?.errorFields) || @@ -539,7 +484,6 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { iconRight: icons.ArrowRight, shouldShowRightIcon: true, title: translate('workspace.accounting.cardReconciliation'), - wrapperStyle: [styles.sectionMenuItemTopDescription], onPress: integrationData?.onCardReconciliationPagePress, }, ] @@ -549,7 +493,6 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { iconRight: icons.ArrowRight, shouldShowRightIcon: true, title: translate('workspace.accounting.advanced'), - wrapperStyle: [styles.sectionMenuItemTopDescription], onPress: integrationData?.onAdvancedPagePress, brickRoadIndicator: areSettingsInErrorFields(integrationData?.subscribedAdvancedSettings, integrationData?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR @@ -559,75 +502,14 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { ] : []; - let rightComponent; - if (isSyncInProgress) { - rightComponent = ; - } else if (canWriteAccounting) { - rightComponent = ( - - ); - } - - return [ - { - ...iconProps, - interactive: false, - wrapperStyle: [styles.sectionMenuItemTopDescription, shouldShowSynchronizationError && styles.pb0], - shouldShowRightComponent: true, - title: integrationData?.title, - errorText: synchronizationError, - errorTextStyle: [styles.mt5], - shouldShowRedDotIndicator: true, - description: connectionMessage, - rightComponent, - }, + const menuItems: MenuItemData[] = [ ...(isEmptyObject(integrationSpecificMenuItems) || shouldShowSynchronizationError || !hasAccountingConnection ? [] : [integrationSpecificMenuItems]), ...(!hasAccountingConnection || !isConnectionVerified ? [] : configurationOptions), ]; - }, [ - policy, - hasAccountingConnection, - isSyncInProgress, - policyID, - connectedIntegration, - translate, - isBetaEnabled, - accountingIcons, - cardFeeds, - cardLists, - connectionSyncStage, - icons.Pencil, - icons.ArrowRight, - icons.ExpensifyCard, - icons.Gear, - icons.Send, - styles.sectionMenuItemTopDescription, - styles.pb0, - styles.mt5, - styles.popoverMenuIcon, - shouldShowCardReconciliationOption, - shouldShowSynchronizationError, - synchronizationError, - overflowMenu, - integrationSpecificMenuItems, - isConnectedToIntuitEnterpriseSuite, - datetimeToRelative, - hasReusablePoliciesConnectedToSageIntacct, - hasReusablePoliciesConnectedToCertinia, - hasReusablePoliciesConnectedToRillet, - hasReusablePoliciesConnectedToDualEntry, - hasReusablePoliciesConnectedToCampfire, - hasReusablePoliciesConnectedToQBD, - canWriteAccounting, - ]); + return {icon: integrationData?.icon, title: integrationData?.title, connectionMessage, menuItems}; + }; + const connectionDetails = getConnectionDetails(); + const connectionsMenuItems = connectionDetails?.menuItems ?? []; const oldDotPolicyConnectionsURL = policyID ? `${oldDotEnvironmentURL}/${CONST.OLDDOT_URLS.POLICY_CONNECTIONS_URL_ENCODED(policyID)}` : ''; @@ -644,14 +526,32 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { enableEdgeToEdgeBottomSafeAreaPadding > Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} - /> + > + {!!connectionDetails && isSyncInProgress && } + - + + {shouldShowSynchronizationError && ( + + )} {!hasUnsupportedNDIntegration && connectionsMenuItems.map((menuItem) => ( )} {hasUnsupportedNDIntegration && !hasSyncError && !!policyID && ( - + { diff --git a/src/pages/workspace/merge/MergeConnectionsPageBase.tsx b/src/pages/workspace/merge/MergeConnectionsPageBase.tsx index 58ec68beeb57..adb459b1b488 100644 --- a/src/pages/workspace/merge/MergeConnectionsPageBase.tsx +++ b/src/pages/workspace/merge/MergeConnectionsPageBase.tsx @@ -1,13 +1,10 @@ import ConnectToMergeFlow from '@components/ConnectToMergeFlow'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import usePolicy from '@hooks/usePolicy'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; -import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; import {openPolicyHRPage, openPolicyRecruitingPage} from '@libs/actions/PolicyConnections'; @@ -59,7 +56,6 @@ type MergeConnectionsPageBaseProps = MergeConnectionsPageBaseContentProps & { function MergeConnectionsPageBaseContent({policyID, category, cards}: MergeConnectionsPageBaseContentProps) { const {translate} = useLocalize(); - const styles = useThemeStyles(); const policy = usePolicy(policyID); const [activeSetupFlow, setActiveSetupFlow] = useState<{setupLink: string; key: number} | undefined>(); @@ -109,25 +105,17 @@ function MergeConnectionsPageBaseContent({policyID, category, cards}: MergeConne onDone={() => setActiveSetupFlow(undefined)} /> )} - Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} - /> - - {!!connectedCard && ( - handleReconnect(connectedCard)} - onDisconnect={() => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} - canWriteMoreFeatures={canWriteMoreFeatures} - showReadOnlyModal={showReadOnlyModal} - /> - )} - + {!!connectedCard && ( + handleReconnect(connectedCard)} + onDisconnect={() => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} + onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} + canWriteMoreFeatures={canWriteMoreFeatures} + showReadOnlyModal={showReadOnlyModal} + /> + )} ); } diff --git a/src/pages/workspace/merge/MergeProviderCard.tsx b/src/pages/workspace/merge/MergeProviderCard.tsx index 3234bcbac2fc..be213d4278d6 100644 --- a/src/pages/workspace/merge/MergeProviderCard.tsx +++ b/src/pages/workspace/merge/MergeProviderCard.tsx @@ -1,13 +1,13 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import Button from '@components/Button'; -import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; +import FormHelpMessage from '@components/FormHelpMessage'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import RenderHTML from '@components/RenderHTML'; +import ScrollView from '@components/ScrollView'; import TextLink from '@components/TextLink'; -import ThreeDotsMenu from '@components/ThreeDotsMenu'; import type ThreeDotsMenuProps from '@components/ThreeDotsMenu/types'; import useConfirmModal from '@hooks/useConfirmModal'; @@ -45,6 +45,9 @@ type MergeProviderCardProps = { /** Called once the user confirms disconnecting, right before the connection is removed */ onDisconnect?: () => void; + /** Called when the header's back button is pressed. */ + onBackButtonPress: () => void; + /** Whether the current user can edit this Merge connection. */ canWriteMoreFeatures: boolean; @@ -52,7 +55,7 @@ type MergeProviderCardProps = { showReadOnlyModal: () => void; }; -function MergeProviderCard({card, policy, handleConnect, onDisconnect, canWriteMoreFeatures, showReadOnlyModal}: MergeProviderCardProps) { +function MergeProviderCard({card, policy, handleConnect, onDisconnect, onBackButtonPress, canWriteMoreFeatures, showReadOnlyModal}: MergeProviderCardProps) { const {translate, datetimeToRelative} = useLocalize(); const styles = useThemeStyles(); const {environmentURL} = useEnvironment(); @@ -155,88 +158,66 @@ function MergeProviderCard({card, policy, handleConnect, onDisconnect, canWriteM }, ]; - let rightInset: React.ReactNode; - if (!card.isConnected) { - rightInset = ( - - {translate('workspace.merge.connect')} - - ); - } else if (card.isSyncInProgress) { - rightInset = ; - } else { - rightInset = ( - - ); - } - - const rightComponent = {rightInset}; - // While the setup is incomplete only the rows that failed to save are shown, so the admin is steered to the setup flow first. const visibleConfigRows = card.isConnected && !card.isInitialSyncInProgress ? (card.configRows ?? []).filter((row) => !card.completeSetupRoute || !!row.errors) : []; return ( <> - + {card.isSyncInProgress && } + + + {!!card.completeSetupRoute && ( + - ) : undefined - } - errorText={lastSyncErrorMessage} - errorTextStyle={styles.mt5} - shouldShowRedDotIndicator - shouldShowRightComponent={!!rightInset} - brickRoadIndicator={card.completeSetupRoute ? CONST.BRICK_ROAD_INDICATOR_STATUS.INFO : undefined} - rightComponent={rightComponent} - fallbackIcon={fallbackIcon} - /> - {visibleConfigRows.length > 0 && ( - - {visibleConfigRows.map((row) => { - const RowMenuItem = row.shouldRenderAsMenuItem ? MenuItem : MenuItemWithTopDescription; - - return ( - clearMergeConnectionErrorField(policy?.id, card.connectionName, row.field)} - > - Navigation.navigate(row.route)} - interactive={canWriteMoreFeatures} - /> - - ); - })} - - )} + + )} + {!!lastSyncErrorMessage && ( + + )} + {visibleConfigRows.map((row) => { + const RowMenuItem = row.shouldRenderAsMenuItem ? MenuItem : MenuItemWithTopDescription; + + return ( + clearMergeConnectionErrorField(policy?.id, card.connectionName, row.field)} + > + Navigation.navigate(row.route)} + interactive={canWriteMoreFeatures} + /> + + ); + })} + ); } diff --git a/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx b/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx index 7fc90b50496a..ff248e19ea24 100644 --- a/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx +++ b/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx @@ -1,13 +1,12 @@ +import FormHelpMessage from '@components/FormHelpMessage'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import MenuItemSectionRoot from '@components/MenuItem/presets/MenuItemSectionRoot'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; -import ThreeDotsMenu from '@components/ThreeDotsMenu'; import useConfirmModal from '@hooks/useConfirmModal'; import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; @@ -16,7 +15,6 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import usePolicy from '@hooks/usePolicy'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScreenBoundDynamicRoute from '@hooks/useScreenBoundDynamicRoute'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; @@ -26,7 +24,6 @@ import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation import type {SettingsNavigatorParamList} from '@navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import type {MenuItemData} from '@pages/workspace/accounting/types'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; import {openExternalLink} from '@userActions/Link'; @@ -35,12 +32,10 @@ import {openPolicyReceiptPartnersPage, removePolicyReceiptPartnersConnection, to import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {AnchorPosition} from '@src/styles'; -import type {ComponentRef} from 'react'; +import type {ValueOf} from 'type-fest'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; -import {View} from 'react-native'; +import React, {useCallback, useEffect} from 'react'; import getSynchronizationErrorMessage from './utils'; @@ -52,11 +47,7 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps const {translate} = useLocalize(); const buildDynamicRoute = useScreenBoundDynamicRoute(); const styles = useThemeStyles(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); const {showConfirmModal} = useConfirmModal(); - const receiptPartnerNames = CONST.POLICY.RECEIPT_PARTNERS.NAME; - const receiptPartnerIntegrations = Object.values(receiptPartnerNames); - const threeDotsMenuContainerRef = useRef>(null); const policy = usePolicy(policyID); useWorkspaceDocumentTitle(policy?.name, 'workspace.common.receiptPartners'); const {getReceiptPartnersIntegrationData, shouldShowEnterCredentialsError, isUberConnected} = useGetReceiptPartnersIntegrationData(policyID); @@ -93,20 +84,6 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const calculateAndSetThreeDotsMenuPosition = useCallback(() => { - if (shouldUseNarrowLayout) { - return Promise.resolve({horizontal: 0, vertical: 0}); - } - return new Promise((resolve) => { - threeDotsMenuContainerRef.current?.measureInWindow((x, y, width, height) => { - resolve({ - horizontal: x + width, - vertical: y + height, - }); - }); - }); - }, [shouldUseNarrowLayout]); - const toggleWorkspaceUberAutoInvite = useCallback(() => { togglePolicyUberAutoInvite(policyID, !isAutoInvite); }, [isAutoInvite, policyID]); @@ -116,7 +93,7 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps }, [isAutoRemove, policyID]); const disconnectPartner = useCallback( - (partner: (typeof receiptPartnerNames)[keyof typeof receiptPartnerNames]) => { + (partner: ValueOf) => { if (!policyID) { return; } @@ -176,69 +153,7 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps [icons.Key, icons.NewWindow, icons.Trashcan, shouldShowEnterCredentialsError, translate, isOffline, startIntegrationFlow, showConfirmModal, disconnectPartner, policyID], ); - const connectionsMenuItems: MenuItemData[] = useMemo(() => { - if (policyID) { - return receiptPartnerIntegrations - .map((integration) => { - const integrationData = getReceiptPartnersIntegrationData(integration); - if (!integrationData) { - return undefined; - } - const overflowMenu = canWriteMoreFeatures ? getOverflowMenu(integration) : []; - - const iconProps = integrationData?.icon - ? { - icon: integrationData.icon, - iconType: CONST.ICON_TYPE_AVATAR, - } - : {}; - - let rightComponent: React.ReactNode; - if (canWriteMoreFeatures) { - rightComponent = ( - - - - ); - } - - return { - ...iconProps, - ...integrationData, - interactive: false, - errorText: shouldShowEnterCredentialsError ? getSynchronizationErrorMessage(integrationData.title, translate, styles) : undefined, - wrapperStyle: [styles.sectionMenuItemTopDescription], - shouldShowRightComponent: !!rightComponent, - title: integrationData?.title, - numberOfLinesDescription: 5, - titleContainerStyle: [styles.pr2], - description: integrationData?.description, - brickRoadIndicator: !!integrationData?.errorFields || shouldShowEnterCredentialsError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - rightComponent, - }; - }) - .filter(Boolean) as MenuItemData[]; - } - - return []; - }, [ - policyID, - receiptPartnerIntegrations, - getReceiptPartnersIntegrationData, - getOverflowMenu, - canWriteMoreFeatures, - shouldShowEnterCredentialsError, - translate, - styles, - calculateAndSetThreeDotsMenuPosition, - ]); + const uberData = policyID ? getReceiptPartnersIntegrationData(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER) : undefined; return ( Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} /> - - {connectionsMenuItems.map((menuItem) => ( - - + )} + {isUberConnected && ( + <> + + - ))} - {isUberConnected && ( - <> - - - - - - - - - + + + + {centralBillingAccountEmail && ( + + + Navigation.navigate( + ROUTES.WORKSPACE_RECEIPT_PARTNERS_CHANGE_BILLING_ACCOUNT.getRoute(policyID, CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER), + ) + : undefined + } + /> - {centralBillingAccountEmail && ( - - - - Navigation.navigate( - ROUTES.WORKSPACE_RECEIPT_PARTNERS_CHANGE_BILLING_ACCOUNT.getRoute(policyID, CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER), - ) - : undefined - } - > - - {canWriteMoreFeatures && } - - - - - )} - {canWriteMoreFeatures && ( - - - Navigation.navigate( - buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_RECEIPT_PARTNERS_INVITE_EDIT.getRoute(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER)), - ) - } - > - - - - - - {translate('workspace.receiptPartners.uber.manageInvites')} - - - - - - - - )} - - )} - + )} + {canWriteMoreFeatures && ( + + Navigation.navigate(buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_RECEIPT_PARTNERS_INVITE_EDIT.getRoute(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER))) + } + /> + )} + + )} )} From ccf4bfe3477afed8a2b7793ef50ed372e3f2debc Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 29 Sep 2026 07:35:36 +0530 Subject: [PATCH 14/51] Address review findings on the Connections page Signed-off-by: krishna2323 --- src/CONST/index.ts | 2 + src/languages/en.ts | 39 ------------------- .../accounting/AccountingContext/index.tsx | 2 +- .../workspace/connections/ConnectionCard.tsx | 20 ++++------ .../workspace/connections/ConnectionsGrid.tsx | 1 + .../connections/WorkspaceConnectionsPage.tsx | 20 +++++----- .../connections/useMergeConnectionListings.ts | 9 ++++- src/pages/workspace/getWorkspaceMenuItems.ts | 2 +- 8 files changed, 31 insertions(+), 64 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 43e01ac370f7..886487634434 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9730,6 +9730,8 @@ const CONST = { THREE_DOT_MENU: 'WorkspaceAccounting-ThreeDotMenu', }, CONNECTIONS: { + CARD: 'WorkspaceConnections-Card', + CONFIGURE_BUTTON: 'WorkspaceConnections-ConfigureButton', SUGGEST_INTEGRATION: 'WorkspaceConnections-SuggestIntegration', }, RULES: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 540ab599d94a..535ed44cb274 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4869,7 +4869,6 @@ const translations = { accounting: 'Accounting', hr: 'HR', recruiting: 'Recruiting', - mcp: 'MCP', receiptPartners: 'Receipt partners', connections: 'Connections', rules: 'Rules', @@ -5016,22 +5015,14 @@ const translations = { description: 'Great news 🎉. Reach out to us if they need any help with the setup.', }, mcp: { - connectors: 'Connectors', - connectorsSubtitle: 'Connect an AI assistant to your Expensify account.', - connect: 'Connect', - helpPrompt: 'Need help connecting?', - helpLink: 'Read our guide.', claude: { title: 'Claude', - subtitle: 'by Anthropic', }, cursor: { title: 'Cursor', - subtitle: 'by Anysphere', }, chatgpt: { title: 'ChatGPT', - subtitle: 'by OpenAI', }, }, connections: { @@ -5049,7 +5040,6 @@ const translations = { active: 'Active', broken: 'Broken', brokenConnection: 'Cannot sync due to broken connection', - syncing: 'Syncing', synced: (calendarTime: string) => `Synced ${calendarTime}`, hrisListing: (providerName: string) => `${providerName} (HRIS)`, atsListing: (providerName: string) => `${providerName} (ATS)`, @@ -6661,10 +6651,6 @@ const translations = { title: 'Organize', subtitle: 'Group and analyze spend, record every tax paid.', }, - integrateSection: { - title: 'Integrate', - subtitle: 'Connect Expensify to popular financial products.', - }, distanceRates: { title: 'Distance rates', subtitle: 'Add, update, and enforce rates.', @@ -6897,33 +6883,18 @@ const translations = { }, connections: { title: 'Accounting', - subtitle: 'Sync your chart of accounts and more.', - }, - mcp: { - title: 'MCP', - subtitle: 'Connect an AI assistant to your Expensify account.', }, receiptPartners: { title: 'Receipt partners', - subtitle: 'Automatically import receipts.', }, connectionsWarningModal: { featureEnabledTitle: 'Not so fast...', featureEnabledText: "To enable or disable this feature, you'll need to change your accounting import settings.", - disconnectText: "To disable accounting, you'll need to disconnect your accounting connection from your workspace.", manageSettings: 'Manage settings', }, receiptPartnersWarningModal: { featureEnabledTitle: 'Disconnect Uber', - disconnectText: 'To disable this feature, please disconnect the Uber for Business integration first.', description: 'Are you sure you want to disconnect this integration?', - confirmText: 'Got it', - }, - hrWarningModal: { - disconnectText: ({integration}: {integration: string}) => `To disable HR, please disconnect ${integration} from this workspace first.`, - }, - recruitingWarningModal: { - disconnectText: ({integration}: {integration: string}) => `To disable Recruiting, please disconnect ${integration} from this workspace first.`, }, workflowWarningModal: { featureEnabledTitle: 'Not so fast...', @@ -7727,9 +7698,6 @@ const translations = { }, hr: { title: 'HR', - connectionsSubtitle: - "Connect to your HR system to sync employee data, auto-match reimbursements to the right people, and keep your team's expenses accurate without the manual work.", - subtitle: 'Connect HR tools and keep employee approvals in sync.', alreadyConnectedTitle: 'Cannot connect to multiple HR platforms', alreadyConnectedPrompt: 'You must disconnect your current HR platform before connecting another.', connectionDescription: (providerName: string) => `Connect ${providerName} to keep employee approvals in sync with your workspace.`, @@ -7786,9 +7754,6 @@ const translations = { }, recruiting: { title: 'Recruiting', - subtitle: 'Connect Recruiting tools and keep candidate approvals in sync.', - connectionsSubtitle: - "Connect to your recruiting system to sync candidate data, auto-match reimbursements to the right people, and keep your team's expenses accurate without the manual work.", alreadyConnectedTitle: 'Cannot connect to multiple ATS platforms', alreadyConnectedPrompt: 'You must disconnect your current ATS before connecting another.', syncing: 'Syncing candidates', @@ -7800,7 +7765,6 @@ const translations = { }, setupIncomplete: (setupLink: string | undefined) => `Connected. ${setupLink ? `Complete setup` : 'Complete setup'} to import candidates.`, - dontSeeYourATS: `Don't see your ATS here? Ask Concierge and we can add it.`, importSettings: 'Import settings', defaultApprover: 'Default approver', approverField: `First approver`, @@ -7841,9 +7805,6 @@ const translations = { }, }, merge: { - connections: 'Connections', - connect: 'Connect', - findIntegration: 'Find integration', syncNow: 'Sync now', disconnect: 'Disconnect', disconnectTitle: (providerName: string) => `Disconnect ${providerName}`, diff --git a/src/pages/workspace/accounting/AccountingContext/index.tsx b/src/pages/workspace/accounting/AccountingContext/index.tsx index 8e8be5e6f0ab..08b7b86e7ce6 100644 --- a/src/pages/workspace/accounting/AccountingContext/index.tsx +++ b/src/pages/workspace/accounting/AccountingContext/index.tsx @@ -171,7 +171,7 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider showConfirmModal({ // `startIntegrationFlow` can run more than once for the same flow (the `useFocusEffect` in - // `PolicyAccountingPage` re-fires whenever `startIntegrationFlow` is re-created). A stable id keeps the + // `WorkspaceConnectionsPage` re-fires whenever `startIntegrationFlow` is re-created). A stable id keeps the // repeat call updating this prompt in place instead of stacking a second copy behind it. id: ACCOUNTING_CONNECTION_CONFIRMATION_MODAL_ID, title: translate('workspace.accounting.alreadyConnectedTitle'), diff --git a/src/pages/workspace/connections/ConnectionCard.tsx b/src/pages/workspace/connections/ConnectionCard.tsx index 466dbefcfbea..7aaf1263a7bb 100644 --- a/src/pages/workspace/connections/ConnectionCard.tsx +++ b/src/pages/workspace/connections/ConnectionCard.tsx @@ -10,7 +10,6 @@ import MenuItem from '@components/MenuItem'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -26,15 +25,17 @@ import type {ConnectionListing} from './types'; type ConnectionCardProps = { listing: ConnectionListing; + + /** Whether the card takes the whole row instead of sharing it with a second card */ + shouldUseFullWidth: boolean; }; -function ConnectionCard({listing}: ConnectionCardProps) { +function ConnectionCard({listing, shouldUseFullWidth}: ConnectionCardProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const theme = useTheme(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); - const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); const icons = useMemoizedLazyExpensifyIcons(['Building', 'Plus']); const {title, icon, status, onConnect, onConfigure, registerConnectButton} = listing; @@ -55,22 +56,14 @@ function ConnectionCard({listing}: ConnectionCardProps) { variant={status.isBroken ? CONST.BUTTON_VARIANT.DANGER : undefined} onPress={onConfigure} style={[styles.alignSelfCenter, styles.ml3]} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.CONNECTIONS.CONFIGURE_BUTTON} > {translate(status.isBroken ? 'workspace.connections.fix' : 'workspace.connections.configure')} ); return ( - + ))} {needsFiller && ( diff --git a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx index f8f5a33d006e..eb3dcbc07e5f 100644 --- a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx +++ b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx @@ -97,7 +97,7 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { const icons = useMemoizedLazyExpensifyIcons(['ArrowRight']); const {translate} = useLocalize(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); - const shouldUseNarrowGridLayout = shouldUseNarrowLayout || isMediumScreenWidth; + const shouldUseSingleColumn = shouldUseNarrowLayout || isMediumScreenWidth; const [isSearchFocused, setIsSearchFocused] = useState(false); const {isBetaEnabled} = usePermissions(); const {openConciergeAnywhere} = useOpenConciergeAnywhere(); @@ -181,25 +181,26 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { title: translate(`workspace.connections.tabs.${tab}`), })); + const searchLabel = translate('workspace.connections.findConnections'); const searchBar = ( setIsSearchFocused(true)} onBlur={() => setIsSearchFocused(false)} onChangeText={setSearchValue} @@ -263,7 +264,7 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { )} - 0 ? styles.mt6 : styles.mt3, !shouldUseNarrowGridLayout && [styles.flexRow, styles.alignItemsCenter, styles.gap5]]}> + 0 ? styles.mt6 : styles.mt3, !shouldUseSingleColumn && [styles.flexRow, styles.alignItemsCenter, styles.gap5]]}> - {searchBar} + {searchBar} {shouldShowNoResults ? ( @@ -316,6 +317,7 @@ function WorkspaceConnectionsPageWrapper(props: WithPolicyConnectionsProps) { diff --git a/src/pages/workspace/connections/useMergeConnectionListings.ts b/src/pages/workspace/connections/useMergeConnectionListings.ts index a4e53a2d5664..bf70a1ca9fe9 100644 --- a/src/pages/workspace/connections/useMergeConnectionListings.ts +++ b/src/pages/workspace/connections/useMergeConnectionListings.ts @@ -1,13 +1,15 @@ -import useConfirmModal from '@hooks/useConfirmModal'; /** * Builds the People listings (HR and, behind the Merge ATS beta, recruiting providers) for the Connections page. */ +import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useStyleUtils from '@hooks/useStyleUtils'; import Navigation from '@libs/Navigation/Navigation'; import {isControlPolicy} from '@libs/PolicyUtils'; @@ -16,6 +18,8 @@ import {getHRCards} from '@pages/workspace/hr/utils'; import type {MergeProviderCardCategory, MergeProviderCardDescriptor} from '@pages/workspace/merge/types'; import {getRecruitingCards} from '@pages/workspace/recruiting/utils'; +import variables from '@styles/variables'; + import {enablePolicyHR, enablePolicyRecruiting} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; @@ -49,6 +53,8 @@ function useMergeConnectionListings(policy: OnyxEntry, onStartSetup: (se const {translate, getLocalDateFromDatetime, datetimeToCalendarTime, formatPhoneNumber} = useLocalize(); const {isBetaEnabled} = usePermissions(); const {showConfirmModal} = useConfirmModal(); + const StyleUtils = useStyleUtils(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const policyEmployeePersonalDetails = usePersonalDetailsByLogins([...Object.keys(policy?.employeeList ?? {})]); const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); const {canWrite, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); @@ -94,6 +100,7 @@ function useMergeConnectionListings(policy: OnyxEntry, onStartSetup: (se prompt: translate(`workspace.${card.category}.alreadyConnectedPrompt`), confirmText: translate('common.buttonConfirm'), shouldShowCancelButton: false, + innerContainerStyle: shouldUseNarrowLayout ? undefined : StyleUtils.getWidthStyle(variables.wideConfirmModalWidth), }); return; } diff --git a/src/pages/workspace/getWorkspaceMenuItems.ts b/src/pages/workspace/getWorkspaceMenuItems.ts index 8d6f647ae218..6ac5d6685269 100644 --- a/src/pages/workspace/getWorkspaceMenuItems.ts +++ b/src/pages/workspace/getWorkspaceMenuItems.ts @@ -209,7 +209,7 @@ function getWorkspaceMenuItems({ }); } - if (canReadPolicyFeature(CONST.POLICY.POLICY_FEATURE.ACCOUNTING) || canReadMoreFeatures) { + if (canReadMoreFeatures) { items.push({ translationKey: 'workspace.common.connections', icon: icons.Connect, From ac3c2aa62955bae25556b5308a9d6303bb4ae71c Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 29 Sep 2026 07:40:16 +0530 Subject: [PATCH 15/51] Remove the unused CompactSearchBar Signed-off-by: krishna2323 --- src/components/SearchBar/CompactSearchBar.tsx | 45 ------------------- 1 file changed, 45 deletions(-) delete mode 100644 src/components/SearchBar/CompactSearchBar.tsx diff --git a/src/components/SearchBar/CompactSearchBar.tsx b/src/components/SearchBar/CompactSearchBar.tsx deleted file mode 100644 index c623e6196f2c..000000000000 --- a/src/components/SearchBar/CompactSearchBar.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; - -import React from 'react'; - -import type {SharedSearchBarProps} from './BaseSearchBar'; - -import BaseSearchBar from './BaseSearchBar'; - -type CompactSearchBarProps = SharedSearchBarProps; - -/** - * The compact list-style search input (placeholder instead of a floating label), matching the one used above selection lists. - */ -function CompactSearchBar({ref, label, style, inputValue, onChangeText, onSubmitEditing, shouldShowEmptyState, emptyStateContainerStyle}: CompactSearchBarProps) { - const styles = useThemeStyles(); - const theme = useTheme(); - // The compact search input is sized by the physical device width (`isSmallScreenWidth`), not `shouldUseNarrowLayout`, so - // it stays compact when rendered inside an RHP/narrow pane on web/desktop and only grows to the tall size on mobile. - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth} = useResponsiveLayout(); - - return ( - - ); -} - -export default CompactSearchBar; From 0fc49e2deb8cd0dc5e9c5cdcf2e96c5738dba891 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 29 Sep 2026 09:21:20 +0530 Subject: [PATCH 16/51] Add three columns, tab fades, and a table-style empty state to Connections Signed-off-by: krishna2323 --- .../TabSelector/TabSelectorBase.tsx | 200 ++++++++++-------- .../TabSelector/TabSelectorScrollFade.tsx | 62 ++++++ src/components/TabSelector/types.ts | 3 + src/languages/en.ts | 3 + .../workspace/connections/ConnectionCard.tsx | 18 +- .../workspace/connections/ConnectionsGrid.tsx | 34 ++- .../connections/WorkspaceConnectionsPage.tsx | 70 +++--- src/styles/index.ts | 5 + src/styles/variables.ts | 2 + 9 files changed, 271 insertions(+), 126 deletions(-) create mode 100644 src/components/TabSelector/TabSelectorScrollFade.tsx diff --git a/src/components/TabSelector/TabSelectorBase.tsx b/src/components/TabSelector/TabSelectorBase.tsx index 4433519190bc..df182f1df08d 100644 --- a/src/components/TabSelector/TabSelectorBase.tsx +++ b/src/components/TabSelector/TabSelectorBase.tsx @@ -14,6 +14,7 @@ import getBackgroundColor from './getBackground'; import getOpacity from './getOpacity'; import {useTabSelectorActions, useTabSelectorState} from './TabSelectorContext'; import TabSelectorItem from './TabSelectorItem'; +import TabSelectorScrollFade from './TabSelectorScrollFade'; /** * Navigation-agnostic tab selector UI that renders a row of TabSelectorItem components. @@ -33,6 +34,7 @@ function TabSelectorBase({ equalWidth = false, contentContainerStyles, tabButtonStyles, + shouldShowScrollFade = false, }: TabSelectorBaseProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -48,6 +50,10 @@ function TabSelectorBase({ const activeIndex = tabs.findIndex((tab) => tab.key === activeTabKey); + const [scrollMetrics, setScrollMetrics] = useState({offset: 0, viewportWidth: 0, contentWidth: 0}); + const canScrollLeft = scrollMetrics.offset > 1; + const canScrollRight = scrollMetrics.offset + scrollMetrics.viewportWidth < scrollMetrics.contentWidth - 1; + // After a tab change, reset affectedAnimatedTabs once the transition is done so // tabs settle back into the default animated state. useEffect(() => { @@ -59,96 +65,116 @@ function TabSelectorBase({ }, [defaultAffectedAnimatedTabs, activeIndex]); return ( - { - onContainerScroll(e); - triggerScrollEvent(); - }} - ref={containerRef} - style={styles.scrollableTabSelector} - // On iOS a horizontal ScrollView lays out its content along an unbounded main axis, so flex-1 tabs - // (equalWidth) divide their intrinsic content width instead of the viewport. Giving the content - // container a definite width lets the flex children split it evenly. Scoped to equalWidth so normal - // overflowing/scrollable tab rows are not constrained. - contentContainerStyle={[styles.tabSelectorContentContainer, equalWidth && styles.w100, contentContainerStyles]} - horizontal - showsHorizontalScrollIndicator={false} - keyboardShouldPersistTaps="handled" - > - {tabs.map((tab, index) => { - const isActive = index === activeIndex; - const activeOpacity = getOpacity({ - routesLength, - tabIndex: index, - active: true, - affectedTabs: affectedAnimatedTabs, - position, - isActive, - }); - const inactiveOpacity = getOpacity({ - routesLength, - tabIndex: index, - active: false, - affectedTabs: affectedAnimatedTabs, - position, - isActive, - }); - const backgroundColor = getBackgroundColor({ - routesLength, - tabIndex: index, - affectedTabs: affectedAnimatedTabs, - theme, - position, - isActive, - }); - - const handlePress = () => { - if (tab.isDisabled) { - tab.disabledAction?.(); - return; + <> + { + onContainerLayout(e); + if (shouldShowScrollFade) { + const viewportWidth = e.nativeEvent.layout.width; + setScrollMetrics((metrics) => ({...metrics, viewportWidth})); } - if (isActive) { - onActiveTabPress(tab.key); + }} + onContentSizeChange={(contentWidth) => { + if (!shouldShowScrollFade) { return; } - setAffectedAnimatedTabs([activeIndex, index]); - onTabPress(tab.key); - }; - - return ( - onLongTabPress(tab.key) : undefined} - activeOpacity={activeOpacity} - inactiveOpacity={inactiveOpacity} - backgroundColor={backgroundColor} - isActive={isActive} - testID={tab.testID} - sentryLabel={tab.sentryLabel} - shouldShowLabelWhenInactive={shouldShowLabelWhenInactive} - equalWidth={equalWidth} - tabButtonStyles={tabButtonStyles} - badgeText={tab.badgeText} - isBadgeCondensed={tab.isBadgeCondensed} - badgeStyles={tab.badgeStyles} - isBadgeError={tab.isBadgeError} - pendingAction={tab.pendingAction} - isDisabled={tab.isDisabled} - disabledAction={tab.disabledAction} - badgeEducationalTooltipProps={tab.badgeEducationalTooltipProps} - /> - ); - })} - + setScrollMetrics((metrics) => ({...metrics, contentWidth})); + }} + onScroll={(e) => { + onContainerScroll(e); + triggerScrollEvent(); + if (shouldShowScrollFade) { + const offset = e.nativeEvent.contentOffset.x; + setScrollMetrics((metrics) => ({...metrics, offset})); + } + }} + ref={containerRef} + style={styles.scrollableTabSelector} + // On iOS a horizontal ScrollView lays out its content along an unbounded main axis, so flex-1 tabs + // (equalWidth) divide their intrinsic content width instead of the viewport. Giving the content + // container a definite width lets the flex children split it evenly. Scoped to equalWidth so normal + // overflowing/scrollable tab rows are not constrained. + contentContainerStyle={[styles.tabSelectorContentContainer, equalWidth && styles.w100, contentContainerStyles]} + horizontal + showsHorizontalScrollIndicator={false} + keyboardShouldPersistTaps="handled" + > + {tabs.map((tab, index) => { + const isActive = index === activeIndex; + const activeOpacity = getOpacity({ + routesLength, + tabIndex: index, + active: true, + affectedTabs: affectedAnimatedTabs, + position, + isActive, + }); + const inactiveOpacity = getOpacity({ + routesLength, + tabIndex: index, + active: false, + affectedTabs: affectedAnimatedTabs, + position, + isActive, + }); + const backgroundColor = getBackgroundColor({ + routesLength, + tabIndex: index, + affectedTabs: affectedAnimatedTabs, + theme, + position, + isActive, + }); + + const handlePress = () => { + if (tab.isDisabled) { + tab.disabledAction?.(); + return; + } + if (isActive) { + onActiveTabPress(tab.key); + return; + } + setAffectedAnimatedTabs([activeIndex, index]); + onTabPress(tab.key); + }; + + return ( + onLongTabPress(tab.key) : undefined} + activeOpacity={activeOpacity} + inactiveOpacity={inactiveOpacity} + backgroundColor={backgroundColor} + isActive={isActive} + testID={tab.testID} + sentryLabel={tab.sentryLabel} + shouldShowLabelWhenInactive={shouldShowLabelWhenInactive} + equalWidth={equalWidth} + tabButtonStyles={tabButtonStyles} + badgeText={tab.badgeText} + isBadgeCondensed={tab.isBadgeCondensed} + badgeStyles={tab.badgeStyles} + isBadgeError={tab.isBadgeError} + pendingAction={tab.pendingAction} + isDisabled={tab.isDisabled} + disabledAction={tab.disabledAction} + badgeEducationalTooltipProps={tab.badgeEducationalTooltipProps} + /> + ); + })} + + {shouldShowScrollFade && canScrollLeft && } + {shouldShowScrollFade && canScrollRight && } + ); } diff --git a/src/components/TabSelector/TabSelectorScrollFade.tsx b/src/components/TabSelector/TabSelectorScrollFade.tsx new file mode 100644 index 000000000000..ee8148333c53 --- /dev/null +++ b/src/components/TabSelector/TabSelectorScrollFade.tsx @@ -0,0 +1,62 @@ +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import variables from '@styles/variables'; + +import React from 'react'; +import {View} from 'react-native'; +import Svg, {Defs, LinearGradient, Rect, Stop} from 'react-native-svg'; + +type TabSelectorScrollFadeProps = { + /** Which edge of the tab row the fade covers */ + side: 'left' | 'right'; +}; + +/** Fades the tabs out toward an edge so a cut-off tab reads as scrollable */ +function TabSelectorScrollFade({side}: TabSelectorScrollFadeProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const gradientID = `tabSelectorScrollFade-${side}`; + + return ( + + + + + + + + + + + + ); +} + +export default TabSelectorScrollFade; diff --git a/src/components/TabSelector/types.ts b/src/components/TabSelector/types.ts index 7981520e8ced..02fa391df9fe 100644 --- a/src/components/TabSelector/types.ts +++ b/src/components/TabSelector/types.ts @@ -94,6 +94,9 @@ type TabSelectorBaseProps = { /** Additional styles for each tab button. */ tabButtonStyles?: StyleProp; + + /** Whether to fade the edges that have more tabs to scroll to. The fades are positioned against the parent view. */ + shouldShowScrollFade?: boolean; }; type TabSelectorItemProps = WithSentryLabel & { diff --git a/src/languages/en.ts b/src/languages/en.ts index 535ed44cb274..0693259f1ce8 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5044,6 +5044,9 @@ const translations = { hrisListing: (providerName: string) => `${providerName} (HRIS)`, atsListing: (providerName: string) => `${providerName} (ATS)`, suggestIntegration: "Don't see yours? Suggest an integration, we'll look into it.", + noResultsPrompt: 'Please adjust your search or', + suggestAnIntegration: 'suggest an integration', + noResultsPromptEnd: '.', }, receiptPartners: { uber: { diff --git a/src/pages/workspace/connections/ConnectionCard.tsx b/src/pages/workspace/connections/ConnectionCard.tsx index 7aaf1263a7bb..294e1fcf296b 100644 --- a/src/pages/workspace/connections/ConnectionCard.tsx +++ b/src/pages/workspace/connections/ConnectionCard.tsx @@ -26,11 +26,11 @@ import type {ConnectionListing} from './types'; type ConnectionCardProps = { listing: ConnectionListing; - /** Whether the card takes the whole row instead of sharing it with a second card */ - shouldUseFullWidth: boolean; + /** How many cards share a row */ + columnCount: number; }; -function ConnectionCard({listing, shouldUseFullWidth}: ConnectionCardProps) { +function ConnectionCard({listing, columnCount}: ConnectionCardProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const theme = useTheme(); @@ -63,7 +63,17 @@ function ConnectionCard({listing, shouldUseFullWidth}: ConnectionCardProps) { ); return ( - + variables.connectionsThreeColumnBreakpoint) { + columnCount = 3; + } - // An odd number of cards gets an invisible filler so the last card keeps its column width - const needsFiller = !shouldUseSingleColumn && listings.length % 2 === 1; + // Invisible fillers complete the last row so its cards keep their column width + const fillerCount = (columnCount - (listings.length % columnCount)) % columnCount; return ( @@ -31,16 +40,25 @@ function ConnectionsGrid({listings}: ConnectionsGridProps) { ))} - {needsFiller && ( + {Array.from({length: fillerCount}, (_, index) => ( - )} + ))} ); } diff --git a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx index eb3dcbc07e5f..162483f0c9bc 100644 --- a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx +++ b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx @@ -3,6 +3,7 @@ * receipt partners, and AI assistants. */ import ConnectToMergeFlow from '@components/ConnectToMergeFlow'; +import GenericEmptyStateComponent from '@components/EmptyStateComponent/GenericEmptyStateComponent'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; import {PressableWithFeedback} from '@components/Pressable'; @@ -13,9 +14,10 @@ import TabSelectorContextProvider from '@components/TabSelector/TabSelectorConte import type {TabSelectorBaseItem} from '@components/TabSelector/types'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; +import TextLink from '@components/TextLink'; import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibilityAnnouncement'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOpenConciergeAnywhere from '@hooks/useOpenConciergeAnywhere'; @@ -95,6 +97,7 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { const styles = useThemeStyles(); const theme = useTheme(); const icons = useMemoizedLazyExpensifyIcons(['ArrowRight']); + const illustrations = useMemoizedLazyIllustrations(['EmptyShelves']); const {translate} = useLocalize(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); const shouldUseSingleColumn = shouldUseNarrowLayout || isMediumScreenWidth; @@ -265,48 +268,61 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { )} 0 ? styles.mt6 : styles.mt3, !shouldUseSingleColumn && [styles.flexRow, styles.alignItemsCenter, styles.gap5]]}> - + { + setSearchValue(''); + setActiveTab(tab); + }} + onActiveTabPress={() => setSearchValue('')} + // On mobile the tabs scroll edge to edge, so the page gutter moves inside the scroll content + contentContainerStyles={shouldUseNarrowLayout ? styles.pb0 : [styles.ph0, styles.pb0]} tabButtonStyles={styles.connectionsTabButton} + shouldShowScrollFade /> {searchBar} {shouldShowNoResults ? ( - - - {noResultsMessage} - - + + {translate('workspace.connections.noResultsPrompt')}{' '} + openConciergeAnywhere({forceConcierge: true})}>{translate('workspace.connections.suggestAnIntegration')} + {translate('workspace.connections.noResultsPromptEnd')} + + } + /> ) : ( )} - openConciergeAnywhere({forceConcierge: true})} - accessibilityLabel={translate('workspace.connections.suggestIntegration')} - role={CONST.ROLE.LINK} - sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.CONNECTIONS.SUGGEST_INTEGRATION} - style={[styles.mt4, styles.flexRow, styles.alignItemsCenter, styles.alignSelfStart, styles.gap1]} - > - {translate('workspace.connections.suggestIntegration')} - - + {!shouldShowNoResults && ( + openConciergeAnywhere({forceConcierge: true})} + accessibilityLabel={translate('workspace.connections.suggestIntegration')} + role={CONST.ROLE.LINK} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.CONNECTIONS.SUGGEST_INTEGRATION} + style={[styles.mt4, styles.flexRow, styles.alignItemsCenter, styles.alignSelfStart, styles.gap1]} + > + {translate('workspace.connections.suggestIntegration')} + + + )} ); diff --git a/src/styles/index.ts b/src/styles/index.ts index b221966aa782..2b263e75ad7d 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4911,6 +4911,11 @@ const staticStyles = (theme: ThemeColors) => scrollMarginInline: variables.tabSelectorScrollMarginInline, }, + // Three 26% bases fit a row with the gaps, while a fourth does not + connectionCardThreeColumns: { + flexBasis: '26%', + }, + connectionsTabButton: { height: 'auto', paddingVertical: 10, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index e8c42618022f..694ffc7f09ec 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -107,6 +107,7 @@ export default { mobileResponsiveWidthBreakpoint: 800, tabletResponsiveWidthBreakpoint: 1024, sidePanelResponsiveWidthBreakpoint: 1300, + connectionsThreeColumnBreakpoint: 1800, iosSafeAreaInsetsPercentage: 0.7, androidSafeAreaInsetsPercentage: 1, sideBarWidth: 375, @@ -241,6 +242,7 @@ export default { signInLogoWidthPill: 132, tabSelectorButtonHeight: 40, tabSelectorButtonPadding: 12, + tabSelectorScrollFadeWidth: 40, tabSelectorScrollMarginInline: 20, tabSelectorMaxTabLabelWidth: 256, filterPillMaxWidth: 256, From f7f2dc3591fea27399d907509b4828c656b91348 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 29 Sep 2026 09:25:27 +0530 Subject: [PATCH 17/51] fix empty state alignment. Signed-off-by: krishna2323 --- src/pages/workspace/connections/WorkspaceConnectionsPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx index 162483f0c9bc..1179e6928c86 100644 --- a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx +++ b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx @@ -258,7 +258,7 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { onBackButtonPress={() => Navigation.goBack()} /> From cae2123fee8be4c220a6702c4648f0e64d54c9ba Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 29 Sep 2026 12:42:04 +0530 Subject: [PATCH 18/51] Narrow the tab scroll fade to match the design Signed-off-by: krishna2323 --- src/styles/variables.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 694ffc7f09ec..00c1d68cee02 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -242,7 +242,7 @@ export default { signInLogoWidthPill: 132, tabSelectorButtonHeight: 40, tabSelectorButtonPadding: 12, - tabSelectorScrollFadeWidth: 40, + tabSelectorScrollFadeWidth: 32, tabSelectorScrollMarginInline: 20, tabSelectorMaxTabLabelWidth: 256, filterPillMaxWidth: 256, From 9ca4a2254007aef63a0b02acb87d4c659e74b46a Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 29 Sep 2026 13:01:21 +0530 Subject: [PATCH 19/51] Fix narrow-layout connects, stale test mock, and old integration links Signed-off-by: krishna2323 --- .../Navigation/linkingConfig/OldRoutes.ts | 2 ++ src/libs/actions/Policy/Policy.ts | 16 +++++------ .../accounting/PolicyAccountingPage.tsx | 2 ++ .../connections/useMCPConnectionListings.ts | 2 +- .../connections/useMergeConnectionListings.ts | 2 +- .../useReceiptPartnerConnectionListings.ts | 2 +- ...seRedirectUnconnectedPanelToConnections.ts | 27 +++++++++++++++++++ .../merge/MergeConnectionsPageBase.tsx | 9 +++++++ .../WorkspaceReceiptPartnersPage.tsx | 2 ++ ...kspaceConnectionsPageAutoStartFlowTest.tsx | 1 - 10 files changed, 53 insertions(+), 12 deletions(-) create mode 100644 src/pages/workspace/connections/useRedirectUnconnectedPanelToConnections.ts diff --git a/src/libs/Navigation/linkingConfig/OldRoutes.ts b/src/libs/Navigation/linkingConfig/OldRoutes.ts index c3911599856f..fa8cbd6501a2 100644 --- a/src/libs/Navigation/linkingConfig/OldRoutes.ts +++ b/src/libs/Navigation/linkingConfig/OldRoutes.ts @@ -27,6 +27,8 @@ const oldRoutes: Record = { '/workspaces/*/downgrade/': '/workspaces/$1/overview/plan/downgrade?policyID=$1', '/workspaces/*/downgrade': '/workspaces/$1/overview/plan/downgrade?policyID=$1', '/workspaces/downgrade': '/settings/subscription/downgrade', + // The MCP page merged into the unified Connections page. + '/workspaces/*/mcp': '/workspaces/$1/connections', '/settings/workspaces/*': '/workspaces/$1', '/settings/workspaces': '/workspaces', '/r/*/settings': '/r/$1/details/report-settings', diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 71143f4bb696..5265c2459f8b 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -4989,7 +4989,7 @@ function enablePolicyConnections(policyID: string, enabled: boolean, shouldGoBac } } -function enablePolicyHR(policyID: string, enabled: boolean) { +function enablePolicyHR(policyID: string, enabled: boolean, shouldGoBack = true) { const onyxData: OnyxData = { optimisticData: [ { @@ -5032,12 +5032,12 @@ function enablePolicyHR(policyID: string, enabled: boolean) { API.writeWithNoDuplicatesEnableFeatureConflicts(WRITE_COMMANDS.ENABLE_POLICY_HR, parameters, onyxData); - if (enabled && getIsNarrowLayout()) { + if (enabled && getIsNarrowLayout() && shouldGoBack) { goBackWhenEnableFeature(); } } -function enablePolicyRecruiting(policyID: string, enabled: boolean) { +function enablePolicyRecruiting(policyID: string, enabled: boolean, shouldGoBack = true) { const onyxData: OnyxData = { optimisticData: [ { @@ -5080,12 +5080,12 @@ function enablePolicyRecruiting(policyID: string, enabled: boolean) { API.writeWithNoDuplicatesEnableFeatureConflicts(WRITE_COMMANDS.ENABLE_POLICY_RECRUITING, parameters, onyxData); - if (enabled && getIsNarrowLayout()) { + if (enabled && getIsNarrowLayout() && shouldGoBack) { goBackWhenEnableFeature(); } } -function enablePolicyMCP(policyID: string, enabled: boolean) { +function enablePolicyMCP(policyID: string, enabled: boolean, shouldGoBack = true) { const onyxData: OnyxData = { optimisticData: [ { @@ -5128,12 +5128,12 @@ function enablePolicyMCP(policyID: string, enabled: boolean) { API.writeWithNoDuplicatesEnableFeatureConflicts(WRITE_COMMANDS.ENABLE_POLICY_MCP, parameters, onyxData); - if (enabled && getIsNarrowLayout()) { + if (enabled && getIsNarrowLayout() && shouldGoBack) { goBackWhenEnableFeature(); } } -function enablePolicyReceiptPartners(policyID: string, enabled: boolean) { +function enablePolicyReceiptPartners(policyID: string, enabled: boolean, shouldGoBack = true) { const onyxData: OnyxData = { optimisticData: [ { @@ -5176,7 +5176,7 @@ function enablePolicyReceiptPartners(policyID: string, enabled: boolean) { API.write(WRITE_COMMANDS.TOGGLE_RECEIPT_PARTNERS, parameters, onyxData); - if (enabled && getIsNarrowLayout()) { + if (enabled && getIsNarrowLayout() && shouldGoBack) { goBackWhenEnableFeature(); } } diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index 5c945d1dc9fe..490a33dab6b8 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -47,6 +47,7 @@ import { import Navigation from '@navigation/Navigation'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import useRedirectUnconnectedPanelToConnections from '@pages/workspace/connections/useRedirectUnconnectedPanelToConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import {openOldDotLink} from '@userActions/Link'; @@ -146,6 +147,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { const hasSyncError = shouldShowSyncError(policy, isSyncInProgress, accountingIntegrations); const hasUnsupportedNDIntegration = !isEmptyObject(policy?.connections) && hasSupportedOnlyOnOldDotIntegration(policy); + useRedirectUnconnectedPanelToConnections(policyID, true, !!connectedIntegration || hasUnsupportedNDIntegration); const tenants = useMemo(() => getXeroTenants(policy), [policy]); const currentXeroOrganization = findCurrentXeroOrganization(tenants, policy?.connections?.xero?.config?.tenantID); diff --git a/src/pages/workspace/connections/useMCPConnectionListings.ts b/src/pages/workspace/connections/useMCPConnectionListings.ts index 538e268e300f..ac5a21dce8f6 100644 --- a/src/pages/workspace/connections/useMCPConnectionListings.ts +++ b/src/pages/workspace/connections/useMCPConnectionListings.ts @@ -46,7 +46,7 @@ function useMCPConnectionListings(policy: OnyxEntry): ConnectionListing[ return; } if (!isMCPEnabled(policy)) { - enablePolicyMCP(policyID, true); + enablePolicyMCP(policyID, true, false); } openExternalLink(url); }, diff --git a/src/pages/workspace/connections/useMergeConnectionListings.ts b/src/pages/workspace/connections/useMergeConnectionListings.ts index bf70a1ca9fe9..5f3dea8ade3a 100644 --- a/src/pages/workspace/connections/useMergeConnectionListings.ts +++ b/src/pages/workspace/connections/useMergeConnectionListings.ts @@ -106,7 +106,7 @@ function useMergeConnectionListings(policy: OnyxEntry, onStartSetup: (se } if (!policy?.[config.featureName]) { - config.enableFeature(policyID, true); + config.enableFeature(policyID, true, false); } onStartSetup(card.setupLink, card.category); }; diff --git a/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts index 6f6ea577fcaf..1ee05b04a5b3 100644 --- a/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts +++ b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts @@ -50,7 +50,7 @@ function useReceiptPartnerConnectionListings(policy: OnyxEntry): Connect return; } if (!policy?.receiptPartners?.enabled) { - enablePolicyReceiptPartners(policyID, true); + enablePolicyReceiptPartners(policyID, true, false); } openExternalLink(`${CONST.UBER_CONNECT_URL}?${policy?.receiptPartners?.uber?.connectFormData}`); }; diff --git a/src/pages/workspace/connections/useRedirectUnconnectedPanelToConnections.ts b/src/pages/workspace/connections/useRedirectUnconnectedPanelToConnections.ts new file mode 100644 index 000000000000..82ecde77510d --- /dev/null +++ b/src/pages/workspace/connections/useRedirectUnconnectedPanelToConnections.ts @@ -0,0 +1,27 @@ +import Navigation from '@libs/Navigation/Navigation'; + +import ROUTES from '@src/ROUTES'; + +import {useEffect, useRef} from 'react'; + +/** + * Sends a visit to a connection's settings panel back to the Connections page when that connection isn't set up, so old + * links to the Accounting, HR, and Receipt partners pages keep working. Only the first loaded state is checked, so + * disconnecting from inside the panel keeps its own navigation. + */ +function useRedirectUnconnectedPanelToConnections(policyID: string | undefined, isDataReady: boolean, isConnected: boolean) { + const hasCheckedRef = useRef(false); + + useEffect(() => { + if (hasCheckedRef.current || !policyID || !isDataReady) { + return; + } + hasCheckedRef.current = true; + if (isConnected) { + return; + } + Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)); + }, [policyID, isDataReady, isConnected]); +} + +export default useRedirectUnconnectedPanelToConnections; diff --git a/src/pages/workspace/merge/MergeConnectionsPageBase.tsx b/src/pages/workspace/merge/MergeConnectionsPageBase.tsx index adb459b1b488..2736f27d1cdb 100644 --- a/src/pages/workspace/merge/MergeConnectionsPageBase.tsx +++ b/src/pages/workspace/merge/MergeConnectionsPageBase.tsx @@ -3,6 +3,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; @@ -12,8 +13,10 @@ import {isMergeConnectionName} from '@libs/merge/MergeUtils'; import Navigation from '@libs/Navigation/Navigation'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import useRedirectUnconnectedPanelToConnections from '@pages/workspace/connections/useRedirectUnconnectedPanelToConnections'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import React, {useEffect, useState} from 'react'; @@ -122,6 +125,12 @@ function MergeConnectionsPageBaseContent({policyID, category, cards}: MergeConne function MergeConnectionsPageBase({policyID, category, cards, shouldBeBlocked}: MergeConnectionsPageBaseProps) { const {featureName, openPage} = PAGE_CONFIG[category]; + const [hasConnectionsDataBeenFetched] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED}${policyID}`); + useRedirectUnconnectedPanelToConnections( + policyID, + !!hasConnectionsDataBeenFetched, + cards.some((card) => card.isConnected), + ); useWorkspaceDocumentTitle(undefined, `workspace.common.${category}`); diff --git a/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx b/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx index ff248e19ea24..d78d2579e52a 100644 --- a/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx +++ b/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx @@ -24,6 +24,7 @@ import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation import type {SettingsNavigatorParamList} from '@navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import useRedirectUnconnectedPanelToConnections from '@pages/workspace/connections/useRedirectUnconnectedPanelToConnections'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; import {openExternalLink} from '@userActions/Link'; @@ -52,6 +53,7 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps useWorkspaceDocumentTitle(policy?.name, 'workspace.common.receiptPartners'); const {getReceiptPartnersIntegrationData, shouldShowEnterCredentialsError, isUberConnected} = useGetReceiptPartnersIntegrationData(policyID); const isLoading = policy?.isLoading; + useRedirectUnconnectedPanelToConnections(policyID, !!policy && !isLoading, isUberConnected || shouldShowEnterCredentialsError); const integrations = policy?.receiptPartners; const isAutoRemove = !!integrations?.uber?.autoRemove; const isAutoInvite = !!integrations?.uber?.autoInvite; diff --git a/tests/unit/WorkspaceConnectionsPageAutoStartFlowTest.tsx b/tests/unit/WorkspaceConnectionsPageAutoStartFlowTest.tsx index 8f4027d751af..7b4562d363a5 100644 --- a/tests/unit/WorkspaceConnectionsPageAutoStartFlowTest.tsx +++ b/tests/unit/WorkspaceConnectionsPageAutoStartFlowTest.tsx @@ -94,7 +94,6 @@ jest.mock('@components/ScrollView', () => ({ })); jest.mock('@components/HeaderWithBackButton', () => ({__esModule: true, default: () => null})); -jest.mock('@components/SearchBar/CompactSearchBar', () => ({__esModule: true, default: () => null})); jest.mock('@components/TabSelector/TabSelectorBase', () => ({__esModule: true, default: () => null})); jest.mock('@components/TextLink', () => ({__esModule: true, default: () => null})); jest.mock('@pages/workspace/connections/ConnectionsGrid', () => ({__esModule: true, default: () => null})); From 65e7b86da9db3c7a22a6da296f6c3e6fca009804 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 29 Sep 2026 13:03:20 +0530 Subject: [PATCH 20/51] Isolate accounting sync state, gate Uber connect on its data, restore the 2FA return path Signed-off-by: krishna2323 --- src/hooks/useDynamicForwardPath.ts | 1 + .../workspace/connections/ConnectionCard.tsx | 21 ++++++++++++------- src/pages/workspace/connections/types.ts | 3 +++ .../useAccountingConnectionListings.ts | 8 ++++--- .../useReceiptPartnerConnectionListings.ts | 9 +++++++- 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/hooks/useDynamicForwardPath.ts b/src/hooks/useDynamicForwardPath.ts index e955d8aac546..50e5a2f70291 100644 --- a/src/hooks/useDynamicForwardPath.ts +++ b/src/hooks/useDynamicForwardPath.ts @@ -37,6 +37,7 @@ const FORWARD_TO_MAPPINGS: Record - + {isLoading ? ( + + ) : ( + + )} ); @@ -97,7 +102,7 @@ function ConnectionCard({listing, columnCount}: ConnectionCardProps) { wrapperStyle={[styles.pv4, styles.ph4]} onPress={status ? onConfigure : onConnect} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.CONNECTIONS.CARD} - disabled={!status && isOffline} + disabled={!status && (isOffline || !!isLoading)} shouldShowRightComponent rightComponent={status ? statusButton : connectIcon} /> diff --git a/src/pages/workspace/connections/types.ts b/src/pages/workspace/connections/types.ts index bc789b997889..d788f976d4f0 100644 --- a/src/pages/workspace/connections/types.ts +++ b/src/pages/workspace/connections/types.ts @@ -34,6 +34,9 @@ type ConnectionListing = { onConnect: () => void; + /** Shows a spinner instead of "+" while the data the connection flow needs is still loading */ + isLoading?: boolean; + /** Opens the connection's settings. Only called for connected listings. */ onConfigure?: () => void; diff --git a/src/pages/workspace/connections/useAccountingConnectionListings.ts b/src/pages/workspace/connections/useAccountingConnectionListings.ts index 36589fa5190f..221ab11f3cf8 100644 --- a/src/pages/workspace/connections/useAccountingConnectionListings.ts +++ b/src/pages/workspace/connections/useAccountingConnectionListings.ts @@ -82,8 +82,10 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL return true; }); - const isSyncInProgress = isConnectionInProgress(connectionSyncProgress, policy); const syncingIntegration = accountingIntegrations.find((integration) => integration === connectionSyncProgress?.connectionName); + // HR providers write to the same sync progress key, so only an accounting sync counts here + const accountingSyncProgress = syncingIntegration ? connectionSyncProgress : undefined; + const isSyncInProgress = !!accountingSyncProgress && isConnectionInProgress(accountingSyncProgress, policy); const connectedIntegration = getConnectedIntegration(policy, accountingIntegrations) ?? syncingIntegration; const isIntuitEnterpriseSuiteSyncInProgress = isSyncInProgress && activeIntegration?.name === CONST.POLICY.CONNECTIONS.NAME.QBO && activeIntegration.isIntuitEnterpriseSuite === true; const isConnectedToIntuitEnterpriseSuite = @@ -114,7 +116,7 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL if (isConnectionUnverified(policy, name)) { return {isBroken: false, message: translate('workspace.accounting.notSync')}; } - const syncStage = isSyncInProgress ? connectionSyncProgress?.stageInProgress : undefined; + const syncStage = isSyncInProgress ? accountingSyncProgress?.stageInProgress : undefined; return { isBroken: false, message: getSyncStatusMessage({ @@ -122,7 +124,7 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL successfulDate: getIntegrationLastSuccessfulDate( getLocalDateFromDatetime, policy?.connections?.[name], - name === connectionSyncProgress?.connectionName ? connectionSyncProgress : undefined, + name === accountingSyncProgress?.connectionName ? accountingSyncProgress : undefined, ), translate, datetimeToCalendarTime, diff --git a/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts index 1ee05b04a5b3..334f698d0fd2 100644 --- a/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts +++ b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts @@ -3,6 +3,7 @@ */ import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import usePrevious from '@hooks/usePrevious'; @@ -28,6 +29,7 @@ function useReceiptPartnerConnectionListings(policy: OnyxEntry): Connect const {getReceiptPartnersIntegrationData, shouldShowEnterCredentialsError, isUberConnected} = useGetReceiptPartnersIntegrationData(policyID); const {canWrite, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); const prevIsUberConnected = usePrevious(isUberConnected); + const {isOffline} = useNetwork(); // The Uber connection finishes outside the app, so the invite flow opens once the connection shows up here useEffect(() => { @@ -49,10 +51,14 @@ function useReceiptPartnerConnectionListings(policy: OnyxEntry): Connect showReadOnlyModal(); return; } + const connectFormData = policy?.receiptPartners?.uber?.connectFormData; + if (!connectFormData) { + return; + } if (!policy?.receiptPartners?.enabled) { enablePolicyReceiptPartners(policyID, true, false); } - openExternalLink(`${CONST.UBER_CONNECT_URL}?${policy?.receiptPartners?.uber?.connectFormData}`); + openExternalLink(`${CONST.UBER_CONNECT_URL}?${connectFormData}`); }; const isConnected = isUberConnected || shouldShowEnterCredentialsError; @@ -70,6 +76,7 @@ function useReceiptPartnerConnectionListings(policy: OnyxEntry): Connect } : undefined, onConnect: connectUber, + isLoading: !policy?.receiptPartners?.uber && !isOffline && !!policy?.isLoadingReceiptPartners, onConfigure: () => Navigation.navigate(ROUTES.WORKSPACE_RECEIPT_PARTNERS.getRoute(policyID)), }, ]; From 0ad3ac707d14c44d49675c87732abd673c6fff9c Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 29 Sep 2026 13:06:34 +0530 Subject: [PATCH 21/51] Match search by old integration names, refresh onboarding copy, trim listing subscriptions, flag QBO export errors Signed-off-by: krishna2323 --- .../SearchRouter/useNavigationSuggestions.tsx | 6 ++++- src/languages/en.ts | 5 ++-- .../useAccountingConnectionListings.ts | 26 +++++++------------ .../connections/useMergeConnectionListings.ts | 11 +++++--- src/pages/workspace/getWorkspaceMenuItems.ts | 4 +++ tests/unit/SearchRouterNavigationTest.ts | 12 +++++++++ 6 files changed, 40 insertions(+), 24 deletions(-) diff --git a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index a7c0a29ebc4c..706ff2ea1954 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -314,7 +314,11 @@ function buildWorkspaceNavigationItems({ action: () => navigateToWorkspaceSettingsRoute(item.getRoute(), policy.id, shouldUseNarrowLayout, item.screenName), keyForList: `workspace_${policy.id}_${item.screenName}`, rightElement: , - matchTerms: item.screenName === SCREENS.WORKSPACE.PROFILE ? [itemText, policy.name] : [itemText], + matchTerms: [ + itemText, + ...(item.screenName === SCREENS.WORKSPACE.PROFILE ? [policy.name] : []), + ...(item.searchAliasKeys ?? []).map((translationKey) => getItemText({...item, translationKey})), + ], sortText: policy.name, }; }); diff --git a/src/languages/en.ts b/src/languages/en.ts index 0693259f1ce8..58137f1a77a8 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3658,11 +3658,11 @@ const translations = { 1. Click *Workspaces*. 2. Select your workspace. - 3. Click *Accounting*. + 3. Click *Connections*. 4. Find ${integrationName}. 5. Click *Connect*. - [Take me to accounting](${workspaceAccountingLink}). + [Take me to connections](${workspaceAccountingLink}). `), }, connectCorporateCardTask: { @@ -4869,6 +4869,7 @@ const translations = { accounting: 'Accounting', hr: 'HR', recruiting: 'Recruiting', + mcp: 'MCP', receiptPartners: 'Receipt partners', connections: 'Connections', rules: 'Rules', diff --git a/src/pages/workspace/connections/useAccountingConnectionListings.ts b/src/pages/workspace/connections/useAccountingConnectionListings.ts index 221ab11f3cf8..644afa49b2c0 100644 --- a/src/pages/workspace/connections/useAccountingConnectionListings.ts +++ b/src/pages/workspace/connections/useAccountingConnectionListings.ts @@ -2,9 +2,6 @@ * Builds the accounting listings for the Connections page and starts the accounting connect flows from them. * Must be rendered inside an AccountingContextProvider. */ -import useCardFeeds from '@hooks/useCardFeeds'; -import useCardsLists from '@hooks/useCardsLists'; -import useHasReusablePoliciesConnectedTo from '@hooks/useHasReusablePoliciesConnectedTo'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -13,6 +10,7 @@ import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useThemeStyles from '@hooks/useThemeStyles'; import {isConnectionInProgress, isConnectionUnverified} from '@libs/actions/connections'; +import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; import Navigation from '@libs/Navigation/Navigation'; import {getConnectedIntegration, getIntegrationLastSuccessfulDate, tryNavigateToSubmitWorkspaceUpgrade} from '@libs/PolicyUtils'; @@ -33,6 +31,9 @@ import type {ConnectionListing, ConnectionStatus} from './types'; import {getSyncStatusMessage} from './utils'; +// Listings only read an integration's title and icon. The setup flow inputs are read by `AccountingContext` when a flow starts. +const NO_REUSABLE_CONNECTIONS = {sageIntacct: false, qbd: false, certinia: false, rillet: false, dualEntry: false, campfire: false}; + function useAccountingConnectionListings(policy: OnyxEntry): ConnectionListing[] { const policyID = policy?.id; const styles = useThemeStyles(); @@ -55,16 +56,6 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL 'CampfireSquare', 'BusinessCentralSquare', ]); - const existingConnections = { - sageIntacct: useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, policyID), - qbd: useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.QBD, policyID), - certinia: useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CERTINIA, policyID), - rillet: useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.RILLET, policyID), - dualEntry: useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, policyID), - campfire: useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE, policyID), - }; - const [cardFeeds] = useCardFeeds(policyID); - const [cardLists] = useCardsLists(); if (!policyID) { return []; @@ -110,7 +101,8 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL }; const getConnectedStatus = (name: ConnectionName, title: string | undefined): ConnectionStatus => { - if (getSynchronizationErrorMessage(policy, name, isSyncInProgress, translate, styles)) { + const hasQBOExportError = name === CONST.POLICY.CONNECTIONS.NAME.QBO && shouldShowQBOReimbursableExportDestinationAccountError(policy); + if (hasQBOExportError || getSynchronizationErrorMessage(policy, name, isSyncInProgress, translate, styles)) { return {isBroken: true, message: translate('workspace.connections.brokenConnection')}; } if (isConnectionUnverified(policy, name)) { @@ -142,15 +134,15 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL name, policyID, translate, - existingConnections, + NO_REUSABLE_CONNECTIONS, undefined, undefined, undefined, undefined, undefined, accountingIcons, - cardFeeds, - cardLists, + undefined, + undefined, isIntuitEnterpriseSuite, ); if (!integrationData) { diff --git a/src/pages/workspace/connections/useMergeConnectionListings.ts b/src/pages/workspace/connections/useMergeConnectionListings.ts index 5f3dea8ade3a..c1a334a1b104 100644 --- a/src/pages/workspace/connections/useMergeConnectionListings.ts +++ b/src/pages/workspace/connections/useMergeConnectionListings.ts @@ -1,12 +1,13 @@ /** * Builds the People listings (HR and, behind the Merge ATS beta, recruiting providers) for the Connections page. */ +import type {PersonalDetailsByLogin} from '@components/PersonalDetailsByLoginProvider'; + import useConfirmModal from '@hooks/useConfirmModal'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; -import {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -48,6 +49,9 @@ const CATEGORY_CONFIG = { }, } as const; +// Personal details only feed the settings rows, which the Connections page doesn't render +const NO_PERSONAL_DETAILS: PersonalDetailsByLogin = {}; + function useMergeConnectionListings(policy: OnyxEntry, onStartSetup: (setupLink: string, category: MergeProviderCardCategory) => void): ConnectionListing[] { const policyID = policy?.id; const {translate, getLocalDateFromDatetime, datetimeToCalendarTime, formatPhoneNumber} = useLocalize(); @@ -55,7 +59,6 @@ function useMergeConnectionListings(policy: OnyxEntry, onStartSetup: (se const {showConfirmModal} = useConfirmModal(); const StyleUtils = useStyleUtils(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const policyEmployeePersonalDetails = usePersonalDetailsByLogins([...Object.keys(policy?.employeeList ?? {})]); const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); const {canWrite, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); const icons = useMemoizedLazyExpensifyIcons(['GustoSquare', 'TriNetSquare', 'Download']); @@ -67,7 +70,7 @@ function useMergeConnectionListings(policy: OnyxEntry, onStartSetup: (se const isRecruitingBetaEnabled = isBetaEnabled(CONST.BETAS.MERGE_ATS); const hrCards = getHRCards({ policy, - policyEmployeePersonalDetails, + policyEmployeePersonalDetails: NO_PERSONAL_DETAILS, connectionSyncProgress, getLocalDateFromDatetime, translate, @@ -76,7 +79,7 @@ function useMergeConnectionListings(policy: OnyxEntry, onStartSetup: (se gustoIcon: icons.GustoSquare, trinetIcon: icons.TriNetSquare, }); - const recruitingCards = isRecruitingBetaEnabled ? getRecruitingCards({policy, policyEmployeePersonalDetails, policyID, icons, translate, formatPhoneNumber}) : []; + const recruitingCards = isRecruitingBetaEnabled ? getRecruitingCards({policy, policyEmployeePersonalDetails: NO_PERSONAL_DETAILS, policyID, icons, translate, formatPhoneNumber}) : []; const connect = (card: MergeProviderCardDescriptor, categoryCards: MergeProviderCardDescriptor[]) => { if (!card.setupLink) { diff --git a/src/pages/workspace/getWorkspaceMenuItems.ts b/src/pages/workspace/getWorkspaceMenuItems.ts index 6ac5d6685269..a2173ce17798 100644 --- a/src/pages/workspace/getWorkspaceMenuItems.ts +++ b/src/pages/workspace/getWorkspaceMenuItems.ts @@ -78,6 +78,9 @@ type WorkspaceMenuItem = WithSentryLabel & { screenName: WorkspaceTopLevelScreens; badgeText?: string; highlighted?: boolean; + + /** Other names the Search router also matches this item by */ + searchAliasKeys?: TranslationPaths[]; }; /** Inputs used to build the Workspace menu while preserving its visibility and indicator rules. */ @@ -212,6 +215,7 @@ function getWorkspaceMenuItems({ if (canReadMoreFeatures) { items.push({ translationKey: 'workspace.common.connections', + searchAliasKeys: ['workspace.common.accounting', 'workspace.common.hr', 'workspace.common.recruiting', 'workspace.common.receiptPartners', 'workspace.common.mcp'], icon: icons.Connect, getRoute: () => ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID), brickRoadIndicator: getConnectionsBrickRoadIndicator(), diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index 1db32bd10355..c3460d096e41 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -718,6 +718,7 @@ describe('Workspace Search Router navigation source', () => { ['workspace.common.rooms', 'Rooms'], ['workspace.common.workflows', 'Workflows'], ['workspace.common.connections', 'Connections'], + ['workspace.common.hr', 'HR'], ]); return labels.get(item.translationKey) ?? item.translationKey; }, @@ -744,6 +745,17 @@ describe('Workspace Search Router navigation source', () => { expect(items.some((item) => item.keyForList === `workspace_1_${SCREENS.WORKSPACE.WORKFLOWS}`)).toBe(false); }); + it('matches the Connections row by the name of an integration category it replaced', () => { + // Given a workspace, since HR no longer has its own row + const items = buildItems([createWorkspacePolicy('1', 'Alpha Workspace')]); + + // When searching for the old HR page by its short name + const suggestions = buildNavigationSuggestions('hr', [items], localeCompare); + + // Then the Connections row is suggested, because HR moved onto the Connections page + expect(suggestions.map((item) => item.keyForList)).toEqual([`workspace_1_${SCREENS.WORKSPACE.CONNECTIONS}`]); + }); + it('alphabetizes equal-priority Workspace rows', () => { const items = buildItems([createWorkspacePolicy('1', 'Beta Workspace'), createWorkspacePolicy('2', 'Alpha Workspace')]); From e480530ea6093a2130aa4c1dc5e45e0a23ee5a78 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 29 Sep 2026 13:09:43 +0530 Subject: [PATCH 22/51] Enable Accounting only after the plan check, add an empty tab state, route suggestions to the workspace's contact, show the tooltip to all roles Signed-off-by: krishna2323 --- .../ProductTrainingContext/TOOLTIPS.ts | 3 +- src/languages/en.ts | 2 ++ .../accounting/AccountingContext/index.tsx | 6 ++++ .../connections/WorkspaceConnectionsPage.tsx | 33 +++++++++++++++---- .../useAccountingConnectionListings.ts | 5 --- 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/src/components/ProductTrainingContext/TOOLTIPS.ts b/src/components/ProductTrainingContext/TOOLTIPS.ts index 9d83bea15a55..09866743b60a 100644 --- a/src/components/ProductTrainingContext/TOOLTIPS.ts +++ b/src/components/ProductTrainingContext/TOOLTIPS.ts @@ -120,7 +120,8 @@ const TOOLTIPS: Record = { onHideTooltip: (isDismissedUsingCloseButton = false) => dismissProductTraining(CONNECTIONS_MOVED, isDismissedUsingCloseButton), name: CONNECTIONS_MOVED, priority: 1650, - shouldShow: ({isUserPolicyAdmin}) => isUserPolicyAdmin, + // The workspace menu only renders it next to Connections, which is already limited to members who can read it + shouldShow: () => true, }, }; diff --git a/src/languages/en.ts b/src/languages/en.ts index 58137f1a77a8..9aacfa16a7c7 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5048,6 +5048,8 @@ const translations = { noResultsPrompt: 'Please adjust your search or', suggestAnIntegration: 'suggest an integration', noResultsPromptEnd: '.', + allConnectedTitle: "You're all connected", + allConnectedPrompt: 'Everything here is already connected. You can also', }, receiptPartners: { uber: { diff --git a/src/pages/workspace/accounting/AccountingContext/index.tsx b/src/pages/workspace/accounting/AccountingContext/index.tsx index 08b7b86e7ce6..8287d6e7fdd0 100644 --- a/src/pages/workspace/accounting/AccountingContext/index.tsx +++ b/src/pages/workspace/accounting/AccountingContext/index.tsx @@ -13,6 +13,8 @@ import {isControlPolicy, tryNavigateToSubmitWorkspaceUpgrade} from '@libs/Policy import {getAccountingIntegrationData} from '@pages/workspace/accounting/utils'; +import {enablePolicyConnections} from '@userActions/Policy/Policy'; + import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type Policy from '@src/types/onyx/Policy'; @@ -151,6 +153,10 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider ); return; } + // Enabled only once the plan allows the integration, so backing out of the upgrade leaves the feature untouched + if (!policy?.areConnectionsEnabled) { + enablePolicyConnections(policyID, true, false); + } setActiveIntegration({ ...newActiveIntegration, key: Math.random(), diff --git a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx index 1179e6928c86..acdb14519f9e 100644 --- a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx +++ b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx @@ -20,6 +20,7 @@ import useDebouncedAccessibilityAnnouncement from '@hooks/useDebouncedAccessibil import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; import useOpenConciergeAnywhere from '@hooks/useOpenConciergeAnywhere'; import usePermissions from '@hooks/usePermissions'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; @@ -47,6 +48,7 @@ import variables from '@styles/variables'; import {openPolicyReceiptPartnersPage} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {ConnectionName} from '@src/types/onyx/Policy'; import {useFocusEffect, useRoute} from '@react-navigation/native'; @@ -104,6 +106,7 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { const [isSearchFocused, setIsSearchFocused] = useState(false); const {isBetaEnabled} = usePermissions(); const {openConciergeAnywhere} = useOpenConciergeAnywhere(); + const [account] = useOnyx(ONYXKEYS.ACCOUNT); const {startIntegrationFlow} = useAccountingActions(); const {canWrite: canWriteAccounting} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); const [activeTab, setActiveTab] = useState(CONST.TAB.CONNECTIONS.POPULAR); @@ -138,8 +141,24 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { ); const visibleListings = searchValue.trim() ? searchResults : getListingsForTab(availableListings, activeTab); const noResultsMessage = translate('common.noResultsFoundMatching', searchValue); - const shouldShowNoResults = !!searchValue.trim() && !searchResults.length; + const isSearching = !!searchValue.trim(); + const shouldShowNoResults = isSearching && !searchResults.length; useDebouncedAccessibilityAnnouncement(noResultsMessage, shouldShowNoResults, searchValue); + // A tab also ends up empty once everything in it is connected + const shouldShowEmptyState = !visibleListings.length; + + // Suggestions go to the person who looks after this workspace, like the old Accounting page did + const openSuggestIntegration = () => { + if (policy?.chatReportIDAdmins) { + openConciergeAnywhere({reportID: String(policy.chatReportIDAdmins)}); + return; + } + if (account?.accountManagerAccountID && account.accountManagerReportID) { + openConciergeAnywhere({reportID: account.accountManagerReportID}); + return; + } + openConciergeAnywhere({forceConcierge: true}); + }; // Upgrade and setup flows that leave the app come back here with the integration to connect in the route params. // `startIntegrationFlow` changes identity whenever `policy` does, and the params are only cleared in a later render, @@ -287,16 +306,16 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { {searchBar} - {shouldShowNoResults ? ( + {shouldShowEmptyState ? ( - {translate('workspace.connections.noResultsPrompt')}{' '} - openConciergeAnywhere({forceConcierge: true})}>{translate('workspace.connections.suggestAnIntegration')} + {isSearching ? translate('workspace.connections.noResultsPrompt') : translate('workspace.connections.allConnectedPrompt')}{' '} + {translate('workspace.connections.suggestAnIntegration')} {translate('workspace.connections.noResultsPromptEnd')} } @@ -306,9 +325,9 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { )} - {!shouldShowNoResults && ( + {!shouldShowEmptyState && ( openConciergeAnywhere({forceConcierge: true})} + onPress={openSuggestIntegration} accessibilityLabel={translate('workspace.connections.suggestIntegration')} role={CONST.ROLE.LINK} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.CONNECTIONS.SUGGEST_INTEGRATION} diff --git a/src/pages/workspace/connections/useAccountingConnectionListings.ts b/src/pages/workspace/connections/useAccountingConnectionListings.ts index 644afa49b2c0..b268e4a7e819 100644 --- a/src/pages/workspace/connections/useAccountingConnectionListings.ts +++ b/src/pages/workspace/connections/useAccountingConnectionListings.ts @@ -17,8 +17,6 @@ import {getConnectedIntegration, getIntegrationLastSuccessfulDate, tryNavigateTo import {useAccountingActions, useAccountingState} from '@pages/workspace/accounting/AccountingContext'; import {getAccountingIntegrationData, getSynchronizationErrorMessage, isIntuitEnterpriseSuiteConnection} from '@pages/workspace/accounting/utils'; -import {enablePolicyConnections} from '@userActions/Policy/Policy'; - import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -90,9 +88,6 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL if (tryNavigateToSubmitWorkspaceUpgrade(policy, true, CONST.UPGRADE_FEATURE_INTRO_MAPPING.accounting.alias, ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))) { return; } - if (!policy?.areConnectionsEnabled) { - enablePolicyConnections(policyID, true, false); - } startIntegrationFlow( connectedIntegration ? {name, isIntuitEnterpriseSuite, integrationToDisconnect: connectedIntegration, shouldDisconnectIntegrationBeforeConnecting: true} From 2d59ada77e8fef28f14deb575a9a8612336185fc Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 29 Sep 2026 13:12:23 +0530 Subject: [PATCH 23/51] Keep the tab's listings until the debounced search applies Signed-off-by: krishna2323 --- src/hooks/useSearchResults.ts | 3 ++- .../workspace/connections/WorkspaceConnectionsPage.tsx | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/hooks/useSearchResults.ts b/src/hooks/useSearchResults.ts index 9e5f064f565d..ea70bd4fe0dd 100644 --- a/src/hooks/useSearchResults.ts +++ b/src/hooks/useSearchResults.ts @@ -16,6 +16,7 @@ const stableSortDataDefault = (data: TValue[]) => data; * @param filterData - Predicate that decides whether a datum matches the current search input. * @param sortData - Optional comparator used to sort the filtered results. Defaults to identity (no sort). * @param preFilter - Optional filter applied before text search (e.g. group/category filter). + * @returns The search input, its setter, the results, and the search text the results were filtered by. That last one lags the input by the debounce window. */ function useSearchResults( data: TValue[], @@ -40,7 +41,7 @@ function useSearchResults( setInputValue(''); }, [data.length, prevData.length, setInputValue]); - return [inputValue, setInputValue, result] as const; + return [inputValue, setInputValue, result, searchQuery] as const; } export default useSearchResults; diff --git a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx index acdb14519f9e..5cdd93361ecd 100644 --- a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx +++ b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx @@ -135,13 +135,14 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { const connectedListings = listings.filter((listing) => !!listing.status); const availableListings = listings.filter((listing) => !listing.status); - const [searchValue, setSearchValue, searchResults] = useSearchResults( + const [searchValue, setSearchValue, searchResults, appliedSearchValue] = useSearchResults( availableListings, (listing, searchInput) => tokenizedSearch([listing], searchInput, (item) => [item.title]).length > 0, ); - const visibleListings = searchValue.trim() ? searchResults : getListingsForTab(availableListings, activeTab); + // The results only switch once the debounced search has run, so the tab's full list doesn't flash while typing + const isSearching = !!searchValue.trim() && !!appliedSearchValue.trim(); + const visibleListings = isSearching ? searchResults : getListingsForTab(availableListings, activeTab); const noResultsMessage = translate('common.noResultsFoundMatching', searchValue); - const isSearching = !!searchValue.trim(); const shouldShowNoResults = isSearching && !searchResults.length; useDebouncedAccessibilityAnnouncement(noResultsMessage, shouldShowNoResults, searchValue); // A tab also ends up empty once everything in it is connected From d3e62228248e742ee42fa019e17bfe39673735ad Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 1 Oct 2026 08:21:53 +0530 Subject: [PATCH 24/51] Drop the tab fade on mobile, clear the tab selection while searching, give the connect icon more room Signed-off-by: krishna2323 --- src/pages/workspace/connections/ConnectionCard.tsx | 2 +- .../workspace/connections/WorkspaceConnectionsPage.tsx | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/pages/workspace/connections/ConnectionCard.tsx b/src/pages/workspace/connections/ConnectionCard.tsx index b347eb76554c..c1b4f830d8da 100644 --- a/src/pages/workspace/connections/ConnectionCard.tsx +++ b/src/pages/workspace/connections/ConnectionCard.tsx @@ -41,7 +41,7 @@ function ConnectionCard({listing, columnCount}: ConnectionCardProps) { const {title, icon, status, onConnect, onConfigure, registerConnectButton, isLoading} = listing; const connectIcon = ( - + {isLoading ? ( ) : ( diff --git a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx index 5cdd93361ecd..f50913d2f383 100644 --- a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx +++ b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx @@ -142,6 +142,8 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { // The results only switch once the debounced search has run, so the tab's full list doesn't flash while typing const isSearching = !!searchValue.trim() && !!appliedSearchValue.trim(); const visibleListings = isSearching ? searchResults : getListingsForTab(availableListings, activeTab); + // Search results span every tab, so no tab is highlighted. Clearing the search returns to the last tab. + const selectedTab = isSearching ? undefined : activeTab; const noResultsMessage = translate('common.noResultsFoundMatching', searchValue); const shouldShowNoResults = isSearching && !searchResults.length; useDebouncedAccessibilityAnnouncement(noResultsMessage, shouldShowNoResults, searchValue); @@ -289,10 +291,10 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { )} 0 ? styles.mt6 : styles.mt3, !shouldUseSingleColumn && [styles.flexRow, styles.alignItemsCenter, styles.gap5]]}> - + { setSearchValue(''); setActiveTab(tab); @@ -301,7 +303,8 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { // On mobile the tabs scroll edge to edge, so the page gutter moves inside the scroll content contentContainerStyles={shouldUseNarrowLayout ? styles.pb0 : [styles.ph0, styles.pb0]} tabButtonStyles={styles.connectionsTabButton} - shouldShowScrollFade + // On mobile a hard edge shows more clearly that the tabs scroll + shouldShowScrollFade={!shouldUseNarrowLayout} /> From 9271cc0bebaeb660a2a37e90b6128676bfb1b541 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 1 Oct 2026 08:24:26 +0530 Subject: [PATCH 25/51] Show sync progress on connected cards and match the RHP's last synced text Signed-off-by: krishna2323 --- src/languages/en.ts | 1 - .../workspace/connections/ConnectionCard.tsx | 17 ++++++++++++----- src/pages/workspace/connections/types.ts | 3 +++ .../useAccountingConnectionListings.ts | 12 +++++++----- .../connections/useMergeConnectionListings.ts | 8 ++++++-- src/pages/workspace/connections/utils.ts | 6 +++--- 6 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 4739690990aa..08c298ea3474 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5058,7 +5058,6 @@ const translations = { active: 'Active', broken: 'Broken', brokenConnection: 'Cannot sync due to broken connection', - synced: (calendarTime: string) => `Synced ${calendarTime}`, hrisListing: (providerName: string) => `${providerName} (HRIS)`, atsListing: (providerName: string) => `${providerName} (ATS)`, suggestIntegration: "Don't see yours? Suggest an integration, we'll look into it.", diff --git a/src/pages/workspace/connections/ConnectionCard.tsx b/src/pages/workspace/connections/ConnectionCard.tsx index c1b4f830d8da..e49fa05b1928 100644 --- a/src/pages/workspace/connections/ConnectionCard.tsx +++ b/src/pages/workspace/connections/ConnectionCard.tsx @@ -55,17 +55,24 @@ function ConnectionCard({listing, columnCount}: ConnectionCardProps) { ); - const statusButton = !!status && ( + let statusComponent = ( ); + if (status?.isSyncing) { + statusComponent = ( + + + + ); + } return ( ); diff --git a/src/pages/workspace/connections/types.ts b/src/pages/workspace/connections/types.ts index d788f976d4f0..fd35499d88eb 100644 --- a/src/pages/workspace/connections/types.ts +++ b/src/pages/workspace/connections/types.ts @@ -15,6 +15,9 @@ type ConnectionStatus = { isBroken: boolean; message: string; + + /** Shows a spinner instead of the Configure button while the connection syncs */ + isSyncing?: boolean; }; /** One integration on the Connections page, either available to connect or already connected. */ diff --git a/src/pages/workspace/connections/useAccountingConnectionListings.ts b/src/pages/workspace/connections/useAccountingConnectionListings.ts index b268e4a7e819..05982b613744 100644 --- a/src/pages/workspace/connections/useAccountingConnectionListings.ts +++ b/src/pages/workspace/connections/useAccountingConnectionListings.ts @@ -35,7 +35,7 @@ const NO_REUSABLE_CONNECTIONS = {sageIntacct: false, qbd: false, certinia: false function useAccountingConnectionListings(policy: OnyxEntry): ConnectionListing[] { const policyID = policy?.id; const styles = useThemeStyles(); - const {translate, datetimeToCalendarTime, getLocalDateFromDatetime} = useLocalize(); + const {translate, datetimeToRelative, getLocalDateFromDatetime} = useLocalize(); const {isBetaEnabled} = usePermissions(); const {startIntegrationFlow} = useAccountingActions(); const {activeIntegration, popoverAnchorRefs} = useAccountingState(); @@ -100,12 +100,14 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL if (hasQBOExportError || getSynchronizationErrorMessage(policy, name, isSyncInProgress, translate, styles)) { return {isBroken: true, message: translate('workspace.connections.brokenConnection')}; } - if (isConnectionUnverified(policy, name)) { - return {isBroken: false, message: translate('workspace.accounting.notSync')}; - } + // A new connection hasn't synced yet while its first sync runs, so the sync is checked first const syncStage = isSyncInProgress ? accountingSyncProgress?.stageInProgress : undefined; + if (!syncStage && isConnectionUnverified(policy, name)) { + return {isBroken: false, isSyncing: isSyncInProgress, message: translate('workspace.accounting.notSync')}; + } return { isBroken: false, + isSyncing: isSyncInProgress, message: getSyncStatusMessage({ syncingMessage: syncStage ? translate('workspace.accounting.connections.syncStageName', syncStage, title) : undefined, successfulDate: getIntegrationLastSuccessfulDate( @@ -114,7 +116,7 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL name === accountingSyncProgress?.connectionName ? accountingSyncProgress : undefined, ), translate, - datetimeToCalendarTime, + datetimeToRelative, }), }; }; diff --git a/src/pages/workspace/connections/useMergeConnectionListings.ts b/src/pages/workspace/connections/useMergeConnectionListings.ts index c1a334a1b104..1e0404cc1c3b 100644 --- a/src/pages/workspace/connections/useMergeConnectionListings.ts +++ b/src/pages/workspace/connections/useMergeConnectionListings.ts @@ -54,7 +54,7 @@ const NO_PERSONAL_DETAILS: PersonalDetailsByLogin = {}; function useMergeConnectionListings(policy: OnyxEntry, onStartSetup: (setupLink: string, category: MergeProviderCardCategory) => void): ConnectionListing[] { const policyID = policy?.id; - const {translate, getLocalDateFromDatetime, datetimeToCalendarTime, formatPhoneNumber} = useLocalize(); + const {translate, getLocalDateFromDatetime, datetimeToRelative, formatPhoneNumber} = useLocalize(); const {isBetaEnabled} = usePermissions(); const {showConfirmModal} = useConfirmModal(); const StyleUtils = useStyleUtils(); @@ -125,7 +125,11 @@ function useMergeConnectionListings(policy: OnyxEntry, onStartSetup: (se if (card.isSyncInProgress) { syncingMessage = card.syncStageInProgress ? translate('workspace.hr.syncStageName', card.syncStageInProgress) : translate(`workspace.${card.category}.syncing`); } - return {isBroken: false, message: getSyncStatusMessage({syncingMessage, successfulDate: card.successfulDate, translate, datetimeToCalendarTime})}; + return { + isBroken: false, + isSyncing: card.isSyncInProgress, + message: getSyncStatusMessage({syncingMessage, successfulDate: card.successfulDate, translate, datetimeToRelative}), + }; }; const getTitle = (card: MergeProviderCardDescriptor) => { diff --git a/src/pages/workspace/connections/utils.ts b/src/pages/workspace/connections/utils.ts index 1845366e9241..08879bc4709c 100644 --- a/src/pages/workspace/connections/utils.ts +++ b/src/pages/workspace/connections/utils.ts @@ -40,17 +40,17 @@ type GetSyncStatusMessageParams = { translate: LocaleContextProps['translate']; - datetimeToCalendarTime: LocaleContextProps['datetimeToCalendarTime']; + datetimeToRelative: LocaleContextProps['datetimeToRelative']; }; -function getSyncStatusMessage({syncingMessage, successfulDate, translate, datetimeToCalendarTime}: GetSyncStatusMessageParams): string { +function getSyncStatusMessage({syncingMessage, successfulDate, translate, datetimeToRelative}: GetSyncStatusMessageParams): string { if (syncingMessage) { return syncingMessage; } if (!successfulDate) { return translate('workspace.accounting.notSync'); } - return translate('workspace.connections.synced', datetimeToCalendarTime(successfulDate, false, true)); + return translate('workspace.accounting.lastSync', datetimeToRelative(successfulDate)); } function getListingsForTab(listings: ConnectionListing[], tab: ConnectionsTab): ConnectionListing[] { From 4786806dbd79cb6385057cba6b0cd2e01fa522c9 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 1 Oct 2026 08:27:49 +0530 Subject: [PATCH 26/51] Return to Connections after upgrading and share one replace prompt across accounting and HR Signed-off-by: krishna2323 --- src/languages/en.ts | 9 +--- .../accounting/AccountingContext/index.tsx | 13 +++--- .../connections/useMergeConnectionListings.ts | 45 +++++++++++-------- .../upgrade/WorkspaceUpgradePage.tsx | 7 +-- .../AccountingConnectionConfirmationTest.tsx | 31 +++++++------ 5 files changed, 54 insertions(+), 51 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 08c298ea3474..cfcefdfb4351 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5058,6 +5058,8 @@ const translations = { active: 'Active', broken: 'Broken', brokenConnection: 'Cannot sync due to broken connection', + replaceConnectionTitle: 'Replace connection?', + replaceConnectionPrompt: (connectionName: string) => `This will remove your current ${connectionName} connection.`, hrisListing: (providerName: string) => `${providerName} (HRIS)`, atsListing: (providerName: string) => `${providerName} (ATS)`, suggestIntegration: "Don't see yours? Suggest an integration, we'll look into it.", @@ -7572,8 +7574,6 @@ const translations = { disconnect: 'Disconnect', reinstall: 'Reinstall connector', disconnectTitle: (connectionName = 'integration') => `Disconnect ${connectionName}`, - alreadyConnectedTitle: 'You already got an active accounting integration', - replaceIntegration: 'Replace integration', syncError: (connectionName: string) => `Can't connect to ${connectionName}`, accounts: 'Chart of accounts', @@ -7591,7 +7591,6 @@ const translations = { [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'NetSuite employee default', }, disconnectPrompt: (connectionName = 'this integration') => `Are you sure you want to disconnect ${connectionName}?`, - connectPrompt: (connectionName: string) => `Are you sure you want to connect ${connectionName}? This will remove any existing accounting connections.`, reconnect: 'Reconnect', enterCredentials: 'Enter your credentials', updateCredentials: 'Update credentials', @@ -7843,8 +7842,6 @@ const translations = { }, hr: { title: 'HR', - alreadyConnectedTitle: 'Cannot connect to multiple HR platforms', - alreadyConnectedPrompt: 'You must disconnect your current HR platform before connecting another.', connectionDescription: (providerName: string) => `Connect ${providerName} to keep employee approvals in sync with your workspace.`, providerApprovalMode: (providerName: string) => `${providerName} approval mode`, providerFinalApprover: (providerName: string) => `${providerName} final approver`, @@ -7899,8 +7896,6 @@ const translations = { }, recruiting: { title: 'Recruiting', - alreadyConnectedTitle: 'Cannot connect to multiple ATS platforms', - alreadyConnectedPrompt: 'You must disconnect your current ATS before connecting another.', syncing: 'Syncing candidates', syncResults: { importedCount: () => ({ diff --git a/src/pages/workspace/accounting/AccountingContext/index.tsx b/src/pages/workspace/accounting/AccountingContext/index.tsx index 8287d6e7fdd0..76d7ea412a13 100644 --- a/src/pages/workspace/accounting/AccountingContext/index.tsx +++ b/src/pages/workspace/accounting/AccountingContext/index.tsx @@ -7,6 +7,7 @@ import useHasReusablePoliciesConnectedTo from '@hooks/useHasReusablePoliciesConn import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import {getAccountingIntegrationDisplayName} from '@libs/AccountingUtils'; import {removePolicyConnection} from '@libs/actions/connections'; import Navigation from '@libs/Navigation/Navigation'; import {isControlPolicy, tryNavigateToSubmitWorkspaceUpgrade} from '@libs/PolicyUtils'; @@ -119,7 +120,7 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider return; } - if (tryNavigateToSubmitWorkspaceUpgrade(policy, true, CONST.UPGRADE_FEATURE_INTRO_MAPPING.accounting.alias)) { + if (tryNavigateToSubmitWorkspaceUpgrade(policy, true, CONST.UPGRADE_FEATURE_INTRO_MAPPING.accounting.alias, ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))) { return; } @@ -169,10 +170,6 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider // Mirrors `shouldShowConfirmationModal` below, which keeps `renderActiveIntegration()` from mounting the // setup flow until the user has decided what to do with the connection that has to be disconnected first. - const connectionName = newActiveIntegration.isIntuitEnterpriseSuite - ? translate('workspace.accounting.intuitEnterpriseSuite') - : (CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[newActiveIntegration.name] ?? newActiveIntegration.name); - isDisconnectConfirmationPendingRef.current = true; showConfirmModal({ @@ -180,9 +177,9 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider // `WorkspaceConnectionsPage` re-fires whenever `startIntegrationFlow` is re-created). A stable id keeps the // repeat call updating this prompt in place instead of stacking a second copy behind it. id: ACCOUNTING_CONNECTION_CONFIRMATION_MODAL_ID, - title: translate('workspace.accounting.alreadyConnectedTitle'), - prompt: translate('workspace.accounting.connectPrompt', connectionName), - confirmText: translate('workspace.accounting.replaceIntegration'), + title: translate('workspace.connections.replaceConnectionTitle'), + prompt: translate('workspace.connections.replaceConnectionPrompt', getAccountingIntegrationDisplayName(policy, integrationToDisconnect, translate)), + confirmText: translate('common.replace'), cancelText: translate('common.cancel'), buttonVariant: CONST.BUTTON_VARIANT.DANGER, }).then((result) => { diff --git a/src/pages/workspace/connections/useMergeConnectionListings.ts b/src/pages/workspace/connections/useMergeConnectionListings.ts index 1e0404cc1c3b..216fee68cbfe 100644 --- a/src/pages/workspace/connections/useMergeConnectionListings.ts +++ b/src/pages/workspace/connections/useMergeConnectionListings.ts @@ -1,6 +1,7 @@ /** * Builds the People listings (HR and, behind the Merge ATS beta, recruiting providers) for the Connections page. */ +import {ModalActions} from '@components/Modal/Global/ModalContext'; import type {PersonalDetailsByLogin} from '@components/PersonalDetailsByLoginProvider'; import useConfirmModal from '@hooks/useConfirmModal'; @@ -9,9 +10,8 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useStyleUtils from '@hooks/useStyleUtils'; +import {removePolicyConnection} from '@libs/actions/connections'; import Navigation from '@libs/Navigation/Navigation'; import {isControlPolicy} from '@libs/PolicyUtils'; @@ -19,8 +19,6 @@ import {getHRCards} from '@pages/workspace/hr/utils'; import type {MergeProviderCardCategory, MergeProviderCardDescriptor} from '@pages/workspace/merge/types'; import {getRecruitingCards} from '@pages/workspace/recruiting/utils'; -import variables from '@styles/variables'; - import {enablePolicyHR, enablePolicyRecruiting} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; @@ -57,8 +55,6 @@ function useMergeConnectionListings(policy: OnyxEntry, onStartSetup: (se const {translate, getLocalDateFromDatetime, datetimeToRelative, formatPhoneNumber} = useLocalize(); const {isBetaEnabled} = usePermissions(); const {showConfirmModal} = useConfirmModal(); - const StyleUtils = useStyleUtils(); - const {shouldUseNarrowLayout} = useResponsiveLayout(); const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); const {canWrite, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); const icons = useMemoizedLazyExpensifyIcons(['GustoSquare', 'TriNetSquare', 'Download']); @@ -96,22 +92,33 @@ function useMergeConnectionListings(policy: OnyxEntry, onStartSetup: (se return; } + const {setupLink} = card; + const startSetup = () => { + if (!policy?.[config.featureName]) { + config.enableFeature(policyID, true, false); + } + onStartSetup(setupLink, card.category); + }; + // At most one provider of a category can be connected to a workspace at a time - if (categoryCards.some((categoryCard) => categoryCard.isConnected)) { - showConfirmModal({ - title: translate(`workspace.${card.category}.alreadyConnectedTitle`), - prompt: translate(`workspace.${card.category}.alreadyConnectedPrompt`), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - innerContainerStyle: shouldUseNarrowLayout ? undefined : StyleUtils.getWidthStyle(variables.wideConfirmModalWidth), - }); + const connectedCard = categoryCards.find((categoryCard) => categoryCard.isConnected); + if (!connectedCard) { + startSetup(); return; } - - if (!policy?.[config.featureName]) { - config.enableFeature(policyID, true, false); - } - onStartSetup(card.setupLink, card.category); + showConfirmModal({ + title: translate('workspace.connections.replaceConnectionTitle'), + prompt: translate('workspace.connections.replaceConnectionPrompt', connectedCard.displayName), + confirmText: translate('common.replace'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }).then(({action}) => { + if (action !== ModalActions.CONFIRM) { + return; + } + removePolicyConnection(policy, connectedCard.connectionName); + startSetup(); + }); }; const getConnectedStatus = (card: MergeProviderCardDescriptor): ConnectionStatus => { diff --git a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx index 3d34fb4c3994..e0c1f532f356 100644 --- a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx +++ b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx @@ -188,8 +188,9 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { case CONST.UPGRADE_FEATURE_INTRO_MAPPING.invoicing.id: case CONST.UPGRADE_FEATURE_INTRO_MAPPING.companyCardSubmit.id: case CONST.UPGRADE_FEATURE_INTRO_MAPPING.travelSubmit.id: - case CONST.UPGRADE_FEATURE_INTRO_MAPPING.hr.id: return Navigation.goBack(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)); + case CONST.UPGRADE_FEATURE_INTRO_MAPPING.hr.id: + return Navigation.goBack(route.params.backTo ?? ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)); default: return route.params.backTo ? Navigation.goBack(route.params.backTo) : Navigation.goBack(); } @@ -297,10 +298,10 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { enablePerDiem(policyID, true, perDiemCustomUnit?.customUnitID, false); break; case CONST.UPGRADE_FEATURE_INTRO_MAPPING.hr.id: - enablePolicyHR(policyID, true); + enablePolicyHR(policyID, true, false); break; case CONST.UPGRADE_FEATURE_INTRO_MAPPING.recruiting.id: - enablePolicyRecruiting(policyID, true); + enablePolicyRecruiting(policyID, true, false); break; case CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id: case CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvalSubmit.id: diff --git a/tests/unit/AccountingConnectionConfirmationTest.tsx b/tests/unit/AccountingConnectionConfirmationTest.tsx index df918e09cde3..b06d783c7f31 100644 --- a/tests/unit/AccountingConnectionConfirmationTest.tsx +++ b/tests/unit/AccountingConnectionConfirmationTest.tsx @@ -13,6 +13,7 @@ import Onyx from 'react-native-onyx'; import type * as MockUseConfirmModalUtil from '../utils/mockUseConfirmModal'; import createRandomPolicy from '../utils/collections/policies'; +import createMock from '../utils/createMock'; import {getShowConfirmModalOption, MockModalActions, mockShowConfirmModal, resetMockConfirmModal, resolveShowConfirmModal} from '../utils/mockUseConfirmModal'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; @@ -96,10 +97,10 @@ const TestHarness = React.forwardRef((_props, ref) => { return null; }); -function renderProvider() { +function renderProvider(providerPolicy: Policy = policy) { const ref = React.createRef(); render( - + , ); @@ -153,9 +154,9 @@ describe('AccountingContextProvider connect-confirmation prompt', () => { // Then the user is asked first, and the setup flow stays unmounted so it cannot start behind the prompt expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); - expect(getShowConfirmModalOption('title')).toBe('workspace.accounting.alreadyConnectedTitle'); - expect(getShowConfirmModalOption('prompt')).toBe(`workspace.accounting.connectPrompt:${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.quickbooksOnline}`); - expect(getShowConfirmModalOption('confirmText')).toBe('workspace.accounting.replaceIntegration'); + expect(getShowConfirmModalOption('title')).toBe('workspace.connections.replaceConnectionTitle'); + expect(getShowConfirmModalOption('prompt')).toBe(`workspace.connections.replaceConnectionPrompt:${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.xero}`); + expect(getShowConfirmModalOption('confirmText')).toBe('common.replace'); expect(getShowConfirmModalOption('cancelText')).toBe('common.cancel'); expect(getShowConfirmModalOption('buttonVariant')).toBe(CONST.BUTTON_VARIANT.DANGER); expect(screen.queryByTestId(SETUP_FLOW_TEST_ID)).not.toBeOnTheScreen(); @@ -188,23 +189,25 @@ describe('AccountingContextProvider connect-confirmation prompt', () => { expect(screen.getByTestId(SETUP_FLOW_TEST_ID)).toBeOnTheScreen(); }); - it('should use the Intuit Enterprise Suite display name when the integration is one', async () => { - // Given a workspace with an integration connected - const ref = renderProvider(); + it('should name the connected Intuit Enterprise Suite when that is the connection being replaced', async () => { + // Given a workspace whose QuickBooks Online connection is the Intuit Enterprise Suite variant + const ref = renderProvider({ + ...policy, + connections: createMock({quickbooksOnline: {config: {credentials: {scope: CONST.POLICY.CONNECTIONS.INTUIT_ENTERPRISE_SUITE_SCOPE}}}}), + }); - // When the flow being started is the Intuit Enterprise Suite variant, which shares QBO's connection name + // When a different integration is started, which means replacing that connection await act(async () => { ref.current?.startIntegrationFlow({ - name: CONST.POLICY.CONNECTIONS.NAME.QBO, - isIntuitEnterpriseSuite: true, + name: CONST.POLICY.CONNECTIONS.NAME.XERO, shouldDisconnectIntegrationBeforeConnecting: true, - integrationToDisconnect: CONST.POLICY.CONNECTIONS.NAME.XERO, + integrationToDisconnect: CONST.POLICY.CONNECTIONS.NAME.QBO, }); await waitForBatchedUpdates(); }); - // Then the prompt names the suite rather than QuickBooks Online, so the user recognises what they bought - expect(getShowConfirmModalOption('prompt')).toBe('workspace.accounting.connectPrompt:workspace.accounting.intuitEnterpriseSuite'); + // Then the prompt names the suite rather than QuickBooks Online, so the user recognises what they would lose + expect(getShowConfirmModalOption('prompt')).toBe('workspace.connections.replaceConnectionPrompt:workspace.accounting.intuitEnterpriseSuite'); }); it('should disconnect the old connection and release the setup flow on confirm', async () => { From 6af3289ba6596603bc24a518725f1881ce399185 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 1 Oct 2026 08:31:30 +0530 Subject: [PATCH 27/51] Give partner logos a white backdrop, show the Connections tooltip only to existing users Signed-off-by: krishna2323 --- src/CONST/index.ts | 3 +++ src/components/MenuItem/MenuItem.tsx | 8 ++++++-- src/components/ProductTrainingContext/TOOLTIPS.ts | 7 +++++-- src/components/ProductTrainingContext/index.tsx | 3 +++ src/pages/workspace/connections/ConnectionCard.tsx | 2 ++ 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 47988cf3cf3c..99a4e060b911 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9027,6 +9027,9 @@ const CONST = { PRODUCT_TRAINING_TOOLTIP_REAPPEAR_WINDOW: { SEVEN_DAYS: 7 * 24 * 60 * 60 * 1000, }, + + // Users whose free trial started on or after this date never saw the separate Accounting and HR pages + CONNECTIONS_MOVED_NEW_USER_CUTOFF_DATE: '2026-10-01', INBOX_TAB_STALE_UNREAD_MONTHS: 3, CHANGE_POLICY_TRAINING_MODAL: 'changePolicyModal', AGENTS_RULES_BANNER: 'agentsRulesBanner', diff --git a/src/components/MenuItem/MenuItem.tsx b/src/components/MenuItem/MenuItem.tsx index 5110c8dbb86d..962e3fe447c1 100644 --- a/src/components/MenuItem/MenuItem.tsx +++ b/src/components/MenuItem/MenuItem.tsx @@ -58,7 +58,7 @@ import type WithSentryLabel from '@src/types/utils/SentryLabel'; import type {ImageContentFit} from 'expo-image'; import type {ComponentRef, ReactElement, ReactNode, Ref} from 'react'; -import type {GestureResponderEvent, Role, StyleProp, TextStyle, ViewStyle} from 'react-native'; +import type {GestureResponderEvent, ImageStyle, Role, StyleProp, TextStyle, ViewStyle} from 'react-native'; import type {AnimatedStyle} from 'react-native-reanimated'; import type {ValueOf} from 'type-fest'; @@ -241,6 +241,9 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Prop to represent the size of the avatar images to be shown */ avatarSize?: ValueOf; + /** Additional styles for the avatar image when the icon type is an avatar */ + avatarImageStyles?: StyleProp; + /** Affects avatar size */ viewMode?: ValueOf; @@ -506,6 +509,7 @@ function MenuItem({ leftComponent, rightIconReportID, avatarSize = CONST.AVATAR_SIZE.DEFAULT, + avatarImageStyles, isSmallAvatarSubscriptMenu = false, brickRoadIndicator, shouldRenderAsHTML = false, @@ -949,7 +953,7 @@ function MenuItem({ )} {iconType === CONST.ICON_TYPE_AVATAR && ( = { onHideTooltip: (isDismissedUsingCloseButton = false) => dismissProductTraining(CONNECTIONS_MOVED, isDismissedUsingCloseButton), name: CONNECTIONS_MOVED, priority: 1650, - // The workspace menu only renders it next to Connections, which is already limited to members who can read it - shouldShow: () => true, + // Only users who knew the old pages need to hear they moved. The workspace menu limits it to members who can read Connections. + shouldShow: ({firstDayFreeTrial}) => !firstDayFreeTrial || new Date(firstDayFreeTrial).getTime() < new Date(CONST.CONNECTIONS_MOVED_NEW_USER_CUTOFF_DATE).getTime(), }, }; diff --git a/src/components/ProductTrainingContext/index.tsx b/src/components/ProductTrainingContext/index.tsx index 11e2782ca77a..7985d57ddad1 100644 --- a/src/components/ProductTrainingContext/index.tsx +++ b/src/components/ProductTrainingContext/index.tsx @@ -76,6 +76,7 @@ function ProductTrainingContextProvider({children}: ChildrenProps) { }, [allPolicies, currentUserLogin, allPoliciesMetadata, currentUserLoginMetadata]); const [dismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); + const [firstDayFreeTrial] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -172,6 +173,7 @@ function ProductTrainingContextProvider({children}: ChildrenProps) { isUserPolicyAdmin, hasBeenAddedToNudgeMigration, isUserInPaidPolicy, + firstDayFreeTrial, }); }, [ @@ -186,6 +188,7 @@ function ProductTrainingContextProvider({children}: ChildrenProps) { isUserPolicyEmployee, isUserPolicyAdmin, isUserInPaidPolicy, + firstDayFreeTrial, ], ); diff --git a/src/pages/workspace/connections/ConnectionCard.tsx b/src/pages/workspace/connections/ConnectionCard.tsx index e49fa05b1928..691a8b791854 100644 --- a/src/pages/workspace/connections/ConnectionCard.tsx +++ b/src/pages/workspace/connections/ConnectionCard.tsx @@ -92,6 +92,8 @@ function ConnectionCard({listing, columnCount}: ConnectionCardProps) { titleStyle={styles.textStrong} icon={icon} iconType={CONST.ICON_TYPE_AVATAR} + // Many partner logos are transparent, so they need a white backdrop in both themes + avatarImageStyles={styles.backgroundWhite} fallbackIcon={icons.Building} descriptionAddon={ status ? ( From f48e7e7bbe1edb7e132c3ff035c936de5d52b72b Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 1 Oct 2026 08:48:09 +0530 Subject: [PATCH 28/51] Bring back the Xero and Uber partner offer badges on the Connections cards Signed-off-by: krishna2323 --- src/pages/workspace/accounting/ClaimOfferPage.tsx | 12 +++++++----- src/pages/workspace/connections/ConnectionCard.tsx | 7 ++++++- src/pages/workspace/connections/types.ts | 3 +++ .../connections/useAccountingConnectionListings.ts | 5 +++++ .../useReceiptPartnerConnectionListings.ts | 1 + 5 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/pages/workspace/accounting/ClaimOfferPage.tsx b/src/pages/workspace/accounting/ClaimOfferPage.tsx index d6c959e56334..802ef74e6978 100644 --- a/src/pages/workspace/accounting/ClaimOfferPage.tsx +++ b/src/pages/workspace/accounting/ClaimOfferPage.tsx @@ -25,10 +25,11 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; +import {enablePolicyReceiptPartners} from '@userActions/Policy/Policy'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; -import type {PolicyFeatureName} from '@src/types/onyx/Policy'; import React, {useEffect, useMemo} from 'react'; import {View} from 'react-native'; @@ -44,7 +45,6 @@ type IntegrationConfig = { claimOfferLink?: string; connectButtonText: string; connectionName: string; - featureName: PolicyFeatureName; onConnect: () => void; }; @@ -82,7 +82,6 @@ function ClaimOfferPage({route, policy}: ClaimOfferPageProps) { claimOfferLink: CONST.XERO_PARTNER_LINK, connectButtonText: translate('workspace.accounting.claimOffer.xero.connectButton'), connectionName: CONST.POLICY.CONNECTIONS.NAME.XERO, - featureName: CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED, onConnect: () => { startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.XERO}); }, @@ -93,8 +92,11 @@ function ClaimOfferPage({route, policy}: ClaimOfferPageProps) { descriptionHtml: translate('workspace.accounting.claimOffer.uber.description'), connectButtonText: translate('workspace.accounting.claimOffer.uber.connectButton'), connectionName: CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER, - featureName: CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED, onConnect: () => { + // Offers open from Connections, where Receipt partners only turns on once a partner is connected + if (!policy?.receiptPartners?.enabled) { + enablePolicyReceiptPartners(policyID, true, false); + } openExternalLink(`${CONST.UBER_CONNECT_URL}?${integrations?.uber?.connectFormData}`); }, }, @@ -144,7 +146,7 @@ function ClaimOfferPage({route, policy}: ClaimOfferPageProps) { @@ -107,6 +107,11 @@ function ConnectionCard({listing, columnCount}: ConnectionCardProps) { ) : undefined } description={status?.message} + badgeText={offer ? translate('workspace.accounting.claimOffer.badgeText') : undefined} + isBadgeSuccess + isBadgeCondensed + shouldShowBadgeBelow + onBadgePress={offer?.onPress} numberOfLinesDescription={1} wrapperStyle={[styles.pv4, styles.ph4]} onPress={status ? onConfigure : onConnect} diff --git a/src/pages/workspace/connections/types.ts b/src/pages/workspace/connections/types.ts index fd35499d88eb..a1cc3f0057ee 100644 --- a/src/pages/workspace/connections/types.ts +++ b/src/pages/workspace/connections/types.ts @@ -37,6 +37,9 @@ type ConnectionListing = { onConnect: () => void; + /** Shows an "Offer available!" badge for a partner promotion. The badge only opens the offer when `onPress` is set. */ + offer?: {onPress?: () => void}; + /** Shows a spinner instead of "+" while the data the connection flow needs is still loading */ isLoading?: boolean; diff --git a/src/pages/workspace/connections/useAccountingConnectionListings.ts b/src/pages/workspace/connections/useAccountingConnectionListings.ts index 05982b613744..a46e6ab98364 100644 --- a/src/pages/workspace/connections/useAccountingConnectionListings.ts +++ b/src/pages/workspace/connections/useAccountingConnectionListings.ts @@ -155,6 +155,11 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL title: integrationData.title, icon: integrationData.icon, status: isConnected ? getConnectedStatus(name, integrationData.title) : undefined, + // The Xero partner offer is for workspaces choosing their first accounting integration + offer: + name === CONST.POLICY.CONNECTIONS.NAME.XERO && !connectedIntegration + ? {onPress: canWrite ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CLAIM_OFFER.getRoute(policyID, CONST.POLICY.CONNECTIONS.NAME.XERO)) : undefined} + : undefined, onConnect: () => connect(name, isIntuitEnterpriseSuite), onConfigure: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING.getRoute(policyID)), registerConnectButton: (button) => { diff --git a/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts index 334f698d0fd2..cbd8ae83957b 100644 --- a/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts +++ b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts @@ -75,6 +75,7 @@ function useReceiptPartnerConnectionListings(policy: OnyxEntry): Connect message: shouldShowEnterCredentialsError ? translate('workspace.connections.brokenConnection') : uberData.description, } : undefined, + offer: {onPress: canWrite ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CLAIM_OFFER.getRoute(policyID, CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER)) : undefined}, onConnect: connectUber, isLoading: !policy?.receiptPartners?.uber && !isOffline && !!policy?.isLoadingReceiptPartners, onConfigure: () => Navigation.navigate(ROUTES.WORKSPACE_RECEIPT_PARTNERS.getRoute(policyID)), From 49e66299a3e39a8220fcd3d5613bdb8ab12e2b3e Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 1 Oct 2026 08:55:23 +0530 Subject: [PATCH 29/51] Bring back the Xero and Uber partner offer badges on the Connections cards Signed-off-by: krishna2323 --- src/components/MenuItem/MenuItem.tsx | 2 +- src/pages/workspace/connections/ConnectionCard.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/MenuItem/MenuItem.tsx b/src/components/MenuItem/MenuItem.tsx index 962e3fe447c1..4574c4a080ce 100644 --- a/src/components/MenuItem/MenuItem.tsx +++ b/src/components/MenuItem/MenuItem.tsx @@ -1057,11 +1057,11 @@ function MenuItem({ text={badgeText} icon={badgeIcon} badgeStyles={[ - badgeStyle, styles.alignSelfStart, styles.ml3, styles.mt2, focused && !isBadgeSuccess && styles.badgeDefaultActive, + badgeStyle, ]} success={isBadgeSuccess} isStrong={isBadgeStrong} diff --git a/src/pages/workspace/connections/ConnectionCard.tsx b/src/pages/workspace/connections/ConnectionCard.tsx index 523c87f20134..ee4c95cf3263 100644 --- a/src/pages/workspace/connections/ConnectionCard.tsx +++ b/src/pages/workspace/connections/ConnectionCard.tsx @@ -109,7 +109,7 @@ function ConnectionCard({listing, columnCount}: ConnectionCardProps) { description={status?.message} badgeText={offer ? translate('workspace.accounting.claimOffer.badgeText') : undefined} isBadgeSuccess - isBadgeCondensed + badgeStyle={[styles.minHeight5, styles.mt1]} shouldShowBadgeBelow onBadgePress={offer?.onPress} numberOfLinesDescription={1} From ee5ac6ce267921c7aa5358ea241fdafa45b27de3 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 1 Oct 2026 09:06:35 +0530 Subject: [PATCH 30/51] Use the agreed connection tabs and move the Connections headers to the composed Header Signed-off-by: krishna2323 --- src/CONST/index.ts | 4 +- src/languages/en.ts | 6 +-- .../connections/WorkspaceConnectionsPage.tsx | 29 ++++++++------ src/pages/workspace/connections/types.ts | 4 +- .../connections/useMergeConnectionListings.ts | 2 +- .../useReceiptPartnerConnectionListings.ts | 2 +- src/pages/workspace/connections/utils.ts | 21 ---------- .../workspace/merge/MergeProviderCard.tsx | 40 +++++++++++-------- ...kspaceConnectionsPageAutoStartFlowTest.tsx | 2 +- 9 files changed, 51 insertions(+), 59 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 99a4e060b911..cf402d75e6c6 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6743,11 +6743,11 @@ const CONST = { AGENTS: 'agents', }, CONNECTIONS: { - POPULAR: 'popular', ALL: 'all', ACCOUNTING: 'accounting', PEOPLE: 'people', - TRAVEL: 'travel', + RECRUITING: 'recruiting', + RECEIPTS: 'receipts', AI: 'ai', }, WORKFLOWS_TAB_TYPE: 'workflowsTabType', diff --git a/src/languages/en.ts b/src/languages/en.ts index cfcefdfb4351..ea2b67ba069b 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5045,11 +5045,11 @@ const translations = { }, connections: { tabs: { - [CONST.TAB.CONNECTIONS.POPULAR]: 'Popular', [CONST.TAB.CONNECTIONS.ALL]: 'All', [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Accounting', - [CONST.TAB.CONNECTIONS.PEOPLE]: 'People', - [CONST.TAB.CONNECTIONS.TRAVEL]: 'Travel & Delivery', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'HR & People', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Recruiting', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Receipts', [CONST.TAB.CONNECTIONS.AI]: 'AI & MCP', }, findConnections: 'Find connections', diff --git a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx index f50913d2f383..f390df2b002e 100644 --- a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx +++ b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx @@ -4,11 +4,12 @@ */ import ConnectToMergeFlow from '@components/ConnectToMergeFlow'; import GenericEmptyStateComponent from '@components/EmptyStateComponent/GenericEmptyStateComponent'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Header from '@components/Header'; import Icon from '@components/Icon'; import {PressableWithFeedback} from '@components/Pressable'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import SidePanelButton from '@components/SidePanel/SidePanelButton'; import TabSelectorBase from '@components/TabSelector/TabSelectorBase'; import TabSelectorContextProvider from '@components/TabSelector/TabSelectorContext'; import type {TabSelectorBaseItem} from '@components/TabSelector/types'; @@ -77,8 +78,6 @@ type MergeSetupFlow = { key: number; }; -const TABS: ConnectionsTab[] = Object.values(CONST.TAB.CONNECTIONS); - /** HR and recruiting providers are Control-only, so their data is only requested for Control workspaces */ function fetchConnectionsData(policyID: string | undefined, canUseMergeConnections: boolean, isRecruitingBetaEnabled: boolean) { if (!policyID) { @@ -109,7 +108,7 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { const [account] = useOnyx(ONYXKEYS.ACCOUNT); const {startIntegrationFlow} = useAccountingActions(); const {canWrite: canWriteAccounting} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); - const [activeTab, setActiveTab] = useState(CONST.TAB.CONNECTIONS.POPULAR); + const [activeTab, setActiveTab] = useState(CONST.TAB.CONNECTIONS.ALL); const [mergeSetupFlow, setMergeSetupFlow] = useState(); const route = useRoute(); const params = route.params as RouteParams | undefined; @@ -201,7 +200,10 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { const connectedHRConnectionName = CONST.POLICY.CONNECTIONS.HR_CONNECTION_NAMES.find((name) => !!policy?.connections?.[name]); const connectedRecruitingConnectionName = isRecruitingBetaEnabled ? CONST.POLICY.CONNECTIONS.RECRUITING_CONNECTION_NAMES.find((name) => !!policy?.connections?.[name]) : undefined; - const tabs: Array> = TABS.map((tab) => ({ + // Recruiting providers are behind a beta, so the tab only shows once there is something to list in it + const hasRecruitingListings = listings.some((listing) => listing.category === CONST.TAB.CONNECTIONS.RECRUITING); + const visibleTabs = Object.values(CONST.TAB.CONNECTIONS).filter((tab) => tab !== CONST.TAB.CONNECTIONS.RECRUITING || hasRecruitingListings); + const tabs: Array> = visibleTabs.map((tab) => ({ key: tab, title: translate(`workspace.connections.tabs.${tab}`), })); @@ -272,13 +274,16 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { onDone={() => setMergeSetupFlow(undefined)} /> )} - Navigation.goBack()} - /> +
+ {shouldUseNarrowLayout && Navigation.goBack()} />} + + + + +
; -/** The tabs a listing can belong to. Popular and All are cross-cutting views built from the listings themselves. */ -type ConnectionCategory = Exclude; +/** The tabs a listing can belong to. All shows every listing. */ +type ConnectionCategory = Exclude; type ConnectionStatus = { /** A broken connection shows a Fix button instead of Configure */ diff --git a/src/pages/workspace/connections/useMergeConnectionListings.ts b/src/pages/workspace/connections/useMergeConnectionListings.ts index 216fee68cbfe..c506c9cf7a85 100644 --- a/src/pages/workspace/connections/useMergeConnectionListings.ts +++ b/src/pages/workspace/connections/useMergeConnectionListings.ts @@ -150,7 +150,7 @@ function useMergeConnectionListings(policy: OnyxEntry, onStartSetup: (se const toListings = (categoryCards: MergeProviderCardDescriptor[]): ConnectionListing[] => categoryCards.map((card) => ({ key: card.key, - category: CONST.TAB.CONNECTIONS.PEOPLE, + category: card.category === CONST.POLICY.CONNECTIONS.CATEGORY.RECRUITING ? CONST.TAB.CONNECTIONS.RECRUITING : CONST.TAB.CONNECTIONS.PEOPLE, title: getTitle(card), icon: card.icon, status: card.isConnected ? getConnectedStatus(card) : undefined, diff --git a/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts index cbd8ae83957b..fd7335452da5 100644 --- a/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts +++ b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts @@ -66,7 +66,7 @@ function useReceiptPartnerConnectionListings(policy: OnyxEntry): Connect return [ { key: CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER, - category: CONST.TAB.CONNECTIONS.TRAVEL, + category: CONST.TAB.CONNECTIONS.RECEIPTS, title: uberData.title, icon: uberData.icon, status: isConnected diff --git a/src/pages/workspace/connections/utils.ts b/src/pages/workspace/connections/utils.ts index 08879bc4709c..80f7287470c5 100644 --- a/src/pages/workspace/connections/utils.ts +++ b/src/pages/workspace/connections/utils.ts @@ -4,7 +4,6 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; -import type {MergeHRProviderSlug} from '@src/CONST/MERGE_HR_PROVIDERS'; import type {ConnectionListing, ConnectionsTab} from './types'; @@ -14,23 +13,6 @@ const MCP_CONNECTOR = { CURSOR: 'cursor', } as const; -/** Matches the key `getHRCards` gives each Merge HR provider */ -function getMergeHRListingKey(slug: MergeHRProviderSlug) { - return `merge_${slug}`; -} - -/** Listing keys shown on the Popular tab, in display order */ -const POPULAR_LISTING_KEYS: string[] = [ - CONST.POLICY.CONNECTIONS.NAME.QBO, - CONST.POLICY.CONNECTIONS.NAME.XERO, - CONST.POLICY.CONNECTIONS.NAME.NETSUITE, - CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER, - MCP_CONNECTOR.CLAUDE, - MCP_CONNECTOR.CHATGPT, - CONST.POLICY.CONNECTIONS.NAME.GUSTO, - getMergeHRListingKey('bamboohr'), -]; - type GetSyncStatusMessageParams = { /** Shown instead of the last sync time while a sync is running */ syncingMessage?: string; @@ -57,9 +39,6 @@ function getListingsForTab(listings: ConnectionListing[], tab: ConnectionsTab): if (tab === CONST.TAB.CONNECTIONS.ALL) { return listings; } - if (tab === CONST.TAB.CONNECTIONS.POPULAR) { - return POPULAR_LISTING_KEYS.map((key) => listings.find((listing) => listing.key === key)).filter((listing): listing is ConnectionListing => !!listing); - } return listings.filter((listing) => listing.category === tab); } diff --git a/src/pages/workspace/merge/MergeProviderCard.tsx b/src/pages/workspace/merge/MergeProviderCard.tsx index be213d4278d6..2e159b50d135 100644 --- a/src/pages/workspace/merge/MergeProviderCard.tsx +++ b/src/pages/workspace/merge/MergeProviderCard.tsx @@ -1,6 +1,7 @@ import ActivityIndicator from '@components/ActivityIndicator'; +import UserAvatar from '@components/Avatar/UserAvatar'; import FormHelpMessage from '@components/FormHelpMessage'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Header from '@components/Header'; import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; @@ -15,6 +16,7 @@ import useEnvironment from '@hooks/useEnvironment'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {removePolicyConnection, syncConnection} from '@libs/actions/connections'; @@ -58,6 +60,7 @@ type MergeProviderCardProps = { function MergeProviderCard({card, policy, handleConnect, onDisconnect, onBackButtonPress, canWriteMoreFeatures, showReadOnlyModal}: MergeProviderCardProps) { const {translate, datetimeToRelative} = useLocalize(); const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); const {environmentURL} = useEnvironment(); const {isOffline} = useNetwork(); const icons = useMemoizedLazyExpensifyIcons(['Sync', 'Trashcan', 'Building', 'CheckCircle']); @@ -163,21 +166,26 @@ function MergeProviderCard({card, policy, handleConnect, onDisconnect, onBackBut return ( <> - - {card.isSyncInProgress && } - +
+ + + + + {card.isSyncInProgress && } + {card.isConnected && !card.isSyncInProgress && } + +
({ default: ({children}: {children: React.ReactNode}) => children, })); -jest.mock('@components/HeaderWithBackButton', () => ({__esModule: true, default: () => null})); +jest.mock('@components/SidePanel/SidePanelButton', () => ({__esModule: true, default: () => null})); jest.mock('@components/TabSelector/TabSelectorBase', () => ({__esModule: true, default: () => null})); jest.mock('@components/TextLink', () => ({__esModule: true, default: () => null})); jest.mock('@pages/workspace/connections/ConnectionsGrid', () => ({__esModule: true, default: () => null})); From edb4ce43743c9e01eba42aed8ec864f2845f9b55 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 1 Oct 2026 09:17:55 +0530 Subject: [PATCH 31/51] Point the QBO expiry indicator tests at Connections and hide the mocked menu when the header does Signed-off-by: krishna2323 --- tests/unit/PolicyAccountingPageQBOTokenExpiryTest.tsx | 4 ++-- tests/unit/getWorkspaceMenuItemsTest.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/unit/PolicyAccountingPageQBOTokenExpiryTest.tsx b/tests/unit/PolicyAccountingPageQBOTokenExpiryTest.tsx index a7dd2a16afdb..6058402dc117 100644 --- a/tests/unit/PolicyAccountingPageQBOTokenExpiryTest.tsx +++ b/tests/unit/PolicyAccountingPageQBOTokenExpiryTest.tsx @@ -90,8 +90,8 @@ jest.mock('@components/HeaderWithBackButton', () => { const {default: MockText} = jest.requireActual<{default: typeof TextComponent}>('@components/Text'); return { __esModule: true, - default: ({threeDotsMenuItems}: {threeDotsMenuItems?: PopoverMenuItem[]}) => - threeDotsMenuItems?.map((menuItem) => {`${OVERFLOW_MENU_ITEM_PREFIX}${menuItem.text}`}), + default: ({threeDotsMenuItems, shouldShowThreeDotsButton}: {threeDotsMenuItems?: PopoverMenuItem[]; shouldShowThreeDotsButton?: boolean}) => + shouldShowThreeDotsButton ? threeDotsMenuItems?.map((menuItem) => {`${OVERFLOW_MENU_ITEM_PREFIX}${menuItem.text}`}) : null, }; }); diff --git a/tests/unit/getWorkspaceMenuItemsTest.ts b/tests/unit/getWorkspaceMenuItemsTest.ts index 658b96bd8425..709fea9ba059 100644 --- a/tests/unit/getWorkspaceMenuItemsTest.ts +++ b/tests/unit/getWorkspaceMenuItemsTest.ts @@ -314,7 +314,7 @@ describe('getWorkspaceMenuItems', () => { expect(items.find((item) => item.translationKey === 'workspace.common.connections')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.INFO); }); - it('shows an information indicator on Accounting when the QBO refresh token is about to expire', () => { + it('shows an information indicator on Connections when the QBO refresh token is about to expire', () => { const policy = createMock({ ...buildPolicy(CONST.POLICY.ROLE.ADMIN), areConnectionsEnabled: true, @@ -334,10 +334,10 @@ describe('getWorkspaceMenuItems', () => { convertToDisplayString: () => '', }); - expect(items.find((item) => item.translationKey === 'workspace.common.accounting')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.INFO); + expect(items.find((item) => item.translationKey === 'workspace.common.connections')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.INFO); }); - it('keeps the error indicator on Accounting when a sync error exists even if the QBO refresh token is about to expire', () => { + it('keeps the error indicator on Connections when a sync error exists even if the QBO refresh token is about to expire', () => { const policy = createMock({ ...buildPolicy(CONST.POLICY.ROLE.ADMIN), areConnectionsEnabled: true, @@ -357,7 +357,7 @@ describe('getWorkspaceMenuItems', () => { convertToDisplayString: () => '', }); - expect(items.find((item) => item.translationKey === 'workspace.common.accounting')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); + expect(items.find((item) => item.translationKey === 'workspace.common.connections')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); }); it('shows an error indicator when the Merge HR connection has an authentication error', () => { From 74c2aca805b0457374845ba891acf3d7fc738b07 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 1 Oct 2026 16:05:47 +0530 Subject: [PATCH 32/51] Show a condensed Offer badge next to the name, hide offers once connected, tighten the card gap, keep the selected tab Signed-off-by: krishna2323 --- src/CONST/index.ts | 1 + src/components/MenuItem/MenuItem.tsx | 7 ++++++- src/languages/en.ts | 2 +- .../workspace/connections/ConnectionCard.tsx | 19 +++++++++++------ .../workspace/connections/ConnectionsGrid.tsx | 2 +- .../connections/WorkspaceConnectionsPage.tsx | 21 ++++++++++++++----- .../useReceiptPartnerConnectionListings.ts | 5 ++++- src/styles/index.ts | 9 ++++++++ src/styles/theme/themes/dark.ts | 2 ++ src/styles/theme/themes/light.ts | 2 ++ src/styles/theme/types.ts | 2 ++ 11 files changed, 57 insertions(+), 15 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index cf402d75e6c6..d4b237174924 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6750,6 +6750,7 @@ const CONST = { RECEIPTS: 'receipts', AI: 'ai', }, + CONNECTIONS_TAB_TYPE: 'connectionsTabType', WORKFLOWS_TAB_TYPE: 'workflowsTabType', WORKFLOWS: { SUBMISSIONS: 'submissions', diff --git a/src/components/MenuItem/MenuItem.tsx b/src/components/MenuItem/MenuItem.tsx index 4574c4a080ce..fa45b0a98124 100644 --- a/src/components/MenuItem/MenuItem.tsx +++ b/src/components/MenuItem/MenuItem.tsx @@ -178,6 +178,9 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Optional component to render before the description text (e.g. a badge pill) */ descriptionAddon?: ReactNode; + /** Content shown right after the title, on the same line */ + titleAddon?: ReactNode; + /** Text to show below menu item. This text is not interactive */ helperText?: string; @@ -479,6 +482,7 @@ function MenuItem({ furtherDetailsComponent, description, descriptionAddon, + titleAddon, helperText, helperTextStyle, errorText, @@ -1030,6 +1034,7 @@ function MenuItem({ />
)} + {titleAddon}
)} {!shouldShowDescriptionOnTop && renderDescriptionView()} @@ -1057,11 +1062,11 @@ function MenuItem({ text={badgeText} icon={badgeIcon} badgeStyles={[ + badgeStyle, styles.alignSelfStart, styles.ml3, styles.mt2, focused && !isBadgeSuccess && styles.badgeDefaultActive, - badgeStyle, ]} success={isBadgeSuccess} isStrong={isBadgeStrong} diff --git a/src/languages/en.ts b/src/languages/en.ts index ea2b67ba069b..7d47866764f4 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5059,6 +5059,7 @@ const translations = { broken: 'Broken', brokenConnection: 'Cannot sync due to broken connection', replaceConnectionTitle: 'Replace connection?', + offer: 'Offer', replaceConnectionPrompt: (connectionName: string) => `This will remove your current ${connectionName} connection.`, hrisListing: (providerName: string) => `${providerName} (HRIS)`, atsListing: (providerName: string) => `${providerName} (ATS)`, @@ -7597,7 +7598,6 @@ const translations = { qboConnectionExpiring: ({date}: {date: string}) => `Your QuickBooks Online connection expires on ${date}.`, qboConnectionExpired: ({date}: {date: string}) => `Your QuickBooks Online connection expired on ${date}.`, claimOffer: { - badgeText: 'Offer available!', xero: { headline: 'Get Xero free for 6 months!', description: 'New to Xero? Expensify customers get 6 months free. Claim your offer below.', diff --git a/src/pages/workspace/connections/ConnectionCard.tsx b/src/pages/workspace/connections/ConnectionCard.tsx index ee4c95cf3263..4184f070cc85 100644 --- a/src/pages/workspace/connections/ConnectionCard.tsx +++ b/src/pages/workspace/connections/ConnectionCard.tsx @@ -89,7 +89,7 @@ function ConnectionCard({listing, columnCount}: ConnectionCardProps) { + ) : undefined + } numberOfLinesDescription={1} wrapperStyle={[styles.pv4, styles.ph4]} onPress={status ? onConfigure : onConnect} diff --git a/src/pages/workspace/connections/ConnectionsGrid.tsx b/src/pages/workspace/connections/ConnectionsGrid.tsx index d99d6e11a2ae..1a441d3cf056 100644 --- a/src/pages/workspace/connections/ConnectionsGrid.tsx +++ b/src/pages/workspace/connections/ConnectionsGrid.tsx @@ -35,7 +35,7 @@ function ConnectionsGrid({listings}: ConnectionsGridProps) { const fillerCount = (columnCount - (listings.length % columnCount)) % columnCount; return ( - + {listings.map((listing) => ( (Object.values(CONST.TAB.CONNECTIONS).map((tab) => [tab, tab])); + type MergeSetupFlow = { setupLink: string; category: MergeProviderCardCategory; @@ -108,7 +115,7 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { const [account] = useOnyx(ONYXKEYS.ACCOUNT); const {startIntegrationFlow} = useAccountingActions(); const {canWrite: canWriteAccounting} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); - const [activeTab, setActiveTab] = useState(CONST.TAB.CONNECTIONS.ALL); + const [lastSelectedTab] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.CONNECTIONS_TAB_TYPE}`); const [mergeSetupFlow, setMergeSetupFlow] = useState(); const route = useRoute(); const params = route.params as RouteParams | undefined; @@ -134,6 +141,13 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { const connectedListings = listings.filter((listing) => !!listing.status); const availableListings = listings.filter((listing) => !listing.status); + // Recruiting providers are behind a beta, so the tab only shows once there is something to list in it + const hasRecruitingListings = listings.some((listing) => listing.category === CONST.TAB.CONNECTIONS.RECRUITING); + const visibleTabs = Object.values(CONST.TAB.CONNECTIONS).filter((tab) => tab !== CONST.TAB.CONNECTIONS.RECRUITING || hasRecruitingListings); + // The tab is kept in Onyx so the page lands on it again after a remount, e.g. when coming back from a connect flow in another browser tab + const persistedTab = CONNECTIONS_TABS_BY_KEY.get(lastSelectedTab ?? ''); + const activeTab = persistedTab && visibleTabs.includes(persistedTab) ? persistedTab : CONST.TAB.CONNECTIONS.ALL; + const [searchValue, setSearchValue, searchResults, appliedSearchValue] = useSearchResults( availableListings, (listing, searchInput) => tokenizedSearch([listing], searchInput, (item) => [item.title]).length > 0, @@ -200,9 +214,6 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { const connectedHRConnectionName = CONST.POLICY.CONNECTIONS.HR_CONNECTION_NAMES.find((name) => !!policy?.connections?.[name]); const connectedRecruitingConnectionName = isRecruitingBetaEnabled ? CONST.POLICY.CONNECTIONS.RECRUITING_CONNECTION_NAMES.find((name) => !!policy?.connections?.[name]) : undefined; - // Recruiting providers are behind a beta, so the tab only shows once there is something to list in it - const hasRecruitingListings = listings.some((listing) => listing.category === CONST.TAB.CONNECTIONS.RECRUITING); - const visibleTabs = Object.values(CONST.TAB.CONNECTIONS).filter((tab) => tab !== CONST.TAB.CONNECTIONS.RECRUITING || hasRecruitingListings); const tabs: Array> = visibleTabs.map((tab) => ({ key: tab, title: translate(`workspace.connections.tabs.${tab}`), @@ -302,7 +313,7 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { activeTabKey={selectedTab} onTabPress={(tab) => { setSearchValue(''); - setActiveTab(tab); + Tab.setSelectedTab(CONST.TAB.CONNECTIONS_TAB_TYPE, tab); }} onActiveTabPress={() => setSearchValue('')} // On mobile the tabs scroll edge to edge, so the page gutter moves inside the scroll content diff --git a/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts index fd7335452da5..c86f4c141a0a 100644 --- a/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts +++ b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts @@ -75,7 +75,10 @@ function useReceiptPartnerConnectionListings(policy: OnyxEntry): Connect message: shouldShowEnterCredentialsError ? translate('workspace.connections.brokenConnection') : uberData.description, } : undefined, - offer: {onPress: canWrite ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CLAIM_OFFER.getRoute(policyID, CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER)) : undefined}, + // The Uber partner offer only applies to new connections + offer: isConnected + ? undefined + : {onPress: canWrite ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CLAIM_OFFER.getRoute(policyID, CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER)) : undefined}, onConnect: connectUber, isLoading: !policy?.receiptPartners?.uber && !isOffline && !!policy?.isLoadingReceiptPartners, onConfigure: () => Navigation.navigate(ROUTES.WORKSPACE_RECEIPT_PARTNERS.getRoute(policyID)), diff --git a/src/styles/index.ts b/src/styles/index.ts index ddac5b18f140..f6b88410560e 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -1132,6 +1132,15 @@ const staticStyles = (theme: ThemeColors) => borderColor: theme.badgeDangerBG, }, + badgeOffer: { + backgroundColor: theme.badgeOfferBG, + borderColor: theme.badgeOfferBG, + }, + + badgeOfferText: { + color: theme.badgeOfferText, + }, + badgeDangerStrong: { backgroundColor: theme.danger, borderColor: theme.danger, diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index 6fb517723de7..c108dd30b0fa 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -97,6 +97,8 @@ const darkTheme = { badgeDangerText: colors.tangerine200, badgeAdHoc: colors.pink600, badgeAdHocHover: colors.pink700, + badgeOfferBG: colors.pink700, + badgeOfferText: colors.pink200, bordersBold: colors.productDark400, buttonIcon: colors.productDark700, mentionText: colors.blue100, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index b83c5a565946..3a71dfe79c3d 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -97,6 +97,8 @@ const lightTheme = { badgeDangerText: colors.tangerine700, badgeAdHoc: colors.pink600, badgeAdHocHover: colors.pink700, + badgeOfferBG: colors.pink200, + badgeOfferText: colors.pink600, bordersBold: colors.productLight400, buttonIcon: colors.productLight700, mentionText: colors.blue600, diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts index da9f8e06987c..415292a2bbe2 100644 --- a/src/styles/theme/types.ts +++ b/src/styles/theme/types.ts @@ -105,6 +105,8 @@ type ThemeColors = { badgeDangerText: Color; badgeAdHoc: Color; badgeAdHocHover: Color; + badgeOfferBG: Color; + badgeOfferText: Color; bordersBold: Color; buttonIcon: Color; mentionText: Color; From 7e6b2ef8708ec756ef2f5aabd273e1095c754959 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 2 Oct 2026 16:32:01 +0530 Subject: [PATCH 33/51] fix Oxfmt check failure. Signed-off-by: krishna2323 --- src/pages/workspace/WorkspaceInitialPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index a7311b343cab..35da241ab5e3 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -30,8 +30,8 @@ import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; import {isConnectionInProgress} from '@libs/actions/connections'; import {clearErrors, openPolicyInitialPage, removeWorkspace} from '@libs/actions/Policy/Policy'; -import {dismissProductTraining} from '@libs/actions/Welcome'; import {getRules} from '@libs/actions/Policy/Rules'; +import {dismissProductTraining} from '@libs/actions/Welcome'; import goBackFromWorkspaceSettingPages from '@libs/Navigation/helpers/goBackFromWorkspaceSettingPages'; import WorkspaceCreationReveal from '@libs/Navigation/helpers/WorkspaceCreationReveal'; import Navigation from '@libs/Navigation/Navigation'; From 60fa4afb5e5c51b431bf2f6a360fdf41dd95177e Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 2 Oct 2026 16:36:32 +0530 Subject: [PATCH 34/51] Wrap the connection cards by a minimum card width instead of a window breakpoint Signed-off-by: krishna2323 --- .../workspace/connections/ConnectionCard.tsx | 11 ++-- .../workspace/connections/ConnectionsGrid.tsx | 51 +++++++------------ src/styles/index.ts | 13 ++++- src/styles/variables.ts | 2 +- 4 files changed, 33 insertions(+), 44 deletions(-) diff --git a/src/pages/workspace/connections/ConnectionCard.tsx b/src/pages/workspace/connections/ConnectionCard.tsx index 4184f070cc85..af12aeef5495 100644 --- a/src/pages/workspace/connections/ConnectionCard.tsx +++ b/src/pages/workspace/connections/ConnectionCard.tsx @@ -27,11 +27,11 @@ import type {ConnectionListing} from './types'; type ConnectionCardProps = { listing: ConnectionListing; - /** How many cards share a row */ - columnCount: number; + /** Stacks the card at full width instead of wrapping it by its minimum width */ + shouldUseFullWidth: boolean; }; -function ConnectionCard({listing, columnCount}: ConnectionCardProps) { +function ConnectionCard({listing, shouldUseFullWidth}: ConnectionCardProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const theme = useTheme(); @@ -79,11 +79,8 @@ function ConnectionCard({listing, columnCount}: ConnectionCardProps) { style={[ styles.workspaceSectionMoreFeaturesItem, styles.p0, - styles.mt0, styles.overflowHidden, - StyleUtils.getMinimumWidth(0), - columnCount === 1 && styles.flexBasis100, - columnCount === 3 && styles.connectionCardThreeColumns, + shouldUseFullWidth ? [styles.flexBasis100, StyleUtils.getMinimumWidth(0)] : styles.connectionCard, ]} > variables.connectionsThreeColumnBreakpoint) { - columnCount = 3; - } - - // Invisible fillers complete the last row so its cards keep their column width - const fillerCount = (columnCount - (listings.length % columnCount)) % columnCount; + // Medium screens can leave less than a card's minimum width once both navigation panes are shown + const shouldStackCards = shouldUseNarrowLayout || isMediumScreenWidth; return ( - + {listings.map((listing) => ( - ))} - {Array.from({length: fillerCount}, (_, index) => ( - ))} + {!shouldStackCards && + Array.from({length: FILLER_COUNT}, (_, index) => ( + + ))} ); } diff --git a/src/styles/index.ts b/src/styles/index.ts index 3f15ca5c763a..5852d11b3ecd 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4988,9 +4988,18 @@ const staticStyles = (theme: ThemeColors) => scrollMarginInline: variables.tabSelectorScrollMarginInline, }, - // Three 26% bases fit a row with the gaps, while a fourth does not - connectionCardThreeColumns: { + // Rows are spaced by each card's top margin so the zero-height fillers add no space. The negative margin cancels the first row's. + connectionsGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + columnGap: 12, + marginTop: -12, + }, + + // Cards wrap by their minimum width. Three 26% bases fit a row with the gaps, while a fourth does not. + connectionCard: { flexBasis: '26%', + minWidth: variables.connectionCardMinWidth, }, connectionsTabButton: { diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 4da9e6039063..fe1c9df36157 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -107,7 +107,7 @@ export default { mobileResponsiveWidthBreakpoint: 800, tabletResponsiveWidthBreakpoint: 1024, sidePanelResponsiveWidthBreakpoint: 1300, - connectionsThreeColumnBreakpoint: 1800, + connectionCardMinWidth: 300, iosSafeAreaInsetsPercentage: 0.7, androidSafeAreaInsetsPercentage: 1, sideBarWidth: 375, From c4029fc5c13fc6244c14542cbf8431d42fb44723 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 2 Oct 2026 17:04:33 +0530 Subject: [PATCH 35/51] update translations. Signed-off-by: krishna2323 --- src/languages/de.ts | 77 ++++++++++++++++++---------------------- src/languages/el.ts | 71 ++++++++++++++++-------------------- src/languages/es.ts | 71 +++++++++++++++++------------------- src/languages/fr.ts | 71 +++++++++++++++++------------------- src/languages/it.ts | 73 ++++++++++++++++++------------------- src/languages/ja.ts | 71 +++++++++++++++++------------------- src/languages/nl.ts | 67 ++++++++++++++++------------------ src/languages/pl.ts | 67 ++++++++++++++++------------------ src/languages/pt-BR.ts | 65 ++++++++++++++++----------------- src/languages/zh-hans.ts | 67 +++++++++++++++++----------------- 10 files changed, 322 insertions(+), 378 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 199086ab8a88..505fd4e890b9 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3566,16 +3566,15 @@ ${amount} für ${merchant} – ${date}`, `Verbinden${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '' : 'zu'} [${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'dein' : ''} ${integrationName}](${workspaceAccountingLink})`, description: ({integrationName, workspaceAccountingLink}) => Str.dedent(` - Verbinde ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'dein' : 'bis'} ${integrationName} für automatische Spesenkodierung und Synchronisierung, die den Monatsabschluss zum Kinderspiel macht. + Verbinden Sie ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'Ihr' : 'bis'} ${integrationName} für automatische Spesenkodierung und Synchronisierung, die den Monatsabschluss zum Kinderspiel macht. - 1. Klicke auf *Workspaces*. - 2. Wähle deinen Workspace aus. - 3. Klicke auf *Accounting*. - 4. Suche nach ${integrationName}. - 5. Klicke auf *Connect*. + 1. Klicken Sie auf *Workspaces*. + 2. Wählen Sie Ihren Workspace aus. + 3. Klicken Sie auf *Connections*. + 4. Suchen Sie ${integrationName}. + 5. Klicken Sie auf *Connect*. - [Zur Buchhaltung](${workspaceAccountingLink}). - `), + [Zu den Verbindungen](${workspaceAccountingLink}).`), }, connectCorporateCardTask: { title: ({corporateCardLink}) => `[Geschäftskarten verbinden](${corporateCardLink})`, @@ -4894,6 +4893,7 @@ ${amount} für ${merchant} – ${date}`, readOnlyActionTitle: 'Nicht so schnell …', readOnlyActionPrompt: 'Ihre Arbeitsbereichsrolle kann diese Einstellungen anzeigen, aber nicht bearbeiten.', noAccessActionPrompt: 'Ihre Arbeitsbereichsrolle hat keinen Zugriff auf diese Einstellungen. Bitten Sie eine/n Admin, falls Sie Zugriff benötigen.', + connections: 'Verknüpfungen', }, createdForClient: { title: 'Du hast einen Workspace für deinen Kunden erstellt!', @@ -6494,10 +6494,6 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU title: 'Organisieren', subtitle: 'Ausgaben gruppieren und analysieren, jede gezahlte Steuer erfassen.', }, - integrateSection: { - title: 'Integrieren', - subtitle: 'Verbinde Expensify mit beliebten Finanzprodukten.', - }, distanceRates: { title: 'Kilometersätze', subtitle: 'Sätze Tarife fest, aktualisiere sie und setze sie durch.', @@ -6720,27 +6716,18 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU }, connections: { title: 'Buchhaltung', - subtitle: 'Synchronisieren Sie Ihren Kontenplan und mehr.', - }, - mcp: { - title: 'MCP', - subtitle: 'Verbinde einen KI-Assistenten mit deinem Expensify-Konto.', }, receiptPartners: { title: 'Belegpartner', - subtitle: 'Belege automatisch importieren.', }, connectionsWarningModal: { featureEnabledTitle: 'Nicht so schnell ...', featureEnabledText: 'Um diese Funktion zu aktivieren oder zu deaktivieren, musst du deine Einstellungen für den Buchhaltungsimport ändern.', - disconnectText: 'Um die Buchhaltung zu deaktivieren, musst du die Buchhaltungsanbindung von deinem Workspace trennen.', manageSettings: 'Einstellungen verwalten', }, receiptPartnersWarningModal: { featureEnabledTitle: 'Uber-Verknüpfung trennen', - disconnectText: 'Um diese Funktion zu deaktivieren, trenne bitte zuerst die Uber for Business-Integration.', description: 'Möchtest du diese Integration wirklich trennen?', - confirmText: 'Verstanden', }, workflowWarningModal: { featureEnabledTitle: 'Nicht so schnell ...', @@ -6757,16 +6744,12 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU subtitle: 'Legen Sie einen abrechnungsfähigen Stundensatz für die Zeiterfassung fest.', defaultHourlyRate: 'Standardstundensatz', }, - hrWarningModal: {disconnectText: ({integration}: {integration: string}) => `Um HR zu deaktivieren, trennen Sie bitte zuerst ${integration} von diesem Workspace.`}, vendors: { title: 'Lieferanten', subtitle: 'Ordnen Sie Kartenausgaben den aus Ihrer Buchhaltungssoftware importierten Lieferanten zu.', disabledTitle: 'Nicht so schnell...', disabledMessage: 'Um diese Funktion zu aktivieren oder zu deaktivieren, müssen Sie Ihre Buchhaltungsimporteinstellungen ändern.', }, - recruitingWarningModal: { - disconnectText: ({integration}: {integration: string}) => `Um Recruiting zu deaktivieren, trennen Sie bitte zuerst die Verbindung von ${integration} mit diesem Workspace.`, - }, }, reports: { reportsCustomTitleExamples: 'Beispiele:', @@ -7282,7 +7265,6 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, disconnect: 'Trennen', reinstall: 'Connector neu installieren', disconnectTitle: (connectionName = 'Integration') => `${connectionName} trennen`, - connectTitle: (connectionName) => `${connectionName} verbinden`, syncError: (connectionName) => `Verbindung mit ${connectionName} nicht möglich`, accounts: 'Kontenplan', taxes: 'Steuern', @@ -7299,12 +7281,10 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'Standardmäßige NetSuite-Mitarbeiterperson', }, disconnectPrompt: (connectionName = 'diese Integration') => `Möchtest du ${connectionName} wirklich trennen?`, - connectPrompt: (connectionName) => `Sind Sie sicher, dass Sie ${connectionName} verbinden möchten? Dadurch werden alle bestehenden Buchhaltungsverbindungen entfernt.`, enterCredentials: 'Gib deine Anmeldedaten ein', reconnect: 'Erneut verbinden', updateCredentials: 'Anmeldedaten aktualisieren', claimOffer: { - badgeText: 'Angebot verfügbar!', xero: { headline: 'Hol dir Xero 6 Monate lang kostenlos!', description: 'Neu bei Xero? Expensify-Kund*innen erhalten 6 Monate kostenlos. Fordern Sie unten Ihr Angebot an.', @@ -8708,11 +8688,6 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc }, hr: { title: 'Personalwesen', - connectionsSubtitle: - 'Verbinden Sie sich mit Ihrem HR-System, um Mitarbeiterdaten zu synchronisieren, Erstattungen automatisch den richtigen Personen zuzuordnen und die Ausgaben Ihres Teams ohne manuellen Aufwand korrekt zu halten.', - subtitle: 'HR-Tools verbinden und Mitarbeitergenehmigungen synchron halten.', - alreadyConnectedTitle: 'Es kann keine Verbindung zu mehreren HR-Plattformen hergestellt werden', - alreadyConnectedPrompt: 'Sie müssen Ihre aktuelle HR-Plattform trennen, bevor Sie eine andere verbinden.', connectionDescription: (providerName: string) => `Verbinden Sie ${providerName}, um Mitarbeitergenehmigungen mit Ihrem Workspace zu synchronisieren.`, providerApprovalMode: (providerName: string) => `${providerName}-Genehmigungsmodus`, providerFinalApprover: (providerName: string) => `${providerName} Endgenehmigende*r`, @@ -8764,14 +8739,9 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc }, recruiting: { title: 'Personalbeschaffung', - connectionsSubtitle: - 'Verbinden Sie Ihr Recruiting-System, um Kandidatendaten zu synchronisieren, Erstattungen automatisch den richtigen Personen zuzuordnen und die Ausgaben Ihres Teams ohne manuelle Arbeit korrekt zu halten.', - alreadyConnectedTitle: 'Es kann keine Verbindung zu mehreren ATS-Plattformen hergestellt werden', - alreadyConnectedPrompt: 'Sie müssen Ihr aktuelles ATS trennen, bevor Sie ein anderes verbinden.', syncing: 'Kandidaten werden synchronisiert', setupIncomplete: (setupLink: string | undefined) => `Verbunden. ${setupLink ? `Einrichtung abschließen` : 'Einrichtung abschließen'} zum Importieren von Kandidaten.`, - dontSeeYourATS: `Sie sehen Ihr ATS hier nicht? Fragen Sie Concierge, und wir können es hinzufügen.`, importSettings: 'Import-Einstellungen', defaultApprover: 'Standardgenehmiger', approverFields: {recruiter: 'Personalvermittler', recruitingCoordinator: 'Recruiting-Koordinator'}, @@ -8797,7 +8767,6 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc }, enableJobStagesOrTags: 'Aktivieren Sie Jobphasen oder Tags, um fortzufahren', }, - subtitle: 'Verknüpfen Sie Recruiting-Tools und halten Sie Kandidatengenehmigungen synchron.', syncResults: { importedCount: () => ({ one: '1 Kandidat', @@ -8817,9 +8786,6 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc }, }, merge: { - connections: 'Verbindungen', - connect: 'Verbinden', - findIntegration: 'Integration finden', syncNow: 'Jetzt synchronisieren', disconnect: 'Trennen', disconnectTitle: (providerName: string) => `${providerName} trennen`, @@ -9009,6 +8975,32 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc noPaymentMethodsFound: 'Keine Zahlungsformen gefunden', noPaymentMethodsFoundDescription: 'Bitte fügen Sie Zahlungsformen in Dynamics 365 Business Central hinzu und synchronisieren Sie die Verbindung erneut', }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Alle', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Buchhaltung', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'Personalwesen & Mitarbeitende', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Personalbeschaffung', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Belege', + [CONST.TAB.CONNECTIONS.AI]: 'KI & MCP', + }, + findConnections: 'Verbindungen finden', + configure: 'Konfigurieren', + fix: 'Beheben', + active: 'Aktiv', + broken: 'Defekt', + brokenConnection: 'Kann aufgrund einer unterbrochenen Verbindung nicht synchronisieren', + replaceConnectionTitle: 'Verbindung ersetzen?', + offer: 'Angebot', + replaceConnectionPrompt: (connectionName: string) => `Dadurch wird Ihre aktuelle ${connectionName}-Verbindung entfernt.`, + hrisListing: (providerName: string) => `${providerName} (HRIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: 'Sie sehen Ihre nicht? Schlagen Sie eine Integration vor, wir schauen sie uns an.', + noResultsPrompt: 'Bitte passen Sie Ihre Suche an oder', + suggestAnIntegration: 'Integration vorschlagen', + allConnectedTitle: 'Sie sind vollständig verbunden', + allConnectedPrompt: 'Hier ist bereits alles verbunden. Sie können auch', + }, }, getAssistancePage: { title: 'Hilfe erhalten', @@ -11374,6 +11366,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc markAllAsRead: 'Rechtsklick, um alles als gelesen zu markieren.', markAllAsReadTouchScreen: 'Lange drücken, um alles als gelesen zu markieren.', accountMovedToTopBar: 'Greifen Sie auf Ihr Konto und Ihre persönlichen Einstellungen zu.', + connectionsMoved: 'Alle Ihre Verbindungen an einem Ort', }, discardChangesConfirmation: { title: 'Änderungen verwerfen?', diff --git a/src/languages/el.ts b/src/languages/el.ts index 8a00eaf4d875..e0da32940dc8 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -3615,15 +3615,15 @@ ${amount} για ${merchant} - ${date}`, `Σύνδεση${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '' : 'προς'} [${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'σας' : ''} ${integrationName}](${workspaceAccountingLink})`, description: ({integrationName, workspaceAccountingLink}) => Str.dedent(` - Συνδέστε το ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'σας' : 'σε'} ${integrationName} για αυτόματη κατηγοριοποίηση και συγχρονισμό εξόδων που κάνουν το κλείσιμο μήνα παιχνιδάκι. + Συνδέστε το ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'σας' : 'σε'} ${integrationName} για αυτόματη κωδικοποίηση και συγχρονισμό εξόδων, ώστε το κλείσιμο μήνα να γίνεται παιχνιδάκι. 1. Κάντε κλικ στο *χώρο εργασίας*. 2. Επιλέξτε τον χώρο εργασίας σας. - 3. Κάντε κλικ στο *λογιστική*. + 3. Κάντε κλικ στις *συνδέσεις*. 4. Βρείτε το ${integrationName}. 5. Κάντε κλικ στο *σύνδεση*. - [Μετάβαση στη λογιστική](${workspaceAccountingLink}).`), + [Μετάβαση στις συνδέσεις](${workspaceAccountingLink}).`), }, connectCorporateCardTask: { title: ({corporateCardLink}) => `Συνδέστε [τις εταιρικές σας κάρτες](${corporateCardLink})`, @@ -4969,6 +4969,7 @@ ${amount} για ${merchant} - ${date}`, deleteTravelInvoicingError: 'Η εταιρεία σας έχει ακόμα ενεργοποιημένη τη συγκεντρωτική χρέωση ταξιδιών.', vendors: 'Προμηθευτές', noAccessActionPrompt: 'Ο ρόλος σας σε αυτόν τον χώρο εργασίας δεν έχει πρόσβαση σε αυτές τις ρυθμίσεις. Ζητήστε από έναν διαχειριστή να σας δώσει πρόσβαση, αν τη χρειάζεστε.', + connections: 'Συνδέσεις', }, createdForClient: { title: 'Δημιουργήσατε έναν χώρο εργασίας για τον πελάτη σας!', @@ -6604,10 +6605,6 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ title: 'Οργανώστε', subtitle: 'Ομαδοποιήστε και αναλύστε τις δαπάνες, καταγράψτε κάθε φόρο που πληρώθηκε.', }, - integrateSection: { - title: 'Ενσωμάτωση', - subtitle: 'Συνδέστε το Expensify με δημοφιλή χρηματοοικονομικά προϊόντα.', - }, distanceRates: { title: 'Τιμές χιλιομετρικής αποζημίωσης', subtitle: 'Προσθέστε, ενημερώστε και εφαρμόστε τιμές.', @@ -6841,30 +6838,18 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ }, connections: { title: 'Λογιστική', - subtitle: 'Συγχρονίστε το λογιστικό σας σχέδιο και άλλα.', - }, - mcp: { - title: 'MCP', - subtitle: 'Συνδέστε έναν βοηθό AI με τον λογαριασμό σας στο Expensify.', }, receiptPartners: { title: 'Συνεργάτες αποδείξεων', - subtitle: 'Αυτόματη εισαγωγή αποδείξεων.', }, connectionsWarningModal: { featureEnabledTitle: 'Όχι και τόσο γρήγορα...', featureEnabledText: 'Για να ενεργοποιήσετε ή να απενεργοποιήσετε αυτή τη λειτουργία, θα χρειαστεί να αλλάξετε τις ρυθμίσεις εισαγωγής λογιστικής.', - disconnectText: 'Για να απενεργοποιήσετε τη λογιστική, θα χρειαστεί να αποσυνδέσετε τη λογιστική σύνδεση από τον χώρο εργασίας σας.', manageSettings: 'Διαχείριση ρυθμίσεων', }, receiptPartnersWarningModal: { featureEnabledTitle: 'Αποσύνδεση του Uber', - disconnectText: 'Για να απενεργοποιήσετε αυτή τη λειτουργία, αποσυνδέστε πρώτα την ενσωμάτωση Uber for Business.', description: 'Είστε βέβαιοι ότι θέλετε να αποσυνδέσετε αυτήν την ενοποίηση;', - confirmText: 'Εντάξει', - }, - hrWarningModal: { - disconnectText: ({integration}: {integration: string}) => `Για να απενεργοποιήσετε το HR, αποσυνδέστε πρώτα το ${integration} από αυτόν τον χώρο εργασίας.`, }, workflowWarningModal: { featureEnabledTitle: 'Όχι και τόσο γρήγορα...', @@ -6887,10 +6872,6 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ disabledTitle: 'Όχι τόσο γρήγορα...', disabledMessage: 'Για να ενεργοποιήσετε ή να απενεργοποιήσετε αυτήν τη λειτουργία, θα πρέπει να αλλάξετε τις ρυθμίσεις εισαγωγής λογιστικής.', }, - recruitingWarningModal: { - disconnectText: ({integration}: {integration: string}) => - `Για να απενεργοποιήσετε την προσέλκυση προσωπικού, αποσυνδέστε πρώτα το ${integration} από αυτόν τον χώρο εργασίας.`, - }, }, reports: { reportsCustomTitleExamples: 'Παραδείγματα:', @@ -7415,7 +7396,6 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ disconnect: 'Αποσύνδεση', reinstall: 'Επανεγκατάσταση συνδέτη', disconnectTitle: (connectionName = 'ενσωμάτωση') => `Αποσύνδεση ${connectionName}`, - connectTitle: (connectionName) => `Συνδέστε ${connectionName}`, syncError: (connectionName) => `Δεν είναι δυνατή η σύνδεση με το ${connectionName}`, accounts: 'Λογιστικό σχέδιο', taxes: 'Φόροι', @@ -7432,12 +7412,10 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'Προεπιλογή εργαζομένου NetSuite', }, disconnectPrompt: (connectionName = 'αυτή η ενοποίηση') => `Είστε βέβαιοι ότι θέλετε να αποσυνδέσετε το ${connectionName};`, - connectPrompt: (connectionName) => `Είστε βέβαιοι ότι θέλετε να συνδέσετε το ${connectionName}; Αυτό θα αφαιρέσει τυχόν υπάρχουσες λογιστικές συνδέσεις.`, reconnect: 'Επανασύνδεση', enterCredentials: 'Εισαγάγετε τα διαπιστευτήριά σας', updateCredentials: 'Ενημέρωση διαπιστευτηρίων', claimOffer: { - badgeText: 'Προσφορά διαθέσιμη!', xero: { headline: 'Αποκτήστε το Xero δωρεάν για 6 μήνες!', description: @@ -7684,11 +7662,6 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ }, hr: { title: 'HR', - connectionsSubtitle: - 'Συνδεθείτε με το σύστημα HR σας για να συγχρονίζετε τα στοιχεία των εργαζομένων, να αντιστοιχίζετε αυτόματα τις αποζημιώσεις στα σωστά άτομα και να διατηρείτε τα έξοδα της ομάδας σας ακριβή χωρίς χειροκίνητη εργασία.', - subtitle: 'Συνδέστε εργαλεία HR και διατηρήστε τις εγκρίσεις εργαζομένων συγχρονισμένες.', - alreadyConnectedTitle: 'Δεν είναι δυνατή η σύνδεση σε πολλές πλατφόρμες HR', - alreadyConnectedPrompt: 'Πρέπει να αποσυνδέσετε την τρέχουσα πλατφόρμα HR προτού συνδέσετε μια άλλη.', connectionDescription: (providerName: string) => `Συνδέστε το ${providerName} για να διατηρείτε τις εγκρίσεις εργαζομένων συγχρονισμένες με τον χώρο εργασίας σας.`, providerApprovalMode: (providerName: string) => `λειτουργία έγκρισης ${providerName}`, providerFinalApprover: (providerName: string) => `τελικός εγκριτής ${providerName}`, @@ -7743,14 +7716,9 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ }, recruiting: { title: 'Προσλήψεις', - connectionsSubtitle: - 'Συνδεθείτε με το σύστημα προσλήψεών σας για να συγχρονίζετε τα δεδομένα υποψηφίων, να αντιστοιχίζετε αυτόματα τις αποζημιώσεις στα σωστά άτομα και να διατηρείτε τις δαπάνες της ομάδας σας ακριβείς χωρίς χειροκίνητη εργασία.', - alreadyConnectedTitle: 'Αδυναμία σύνδεσης σε πολλές πλατφόρμες ATS', - alreadyConnectedPrompt: 'Πρέπει να αποσυνδέσετε το τρέχον ATS σας πριν συνδέσετε κάποιο άλλο.', syncing: 'Γίνεται συγχρονισμός υποψηφίων', setupIncomplete: (setupLink: string | undefined) => `Συνδέθηκε. ${setupLink ? `Ολοκληρώστε τη ρύθμιση` : 'Ολοκληρώστε τη ρύθμιση'} για εισαγωγή υποψηφίων.`, - dontSeeYourATS: `Δεν βλέπετε εδώ το ATS σας; Ρωτήστε το Concierge και μπορούμε να το προσθέσουμε.`, importSettings: 'Ρυθμίσεις εισαγωγής', defaultApprover: 'Προεπιλεγμένος εγκρίνων', approverFields: {recruiter: 'Στρατολογητής', recruitingCoordinator: 'Συντονιστής προσλήψεων'}, @@ -7776,7 +7744,6 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ }, enableJobStagesOrTags: 'Ενεργοποιήστε τα στάδια εργασίας ή τις ετικέτες για να συνεχίσετε', }, - subtitle: 'Συνδέστε τα εργαλεία προσλήψεων και διατηρήστε τις εγκρίσεις υποψηφίων συγχρονισμένες.', syncResults: { importedCount: () => ({ one: '1 υποψήφιος', @@ -7796,9 +7763,6 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ }, }, merge: { - connections: 'Συνδέσεις', - connect: 'Σύνδεση', - findIntegration: 'Βρείτε ενοποίηση', syncNow: 'Συγχρονισμός τώρα', disconnect: 'Αποσύνδεση', disconnectTitle: (providerName: string) => `Αποσύνδεση ${providerName}`, @@ -9176,6 +9140,32 @@ ${reportName}`, noPaymentMethodsFound: 'Δεν βρέθηκαν μέθοδοι πληρωμής', noPaymentMethodsFoundDescription: 'Προσθέστε μεθόδους πληρωμής στο Dynamics 365 Business Central και συγχρονίστε ξανά τη σύνδεση', }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Όλα', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Λογιστική', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'ΔΑΔ & ανθρώπινο δυναμικό', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Προσλήψεις', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Αποδείξεις', + [CONST.TAB.CONNECTIONS.AI]: 'ΤΝ & MCP', + }, + findConnections: 'Εύρεση συνδέσεων', + configure: 'Ρυθμίστε', + fix: 'Διόρθωση', + active: 'Ενεργό', + broken: 'Χαλασμένο', + brokenConnection: 'Δεν είναι δυνατός ο συγχρονισμός λόγω κατεστραμμένης σύνδεσης', + replaceConnectionTitle: 'Αντικατάσταση σύνδεσης;', + offer: 'Προσφορά', + replaceConnectionPrompt: (connectionName: string) => `Αυτό θα καταργήσει την τρέχουσα σύνδεση ${connectionName}.`, + hrisListing: (providerName: string) => `${providerName} (HRIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: 'Δεν βλέπετε τη δική σας; Προτείνετε μια ενσωμάτωση και θα το εξετάσουμε.', + noResultsPrompt: 'Παρακαλούμε προσαρμόστε την αναζήτησή σας ή', + suggestAnIntegration: 'προτείνετε μια ενσωμάτωση', + allConnectedTitle: 'Έχετε συνδεθεί πλήρως', + allConnectedPrompt: 'Όλα εδώ είναι ήδη συνδεδεμένα. Μπορείτε επίσης να', + }, }, getAssistancePage: { title: 'Λάβετε βοήθεια', @@ -11579,6 +11569,7 @@ ${reportName}`, markAllAsRead: 'Δεξί κλικ για επισήμανση όλων ως αναγνωσμένων.', markAllAsReadTouchScreen: 'Παρατεταμένο πάτημα για επισήμανση όλων ως αναγνωσμένων.', accountMovedToTopBar: 'Αποκτήστε πρόσβαση στον λογαριασμό και τις προσωπικές σας ρυθμίσεις.', + connectionsMoved: 'Όλες οι συνδέσεις σας σε ένα σημείο', }, discardChangesConfirmation: { title: 'Απόρριψη αλλαγών;', diff --git a/src/languages/es.ts b/src/languages/es.ts index 48bb1daebdcb..ea08221d069b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3469,16 +3469,15 @@ ${amount} para ${merchant} - ${date}`, `Conéctate${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '' : ' a'} [${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'tu' : ''} ${integrationName}](${workspaceAccountingLink})`, description: ({integrationName, workspaceAccountingLink}) => Str.dedent(` - Conéctate ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'tu' : 'a'} ${integrationName} para la clasificación y sincronización automática de gastos, lo que facilita el cierre de fin de mes. + Conecta ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'tu' : 'a'} ${integrationName} para la clasificación y sincronización automática de gastos, lo que facilita el cierre de fin de mes. - 1. Haz clic en *Espacios de trabajo*. + 1. Haz clic en *Workspaces*. 2. Selecciona tu espacio de trabajo. - 3. Haz clic en *Contabilidad*. + 3. Haz clic en *Connections*. 4. Busca ${integrationName}. - 5. Haz clic en *Conectar*. + 5. Haz clic en *Connect*. - [Ir a contabilidad](${workspaceAccountingLink}). - `), + [Llévame a Conexiones](${workspaceAccountingLink}).`), }, connectCorporateCardTask: { title: ({corporateCardLink}) => `Conecta [tu tarjeta corporativa](${corporateCardLink})`, @@ -4824,6 +4823,7 @@ ${amount} para ${merchant} - ${date}`, peopleAdminAlternateText: 'Gestiona miembros y flujos de aprobación.', paymentsAdminAlternateText: 'Gestiona los pagos del flujo de trabajo.', noAccessActionPrompt: 'Tu rol en el espacio de trabajo no tiene acceso a estos ajustes. Pide a un administrador que te lo habilite si lo necesitas.', + connections: 'Conexiones', }, createdForClient: { title: '¡Has creado un espacio de trabajo para tu cliente!', @@ -6356,10 +6356,6 @@ ${amount} para ${merchant} - ${date}`, title: 'Organizar', subtitle: 'Agrupa y analiza el gasto, registra cada impuesto pagado.', }, - integrateSection: { - title: 'Integrar', - subtitle: 'Conecta Expensify a otros productos financieros populares.', - }, distanceRates: { title: 'Tasas de distancia', subtitle: 'Añade, actualiza y haz cumplir las tasas.', @@ -6596,29 +6592,19 @@ ${amount} para ${merchant} - ${date}`, }, connections: { title: 'Contabilidad', - subtitle: 'Sincroniza tu plan de cuentas y otras opciones.', - }, - mcp: { - title: 'MCP', - subtitle: 'Conecta un asistente de IA a tu cuenta de Expensify.', }, receiptPartners: { title: 'Socios de recibos', - subtitle: 'Importación automática de recibos.', }, connectionsWarningModal: { featureEnabledTitle: 'No tan rápido...', featureEnabledText: 'Para activar o desactivar esta función, cambia la configuración de importación contable.', - disconnectText: 'Para desactivar la contabilidad, desconecta tu conexión contable del espacio de trabajo.', manageSettings: 'Gestionar la configuración', }, receiptPartnersWarningModal: { featureEnabledTitle: 'Desconectar Uber', - disconnectText: 'Para desactivar esta función, desconecta primero la integración de Uber for Business.', description: '¿Está seguro de que desea desconectar esta integración?', - confirmText: 'Entendido', }, - hrWarningModal: {disconnectText: ({integration}: {integration: string}) => `Para deshabilitar RR. HH., primero desconecta ${integration} de este espacio de trabajo.`}, workflowWarningModal: { featureEnabledTitle: 'No tan rápido...', featureEnabledText: @@ -6634,7 +6620,6 @@ ${amount} para ${merchant} - ${date}`, subtitle: 'Establecer una tarifa por hora facturable para el seguimiento de tiempo.', defaultHourlyRate: 'Tarifa por hora predeterminada', }, - recruitingWarningModal: {disconnectText: ({integration}: {integration: string}) => `Para desactivar Recruiting, primero desconecta ${integration} de este espacio de trabajo.`}, }, reports: { reportsCustomTitleExamples: 'Ejemplos:', @@ -7088,7 +7073,6 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, disconnect: 'Desconectar', reinstall: 'Reinstalar el conector', disconnectTitle: (connectionName = 'integración') => `Desconectar ${connectionName}`, - connectTitle: (connectionName) => `Conectar ${connectionName}`, syncError: (connectionName) => `No se puede conectar a ${connectionName}`, accounts: 'Plan de cuentas', taxes: 'Impuestos', @@ -7105,12 +7089,10 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'Predeterminado del empleado NetSuite', }, disconnectPrompt: (connectionName = 'integración') => `¿Estás seguro de que quieres desconectar ${connectionName}?`, - connectPrompt: (connectionName) => `¿Estás seguro de que quieres conectar a ${connectionName}? Esto eliminará cualquier conexión contable existente.`, enterCredentials: 'Ingresa tus credenciales', reconnect: 'Reconectar', updateCredentials: 'Actualizar credenciales', claimOffer: { - badgeText: '¡Oferta disponible!', xero: { headline: '¡Obtén Xero gratis por 6 meses!', description: @@ -7421,11 +7403,6 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, }, hr: { title: 'HR', - connectionsSubtitle: - 'Conéctate a tu sistema de RR. HH. para sincronizar los datos de empleados, emparejar automáticamente los reembolsos con las personas correctas y mantener los gastos de tu equipo precisos sin trabajo manual.', - subtitle: 'Conecta herramientas de HR y mantén sincronizadas las aprobaciones de empleados.', - alreadyConnectedTitle: 'No se puede conectar a varias plataformas de RR. HH.', - alreadyConnectedPrompt: 'Debes desconectar tu plataforma de RR. HH. actual antes de conectar otra.', connectionDescription: (providerName: string) => `Conecta ${providerName} para mantener sincronizadas las aprobaciones de empleados con tu espacio de trabajo.`, providerApprovalMode: (providerName: string) => `Modo de aprobación de ${providerName}`, providerFinalApprover: (providerName: string) => `Aprobador final de ${providerName}`, @@ -7477,14 +7454,9 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, }, recruiting: { title: 'Reclutamiento', - connectionsSubtitle: - 'Conéctate a tu sistema de selección para sincronizar los datos de los candidatos, vincular automáticamente los reembolsos con las personas correctas y mantener los gastos de tu equipo precisos sin trabajo manual.', - alreadyConnectedTitle: 'No se puede conectar a varias plataformas ATS', - alreadyConnectedPrompt: 'Debes desconectar tu ATS actual antes de conectar otro.', syncing: 'Sincronizando candidatos', setupIncomplete: (setupLink: string | undefined) => `Conectado. ${setupLink ? `Completar configuración` : 'Completar configuración'} para importar candidatos.`, - dontSeeYourATS: `¿No ves tu ATS aquí? Pregunta a Concierge y podremos añadirlo.`, importSettings: 'Importar ajustes', defaultApprover: 'Aprobador predeterminado', approverFields: {recruiter: 'Reclutador', recruitingCoordinator: 'Coordinador de selección'}, @@ -7510,7 +7482,6 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, }, enableJobStagesOrTags: 'Habilita las etapas de trabajo o las etiquetas para continuar', }, - subtitle: 'Conecta herramientas de selección y mantén sincronizadas las aprobaciones de candidatos.', syncResults: { importedCount: () => ({ one: '1 candidato', @@ -7529,9 +7500,6 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, }, }, merge: { - connections: 'Conexiones', - connect: 'Conectar', - findIntegration: 'Buscar integración', syncNow: 'Sincronizar ahora', disconnect: 'Desconectar', disconnectTitle: (providerName: string) => `Desconectar ${providerName}`, @@ -8835,6 +8803,32 @@ ${reportName}`, noPaymentMethodsFound: 'No se encontraron formas de pago', noPaymentMethodsFoundDescription: 'Añade formas de pago en Dynamics 365 Business Central y vuelve a sincronizar la conexión', }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Todo', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Contabilidad', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'RR. HH. y Personas', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Reclutamiento', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Recibos', + [CONST.TAB.CONNECTIONS.AI]: 'IA y MCP', + }, + findConnections: 'Buscar conexiones', + configure: 'Configurar', + fix: 'Corregir', + active: 'Activo', + broken: 'Roto', + brokenConnection: 'No se puede sincronizar debido a una conexión interrumpida', + replaceConnectionTitle: '¿Reemplazar la conexión?', + offer: 'Oferta', + replaceConnectionPrompt: (connectionName: string) => `Esto eliminará tu conexión actual de ${connectionName}.`, + hrisListing: (providerName: string) => `${providerName} (HRIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: '¿No ves la tuya? Sugiere una integración y lo estudiaremos.', + noResultsPrompt: 'Ajusta tu búsqueda o', + suggestAnIntegration: 'sugiere una integración', + allConnectedTitle: 'Ya está todo conectado', + allConnectedPrompt: 'Todo aquí ya está conectado. También puedes', + }, }, getAssistancePage: { title: 'Obtener ayuda', @@ -11564,6 +11558,7 @@ ${reportName}`, markAllAsRead: 'Haz clic derecho para marcar todo como leído.', markAllAsReadTouchScreen: 'Mantén pulsado para marcar todo como leído.', accountMovedToTopBar: 'Accede a tu cuenta y a tu configuración personal.', + connectionsMoved: 'Todas tus conexiones en un solo lugar', }, discardChangesConfirmation: { title: '¿Descartar cambios?', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 4db2787967c8..92950fe82d42 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3573,16 +3573,15 @@ ${amount} pour ${merchant} - ${date}`, `Connecter${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '' : 'à'} [${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'votre' : ''} ${integrationName}](${workspaceAccountingLink})`, description: ({integrationName, workspaceAccountingLink}) => Str.dedent(` - Connectez ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'votre' : 'à'} ${integrationName} pour profiter d’un codage automatique des dépenses et d’une synchronisation qui simplifient la clôture de fin de mois. + Connectez ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'votre' : 'à'} ${integrationName} pour un codage automatique des dépenses et une synchronisation qui rendront la clôture de fin de mois très simple. 1. Cliquez sur *Espaces de travail*. 2. Sélectionnez votre espace de travail. - 3. Cliquez sur *Comptabilité*. - 4. Recherchez ${integrationName}. + 3. Cliquez sur *Connexions*. + 4. Trouvez ${integrationName}. 5. Cliquez sur *Connecter*. - [Accéder à la comptabilité](${workspaceAccountingLink}). - `), + [Aller aux connexions](${workspaceAccountingLink}).`), }, connectCorporateCardTask: { title: ({corporateCardLink}) => `Connectez [vos cartes d’entreprise](${corporateCardLink})`, @@ -4900,6 +4899,7 @@ ${amount} pour ${merchant} - ${date}`, readOnlyActionTitle: 'Pas si vite...', readOnlyActionPrompt: 'Votre rôle dans cet espace de travail peut afficher ces paramètres, mais ne peut pas les modifier.', noAccessActionPrompt: 'Votre rôle dans l’espace de travail n’a pas accès à ces paramètres. Demandez à un administrateur si vous en avez besoin.', + connections: 'Connexions', }, createdForClient: { title: 'Vous avez créé un espace de travail pour votre client !', @@ -6500,10 +6500,6 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. title: 'Organiser', subtitle: 'Regroupez et analysez les dépenses, enregistrez chaque taxe payée.', }, - integrateSection: { - title: 'Intégrer', - subtitle: 'Connectez Expensify à des produits financiers populaires.', - }, distanceRates: { title: 'Taux de distance', subtitle: 'Ajoutez, mettez à jour et appliquez les taux.', @@ -6736,27 +6732,18 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. }, connections: { title: 'Comptabilité', - subtitle: 'Synchronisez votre plan comptable et plus encore.', - }, - mcp: { - title: 'MCP', - subtitle: 'Connectez un assistant IA à votre compte Expensify.', }, receiptPartners: { title: 'Partenaires de reçus', - subtitle: 'Importer automatiquement les reçus.', }, connectionsWarningModal: { featureEnabledTitle: 'Pas si vite…', featureEnabledText: 'Pour activer ou désactiver cette fonctionnalité, vous devez modifier vos paramètres d’import comptable.', - disconnectText: 'Pour désactiver la comptabilité, vous devez déconnecter votre connexion comptable de votre espace de travail.', manageSettings: 'Gérer les paramètres', }, receiptPartnersWarningModal: { featureEnabledTitle: 'Dissocier Uber', - disconnectText: 'Pour désactiver cette fonctionnalité, veuillez d’abord déconnecter l’intégration Uber for Business.', description: 'Voulez-vous vraiment déconnecter cette intégration ?', - confirmText: 'Compris', }, workflowWarningModal: { featureEnabledTitle: 'Pas si vite…', @@ -6773,16 +6760,12 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. subtitle: 'Définissez un taux horaire facturable pour le suivi du temps.', defaultHourlyRate: 'Taux horaire par défaut', }, - hrWarningModal: {disconnectText: ({integration}: {integration: string}) => `Pour désactiver les RH, veuillez d'abord déconnecter ${integration} de cet espace de travail.`}, vendors: { title: 'Fournisseurs', subtitle: 'Faites correspondre les dépenses de carte aux fournisseurs importés depuis votre logiciel de comptabilité.', disabledTitle: 'Pas si vite...', disabledMessage: 'Pour activer ou désactiver cette fonctionnalité, vous devrez modifier vos paramètres d’importation comptable.', }, - recruitingWarningModal: { - disconnectText: ({integration}: {integration: string}) => `Pour désactiver le recrutement, veuillez d’abord déconnecter ${integration} de cet espace de travail.`, - }, }, reports: { reportsCustomTitleExamples: 'Exemples :', @@ -7301,7 +7284,6 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, disconnect: 'Déconnecter', reinstall: 'Réinstaller le connecteur', disconnectTitle: (connectionName = 'intégration') => `Déconnecter ${connectionName}`, - connectTitle: (connectionName) => `Connecter ${connectionName}`, syncError: (connectionName) => `Impossible de se connecter à ${connectionName}`, accounts: 'Plan comptable', taxes: 'Taxes', @@ -7318,12 +7300,10 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'Par défaut employé NetSuite', }, disconnectPrompt: (connectionName = 'cette intégration') => `Voulez-vous vraiment déconnecter ${connectionName} ?`, - connectPrompt: (connectionName) => `Voulez-vous vraiment connecter ${connectionName} ? Cette action supprimera toutes les connexions comptables existantes.`, enterCredentials: 'Saisissez vos identifiants', reconnect: 'Reconnecter', updateCredentials: 'Mettre à jour les identifiants', claimOffer: { - badgeText: 'Offre disponible !', xero: { headline: 'Obtenez Xero gratuitement pendant 6 mois !', description: @@ -8725,11 +8705,6 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e }, hr: { title: 'RH', - connectionsSubtitle: - 'Connectez votre système RH pour synchroniser les données des employé·es, faire correspondre automatiquement les remboursements aux bonnes personnes et garder les dépenses de votre équipe exactes, sans travail manuel.', - subtitle: 'Connectez vos outils RH et gardez les approbations des employés synchronisées.', - alreadyConnectedTitle: 'Impossible de se connecter à plusieurs plateformes RH', - alreadyConnectedPrompt: 'Vous devez déconnecter votre plateforme RH actuelle avant d’en connecter une autre.', connectionDescription: (providerName: string) => `Connectez ${providerName} pour synchroniser les approbations des employés avec votre espace de travail.`, providerApprovalMode: (providerName: string) => `Mode d'approbation ${providerName}`, providerFinalApprover: (providerName: string) => `Approbateur final ${providerName}`, @@ -8781,14 +8756,9 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e }, recruiting: { title: 'Recrutement', - connectionsSubtitle: - 'Connectez votre système de recrutement pour synchroniser les données des candidats, faire correspondre automatiquement les remboursements aux bonnes personnes et maintenir des dépenses d’équipe exactes sans travail manuel.', - alreadyConnectedTitle: 'Impossible de se connecter à plusieurs plateformes ATS', - alreadyConnectedPrompt: 'Vous devez déconnecter votre ATS actuel avant d’en connecter un autre.', syncing: 'Synchronisation des candidats', setupIncomplete: (setupLink: string | undefined) => `Connecté. ${setupLink ? `Terminer la configuration` : 'Terminer la configuration'} pour importer des candidats.`, - dontSeeYourATS: `Vous ne voyez pas votre ATS ici ? Demandez à Concierge et nous pourrons l’ajouter.`, importSettings: 'Paramètres d’importation', defaultApprover: 'Approbateur par défaut', approverFields: {recruiter: 'Recruteur', recruitingCoordinator: 'Coordinateur recrutement'}, @@ -8815,7 +8785,6 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e }, enableJobStagesOrTags: 'Activez les étapes de mission ou les Tags pour continuer', }, - subtitle: 'Connectez les outils de recrutement et synchronisez les validations de candidats.', syncResults: { importedCount: () => ({ one: '1 candidat', @@ -8835,9 +8804,6 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e }, }, merge: { - connections: 'Connexions', - connect: 'Connecter', - findIntegration: 'Rechercher une intégration', syncNow: 'Synchroniser maintenant', disconnect: 'Déconnecter', disconnectTitle: (providerName: string) => `Déconnecter ${providerName}`, @@ -9030,6 +8996,32 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e noPaymentMethodsFound: 'Aucun mode de règlement trouvé', noPaymentMethodsFoundDescription: 'Veuillez ajouter des modes de règlement dans Dynamics 365 Business Central et synchroniser à nouveau la connexion', }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Tous', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Comptabilité', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'RH & Personnel', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Recrutement', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Reçus', + [CONST.TAB.CONNECTIONS.AI]: 'IA et MCP', + }, + findConnections: 'Trouver des connexions', + configure: 'Configurer', + fix: 'Corriger', + active: 'Actif', + broken: 'Cassé', + brokenConnection: 'Synchronisation impossible en raison d’une connexion interrompue', + replaceConnectionTitle: 'Remplacer la connexion ?', + offer: 'Offre', + replaceConnectionPrompt: (connectionName: string) => `Cela supprimera votre connexion ${connectionName} actuelle.`, + hrisListing: (providerName: string) => `${providerName} (SIRH)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: 'Vous ne voyez pas la vôtre ? Suggérez une intégration, nous allons l’examiner.', + noResultsPrompt: 'Veuillez modifier votre recherche ou', + suggestAnIntegration: 'suggérer une intégration', + allConnectedTitle: 'Vous êtes entièrement connecté', + allConnectedPrompt: 'Tout est déjà connecté ici. Vous pouvez aussi', + }, }, getAssistancePage: { title: 'Obtenir de l’aide', @@ -11398,6 +11390,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e markAllAsRead: 'Faites un clic droit pour tout marquer comme lu.', markAllAsReadTouchScreen: 'Faites un appui long pour tout marquer comme lu.', accountMovedToTopBar: 'Accédez à votre compte et à vos paramètres personnels.', + connectionsMoved: 'Toutes vos connexions au même endroit', }, discardChangesConfirmation: { title: 'Ignorer les modifications ?', diff --git a/src/languages/it.ts b/src/languages/it.ts index 0eef5c19b96e..7b8853f3bec5 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3551,16 +3551,15 @@ ${amount} per ${merchant} - ${date}`, `Collega${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '' : 'a'} [${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'il tuo' : ''} ${integrationName}](${workspaceAccountingLink})`, description: ({integrationName, workspaceAccountingLink}) => Str.dedent(` - Collega ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'il tuo' : 'a'} ${integrationName} per la codifica automatica delle spese e la sincronizzazione che rendono la chiusura di fine mese semplicissima. + Collega ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'tuo' : 'a'} ${integrationName} per la codifica automatica delle spese e la sincronizzazione che rendono la chiusura di fine mese semplicissima. - 1. Fai clic su *Spazi di lavoro*. - 2. Seleziona il tuo spazio di lavoro. - 3. Fai clic su *Contabilità*. + 1. Clicca su *Workspace*. + 2. Seleziona il tuo workspace. + 3. Clicca su *Connessioni*. 4. Trova ${integrationName}. - 5. Fai clic su *Connetti*. + 5. Clicca su *Connetti*. - [Portami alla contabilità](${workspaceAccountingLink}). - `), + [Portami alle connessioni](${workspaceAccountingLink}).`), }, connectCorporateCardTask: { title: ({corporateCardLink}) => `Collega [le tue carte aziendali](${corporateCardLink})`, @@ -4863,6 +4862,7 @@ ${amount} per ${merchant} - ${date}`, readOnlyActionTitle: 'Non così in fretta...', readOnlyActionPrompt: 'Il tuo ruolo nello spazio di lavoro può visualizzare queste impostazioni, ma non può modificarle.', noAccessActionPrompt: 'Il tuo ruolo nello spazio di lavoro non ha accesso a queste impostazioni. Chiedi a un amministratore se ne hai bisogno.', + connections: 'Connessioni', }, createdForClient: { title: 'Hai creato uno spazio di lavoro per il tuo cliente!', @@ -6447,10 +6447,6 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. title: 'Organizza', subtitle: 'Raggruppa e analizza le spese, registra ogni imposta pagata.', }, - integrateSection: { - title: 'Integra', - subtitle: 'Collega Expensify ai più diffusi prodotti finanziari.', - }, distanceRates: { title: 'Tariffe chilometriche', subtitle: 'Aggiungi, aggiorna e applica le tariffe.', @@ -6682,27 +6678,18 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. }, connections: { title: 'Contabilità', - subtitle: 'Sincronizza il tuo piano dei conti e altro ancora.', - }, - mcp: { - title: 'MCP', - subtitle: 'Collega un assistente IA al tuo account Expensify.', }, receiptPartners: { title: 'Partner ricevute', - subtitle: 'Importa automaticamente le ricevute.', }, connectionsWarningModal: { featureEnabledTitle: 'Non così in fretta...', featureEnabledText: 'Per attivare o disattivare questa funzionalità, devi modificare le impostazioni di importazione contabile.', - disconnectText: 'Per disattivare la contabilità, devi disconnettere la connessione contabile dal tuo spazio di lavoro.', manageSettings: 'Gestisci impostazioni', }, receiptPartnersWarningModal: { featureEnabledTitle: 'Disconnetti Uber', - disconnectText: 'Per disattivare questa funzionalità, disconnetti prima l’integrazione Uber for Business.', description: 'Sei sicuro di voler disconnettere questa integrazione?', - confirmText: 'Ho capito', }, workflowWarningModal: { featureEnabledTitle: 'Non così in fretta...', @@ -6719,14 +6706,12 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. subtitle: 'Imposta una tariffa oraria fatturabile per il rilevamento del tempo.', defaultHourlyRate: 'Tariffa oraria predefinita', }, - hrWarningModal: {disconnectText: ({integration}: {integration: string}) => `Per disattivare HR, scollega prima ${integration} da questo workspace.`}, vendors: { title: 'Fornitori', subtitle: 'Abbina le spese della carta ai fornitori importati dal tuo software di contabilità.', disabledTitle: 'Non così in fretta...', disabledMessage: 'Per abilitare o disabilitare questa funzione, dovrai modificare le impostazioni di importazione della contabilità.', }, - recruitingWarningModal: {disconnectText: ({integration}: {integration: string}) => `Per disattivare Recruiting, scollega prima ${integration} da questo spazio di lavoro.`}, }, reports: { reportsCustomTitleExamples: 'Esempi:', @@ -7240,7 +7225,6 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, disconnect: 'Disconnetti', reinstall: 'Reinstalla connettore', disconnectTitle: (connectionName = 'integrazione') => `Disconnetti ${connectionName}`, - connectTitle: (connectionName) => `Collega ${connectionName}`, syncError: (connectionName) => `Impossibile connettersi a ${connectionName}`, accounts: 'Piano dei conti', taxes: 'Tasse', @@ -7257,12 +7241,10 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'Impostazione predefinita dipendente NetSuite', }, disconnectPrompt: (connectionName = 'questa integrazione') => `Sei sicuro di voler disconnettere ${connectionName}?`, - connectPrompt: (connectionName) => `Sei sicuro di voler collegare ${connectionName}? Questo rimuoverà tutte le connessioni contabili esistenti.`, enterCredentials: 'Inserisci le tue credenziali', reconnect: 'Riconnetti', updateCredentials: 'Aggiorna credenziali', claimOffer: { - badgeText: 'Offerta disponibile!', xero: { headline: 'Ottieni Xero gratis per 6 mesi!', description: 'Nuovo su Xero? I clienti Expensify ricevono 6 mesi gratis. Richiedi la tua offerta qui sotto.', @@ -8652,11 +8634,6 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, hr: { title: 'Risorse umane', - connectionsSubtitle: - 'Collega il tuo sistema HR per sincronizzare i dati dei dipendenti, abbinare automaticamente i rimborsi alle persone giuste e mantenere le spese del tuo team accurate senza lavoro manuale.', - subtitle: 'Collega gli strumenti HR e mantieni sincronizzate le approvazioni dei dipendenti.', - alreadyConnectedTitle: 'Impossibile connettersi a più piattaforme HR', - alreadyConnectedPrompt: 'Devi disconnettere la tua attuale piattaforma HR prima di collegarne un’altra.', connectionDescription: (providerName: string) => `Collega ${providerName} per mantenere sincronizzate le approvazioni dei dipendenti con il tuo spazio di lavoro.`, providerApprovalMode: (providerName: string) => `Modalità di approvazione ${providerName}`, providerFinalApprover: (providerName: string) => `Approvatore finale ${providerName}`, @@ -8708,14 +8685,9 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, recruiting: { title: 'Assunzioni', - connectionsSubtitle: - 'Connetti il tuo sistema di recruiting per sincronizzare i dati dei candidati, abbinare automaticamente i rimborsi alle persone giuste e mantenere le spese del team accurate senza lavoro manuale.', - alreadyConnectedTitle: 'Impossibile connettersi a più piattaforme ATS', - alreadyConnectedPrompt: 'Devi disconnettere il tuo ATS attuale prima di collegarne un altro.', syncing: 'Sincronizzazione candidati', setupIncomplete: (setupLink: string | undefined) => `Connesso. ${setupLink ? `Completa la configurazione` : 'Completa configurazione'} per importare i candidati.`, - dontSeeYourATS: `Non vedi il tuo ATS qui? Chiedi a Concierge e possiamo aggiungerlo.`, importSettings: 'Impostazioni di importazione', defaultApprover: 'Approvatore predefinito', approverFields: {recruiter: 'Recruiter', recruitingCoordinator: 'Coordinatore recruiting'}, @@ -8741,7 +8713,6 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, enableJobStagesOrTags: 'Abilita le fasi di lavoro o i tag per continuare', }, - subtitle: 'Collega gli strumenti di recruiting e mantieni sincronizzate le approvazioni dei candidati.', syncResults: { importedCount: () => ({ one: '1 candidato', @@ -8761,9 +8732,6 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, }, merge: { - connections: 'Connessioni', - connect: 'Connetti', - findIntegration: 'Trova integrazione', syncNow: 'Sincronizza ora', disconnect: 'Disconnetti', disconnectTitle: (providerName: string) => `Disconnetti ${providerName}`, @@ -8953,6 +8921,32 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, noPaymentMethodsFound: 'Nessun metodo di pagamento trovato', noPaymentMethodsFoundDescription: 'Aggiungi metodi di pagamento in Dynamics 365 Business Central e sincronizza di nuovo la connessione', }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Tutto', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Contabilità', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'Risorse umane e persone', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Assunzioni', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Ricevute', + [CONST.TAB.CONNECTIONS.AI]: 'IA e MCP', + }, + findConnections: 'Trova connessioni', + configure: 'Configura', + fix: 'Correggi', + active: 'Attivo', + broken: 'Guasto', + brokenConnection: 'Impossibile sincronizzare a causa di una connessione interrotta', + replaceConnectionTitle: 'Sostituire la connessione?', + offer: 'Offerta', + replaceConnectionPrompt: (connectionName: string) => `Questo rimuoverà la tua connessione ${connectionName} attuale.`, + hrisListing: (providerName: string) => `${providerName} (HRIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: "Non vedi la tua? Suggerisci un'integrazione, la valuteremo.", + noResultsPrompt: 'Modifica la tua ricerca oppure', + suggestAnIntegration: "suggerisci un'integrazione", + allConnectedTitle: 'Sei tutto connesso', + allConnectedPrompt: 'Qui è già tutto collegato. Puoi anche', + }, }, getAssistancePage: { title: 'Ottieni assistenza', @@ -11322,6 +11316,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, markAllAsRead: 'Fai clic destro per segnare tutto come letto.', markAllAsReadTouchScreen: 'Tieni premuto per segnare tutto come letto.', accountMovedToTopBar: 'Accedi al tuo account e alle impostazioni personali.', + connectionsMoved: 'Tutte le tue connessioni in un unico posto', }, discardChangesConfirmation: { title: 'Scartare le modifiche?', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 15e7f1e4ad31..74ee202aa5d1 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3522,16 +3522,15 @@ ${date} の ${merchant} への ${amount}`, `接続${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '' : 'から へ'} [${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' : ''} ${integrationName}](${workspaceAccountingLink})`, description: ({integrationName, workspaceAccountingLink}) => Str.dedent(` -${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' : '宛先'} ${integrationName} を接続して、自動経費コード設定と同期を行い、月末締めをスムーズにしましょう。 +${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' : '宛先'} ${integrationName} を連携して、経費の自動仕訳と同期を行い、月次決算をスムーズにしましょう。 - 1.「*ワークスペース*」をクリックします。 + 1. 「*ワークスペース*」をクリックします。 2. ワークスペースを選択します。 - 3.「*会計*」をクリックします。 + 3. 「*接続*」をクリックします。 4. ${integrationName} を探します。 - 5.「*接続*」をクリックします。 + 5. 「*接続*」をクリックします。 - [経理画面に移動](${workspaceAccountingLink})。 - `), + [接続画面へ移動](${workspaceAccountingLink})。`), }, connectCorporateCardTask: { title: ({corporateCardLink}) => `[法人カード](${corporateCardLink})を連携`, @@ -4826,6 +4825,7 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' readOnlyActionTitle: 'ちょっと待ってください…', readOnlyActionPrompt: 'このワークスペースでのあなたのロールは、これらの設定を表示できますが、編集することはできません。', noAccessActionPrompt: 'このワークスペースでのあなたのロールでは、これらの設定にアクセスできません。必要な場合は管理者に依頼してください。', + connections: '連携', }, createdForClient: { title: 'クライアントのワークスペースを作成しました!', @@ -6372,10 +6372,6 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO title: '整理', subtitle: '支出をグループ化して分析し、支払ったすべての税金を記録します。', }, - integrateSection: { - title: '連携する', - subtitle: 'Expensify を人気の金融サービスに接続しましょう。', - }, distanceRates: { title: '距離単価', subtitle: 'レートを追加、更新し、適用します。', @@ -6592,27 +6588,18 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO }, connections: { title: '会計', - subtitle: '勘定科目表などを同期します。', - }, - mcp: { - title: 'MCP', - subtitle: 'AIアシスタントをExpensifyアカウントに接続します。', }, receiptPartners: { title: '領収書パートナー', - subtitle: '領収書を自動で取り込みます。', }, connectionsWarningModal: { featureEnabledTitle: 'ちょっと待ってください…', featureEnabledText: 'この機能を有効または無効にするには、会計インポート設定を変更する必要があります。', - disconnectText: '会計機能を無効にするには、ワークスペースから会計連携を切断する必要があります。', manageSettings: '設定を管理', }, receiptPartnersWarningModal: { featureEnabledTitle: 'Uber との連携を解除', - disconnectText: 'この機能を無効にするには、まず Uber for Business の連携を解除してください。', description: 'この連携を解除してもよろしいですか?', - confirmText: '了解しました', }, workflowWarningModal: { featureEnabledTitle: 'ちょっと待ってください…', @@ -6629,16 +6616,12 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO subtitle: '時間追跡用の請求可能な時間単価を設定します。', defaultHourlyRate: 'デフォルトの時給率', }, - hrWarningModal: {disconnectText: ({integration}: {integration: string}) => `HR を無効にするには、まずこのワークスペースから ${integration} を切断してください。`}, vendors: { title: 'ベンダー', subtitle: '会計ソフトからインポートした取引先にカード経費を照合します。', disabledTitle: 'ちょっと待ってください...', disabledMessage: 'この機能を有効または無効にするには、会計インポート設定を変更する必要があります。', }, - recruitingWarningModal: { - disconnectText: ({integration}: {integration: string}) => `採用機能を無効にするには、まずこのワークスペースから ${integration} の連携を解除してください。`, - }, }, reports: { reportsCustomTitleExamples: '例:', @@ -7150,7 +7133,6 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から disconnect: '切断', reinstall: 'コネクタを再インストール', disconnectTitle: (connectionName = '連携') => `${connectionName}の接続を解除`, - connectTitle: (connectionName) => `${connectionName} を接続`, syncError: (connectionName) => `${connectionName} に接続できません`, accounts: '勘定科目表', taxes: '税金', @@ -7167,12 +7149,10 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'NetSuite 従業員のデフォルト', }, disconnectPrompt: (connectionName = 'この連携') => `${connectionName} の接続を本当に解除しますか?`, - connectPrompt: (connectionName) => `${connectionName} を接続してもよろしいですか?これにより、既存の会計連携はすべて削除されます。`, enterCredentials: '認証情報を入力してください', reconnect: '再接続', updateCredentials: '認証情報を更新', claimOffer: { - badgeText: 'オファーをご利用いただけます!', xero: { headline: 'Xero を6か月間無料で利用しましょう!', description: @@ -8543,10 +8523,6 @@ ${reportName}`, }, hr: { title: '人事', - connectionsSubtitle: '人事システムと連携して従業員データを同期し、精算を自動で正しい担当者に紐づけることで、手作業なしでチームの経費を正確に管理できます。', - subtitle: '人事ツールを連携して、従業員の承認を常に同期させます。', - alreadyConnectedTitle: '複数の人事プラットフォームには接続できません', - alreadyConnectedPrompt: '別の人事プラットフォームに接続する前に、現在の人事プラットフォームとの接続を解除する必要があります。', connectionDescription: (providerName: string) => `${providerName}を接続して、従業員の承認をワークスペースと同期させましょう。`, providerApprovalMode: (providerName: string) => `${providerName} 承認モード`, providerFinalApprover: (providerName: string) => `${providerName} 最終承認者`, @@ -8598,13 +8574,9 @@ ${reportName}`, }, recruiting: { title: '採用', - connectionsSubtitle: '採用システムに接続して候補者データを同期し、払い戻しを自動的に適切な人に紐づけることで、手作業なしでチームの経費精度を保てます。', - alreadyConnectedTitle: '複数の ATS プラットフォームには接続できません', - alreadyConnectedPrompt: '別の ATS を接続する前に、現在の ATS を必ず切断する必要があります。', syncing: '候補者を同期しています', setupIncomplete: (setupLink: string | undefined) => `接続済み。${setupLink ? `セットアップを完了` : '設定を完了'} をインポート候補として追加します。`, - dontSeeYourATS: `ご利用のATSが見つかりませんか?Concierge にお問い合わせいただければ、追加できます。`, importSettings: 'インポート設定', defaultApprover: 'デフォルト承認者', approverFields: {recruiter: '採用担当者', recruitingCoordinator: '採用コーディネーター'}, @@ -8625,7 +8597,6 @@ ${reportName}`, offices: {title: 'オフィス', description: 'このワークスペースと同期したい候補者のオフィスを選択してください', toggleTitle: 'オフィス', allSelected: 'すべてのオフィス'}, enableJobStagesOrTags: '続行するにはジョブステージまたはタグを有効にしてください', }, - subtitle: '採用ツールを連携して、候補者の承認を常に同期させます。', syncResults: { importedCount: () => ({ one: '1 名の候補者', @@ -8645,9 +8616,6 @@ ${reportName}`, }, }, merge: { - connections: '接続', - connect: '接続', - findIntegration: '連携を検索', syncNow: '今すぐ同期', disconnect: '切断', disconnectTitle: (providerName: string) => `${providerName}を切断`, @@ -8831,6 +8799,32 @@ ${reportName}`, noPaymentMethodsFound: '支払方法が見つかりません', noPaymentMethodsFoundDescription: 'Dynamics 365 Business Central で支払方法を追加し、接続を再度同期してください', }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'すべて', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: '会計', + [CONST.TAB.CONNECTIONS.PEOPLE]: '人事・人材', + [CONST.TAB.CONNECTIONS.RECRUITING]: '採用', + [CONST.TAB.CONNECTIONS.RECEIPTS]: '領収書', + [CONST.TAB.CONNECTIONS.AI]: 'AI と MCP', + }, + findConnections: '連携先を見つける', + configure: '設定', + fix: '修正', + active: '有効', + broken: '壊れています', + brokenConnection: '接続が切断されているため同期できません', + replaceConnectionTitle: '接続を置き換えますか?', + offer: 'オファー', + replaceConnectionPrompt: (connectionName: string) => `現在の ${connectionName} 接続が削除されます。`, + hrisListing: (providerName: string) => `${providerName}(HRIS)`, + atsListing: (providerName: string) => `${providerName}(ATS)`, + suggestIntegration: 'お探しのものが見つかりませんか?連携を提案してください、こちらで検討します。', + noResultsPrompt: '検索条件を調整するか', + suggestAnIntegration: '連携を提案する', + allConnectedTitle: 'すべて接続されました', + allConnectedPrompt: 'ここにあるものはすべてすでに連携されています。さらに、次のこともできます', + }, }, getAssistancePage: { title: 'サポートを受ける', @@ -11156,6 +11150,7 @@ ${reportName}`, markAllAsRead: '右クリックですべて既読にすることができます。', markAllAsReadTouchScreen: '長押しですべて既読にすることができます。', accountMovedToTopBar: 'アカウントと個人設定にアクセスします。', + connectionsMoved: 'すべての連携をひとまとめに', }, discardChangesConfirmation: { title: '変更を破棄しますか?', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index b99da12df736..c79c03920229 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3554,16 +3554,15 @@ ${amount} voor ${merchant} - ${date}`, `Verbind${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '' : 'naar'} [${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'jouw' : ''} ${integrationName}](${workspaceAccountingLink})`, description: ({integrationName, workspaceAccountingLink}) => Str.dedent(` - Verbind ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'jouw' : 'naar'} ${integrationName} voor automatische kosten­codering en synchronisatie, zodat de maandafsluiting een fluitje van een cent wordt. + Verbind ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'je' : 'naar'} ${integrationName} voor automatische onkostencodering en -synchronisatie, zodat de maandafsluiting moeiteloos gaat. 1. Klik op *Workspaces*. 2. Selecteer je workspace. - 3. Klik op *Accounting*. + 3. Klik op *Connections*. 4. Zoek ${integrationName}. 5. Klik op *Connect*. - [Breng me naar boekhouding](${workspaceAccountingLink}). - `), + [Ga naar verbindingen](${workspaceAccountingLink}).`), }, connectCorporateCardTask: { title: ({corporateCardLink}) => `Verbind [je zakelijke kaarten](${corporateCardLink})`, @@ -4865,6 +4864,7 @@ ${amount} voor ${merchant} - ${date}`, readOnlyActionTitle: 'Niet zo snel...', readOnlyActionPrompt: 'Je rol in de workspace kan deze instellingen bekijken, maar niet bewerken.', noAccessActionPrompt: 'Je rol in deze workspace heeft geen toegang tot deze instellingen. Vraag een admin als je die nodig hebt.', + connections: 'Verbindingen', }, createdForClient: { title: 'Je hebt een werkruimte voor je klant aangemaakt!', @@ -6440,10 +6440,6 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ title: 'Organiseren', subtitle: 'Groepeer en analyseer uitgaven, leg elke betaalde belasting vast.', }, - integrateSection: { - title: 'Integreren', - subtitle: 'Koppel Expensify aan populaire financiële producten.', - }, distanceRates: { title: 'Kilometertarieven', subtitle: 'Tarieven toevoegen, bijwerken en afdwingen.', @@ -6667,27 +6663,18 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ }, connections: { title: 'Boekhouding', - subtitle: 'Synchroniseer je grootboekrekeningschema en meer.', - }, - mcp: { - title: 'MCP', - subtitle: 'Verbind een AI-assistent met je Expensify-account.', }, receiptPartners: { title: 'Bonpartners', - subtitle: 'Automatisch bonnetjes importeren.', }, connectionsWarningModal: { featureEnabledTitle: 'Niet zo snel...', featureEnabledText: 'Om deze functie in of uit te schakelen, moet je je instellingen voor boekhoudkundige import wijzigen.', - disconnectText: 'Om de boekhouding uit te schakelen, moet je de boekhoudkoppeling van je werkruimte loskoppelen.', manageSettings: 'Instellingen beheren', }, receiptPartnersWarningModal: { featureEnabledTitle: 'Verbinding met Uber verbreken', - disconnectText: 'Om deze functie uit te schakelen, verbreek eerst de koppeling met de Uber for Business-integratie.', description: 'Weet je zeker dat je deze integratie wilt ontkoppelen?', - confirmText: 'Begrepen', }, workflowWarningModal: { featureEnabledTitle: 'Niet zo snel...', @@ -6704,14 +6691,12 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ subtitle: 'Stel een factureerbaar uurtarief in voor tijdregistratie.', defaultHourlyRate: 'Standaard uurtarief', }, - hrWarningModal: {disconnectText: ({integration}: {integration: string}) => `Om HR uit te schakelen, koppel eerst ${integration} los van deze workspace.`}, vendors: { title: 'Leveranciers', subtitle: 'Koppel kaartuitgaven aan leveranciers die zijn geïmporteerd uit je boekhoudsoftware.', disabledTitle: 'Niet zo snel...', disabledMessage: 'Om deze functie in of uit te schakelen, moet je je boekhoudimportinstellingen wijzigen.', }, - recruitingWarningModal: {disconnectText: ({integration}: {integration: string}) => `Om Werving uit te schakelen, koppel ${integration} eerst los van deze workspace.`}, }, reports: { reportsCustomTitleExamples: 'Voorbeelden:', @@ -7224,7 +7209,6 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, disconnect: 'Verbinding verbreken', reinstall: 'Connector opnieuw installeren', disconnectTitle: (connectionName = 'integratie') => `Verbinding met ${connectionName} verbreken`, - connectTitle: (connectionName) => `Verbind ${connectionName}`, syncError: (connectionName) => `Kan geen verbinding maken met ${connectionName}`, accounts: 'Rekeningschema', taxes: 'Belastingen', @@ -7241,12 +7225,10 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'Standaard NetSuite-medewerker', }, disconnectPrompt: (connectionName = 'deze integratie') => `Weet je zeker dat je ${connectionName} wilt ontkoppelen?`, - connectPrompt: (connectionName) => `Weet je zeker dat je ${connectionName} wilt koppelen? Hierdoor worden alle bestaande boekhoudkundige koppelingen verwijderd.`, enterCredentials: 'Voer je inloggegevens in', reconnect: 'Opnieuw verbinden', updateCredentials: 'Inloggegevens bijwerken', claimOffer: { - badgeText: 'Aanbieding beschikbaar!', xero: { headline: 'Krijg Xero 6 maanden gratis!', description: 'Nieuw bij Xero? Expensify-klanten krijgen 6 maanden gratis. Claim jouw aanbieding hieronder.', @@ -8631,11 +8613,6 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, hr: { title: 'HR', - connectionsSubtitle: - 'Verbind met je HR-systeem om werknemersgegevens te synchroniseren, terugbetalingen automatisch aan de juiste personen te koppelen en de uitgaven van je team nauwkeurig te houden zonder handmatig werk.', - subtitle: 'Koppel HR-tools en houd goedkeuringen van medewerkers gesynchroniseerd.', - alreadyConnectedTitle: 'Kan niet met meerdere HR-platformen verbinden', - alreadyConnectedPrompt: 'Je moet je huidige HR-platform loskoppelen voordat je een ander kunt verbinden.', connectionDescription: (providerName: string) => `Verbind ${providerName} om goedkeuringen van werknemers gesynchroniseerd te houden met je werkruimte.`, providerApprovalMode: (providerName: string) => `${providerName}-goedkeuringsmodus`, providerFinalApprover: (providerName: string) => `Laatste ${providerName}-fiatteur`, @@ -8687,14 +8664,9 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, recruiting: { title: 'Werving', - connectionsSubtitle: - 'Koppel je recruitmentsysteem om kandidaatgegevens te synchroniseren, vergoedingen automatisch aan de juiste personen te koppelen en de uitgaven van je team nauwkeurig te houden zonder handmatig werk.', - alreadyConnectedTitle: 'Kan niet met meerdere ATS-platformen verbinden', - alreadyConnectedPrompt: 'Je moet je huidige ATS loskoppelen voordat je een andere verbindt.', syncing: 'Kandidaten synchroniseren', setupIncomplete: (setupLink: string | undefined) => `Verbonden. ${setupLink ? `Installatie voltooien` : 'Voltooi installatie'} om kandidaten te importeren.`, - dontSeeYourATS: `Zie je je ATS hier niet? Vraag het aan Concierge en we kunnen het toevoegen.`, importSettings: 'Importinstellingen', defaultApprover: 'Standaardgoedkeurder', approverFields: {recruiter: 'Recruiter', recruitingCoordinator: 'Coördinator werving'}, @@ -8720,7 +8692,6 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, enableJobStagesOrTags: 'Schakel functiestadia of labels in om door te gaan', }, - subtitle: 'Koppel wervingstools en houd kandidaategoedkeuringen gesynchroniseerd.', syncResults: { importedCount: () => ({ one: '1 kandidaat', @@ -8739,9 +8710,6 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, }, merge: { - connections: 'Verbindingen', - connect: 'Verbinden', - findIntegration: 'Integratie zoeken', syncNow: 'Nu synchroniseren', disconnect: 'Ontkoppelen', disconnectTitle: (providerName: string) => `${providerName} ontkoppelen`, @@ -8928,6 +8896,32 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, noPaymentMethodsFound: 'Geen betalingswijzen gevonden', noPaymentMethodsFoundDescription: 'Voeg betalingswijzen toe in Dynamics 365 Business Central en synchroniseer de verbinding opnieuw', }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Alles', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Boekhouding', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'HR & People', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Werving', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Bonnetjes', + [CONST.TAB.CONNECTIONS.AI]: 'AI & MCP', + }, + findConnections: 'Verbindingen zoeken', + configure: 'Configureren', + fix: 'Oplossen', + active: 'Actief', + broken: 'Defect', + brokenConnection: 'Kan niet synchroniseren vanwege een verbroken verbinding', + replaceConnectionTitle: 'Verbinding vervangen?', + offer: 'Aanbieding', + replaceConnectionPrompt: (connectionName: string) => `Hiermee verwijder je je huidige ${connectionName}-verbinding.`, + hrisListing: (providerName: string) => `${providerName} (HRIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: 'Zie je de jouwe niet? Stel een integratie voor, dan kijken we ernaar.', + noResultsPrompt: 'Pas je zoekopdracht aan of', + suggestAnIntegration: 'stel een integratie voor', + allConnectedTitle: 'Je bent helemaal verbonden', + allConnectedPrompt: 'Alles hier is al verbonden. Je kunt ook', + }, }, getAssistancePage: { title: 'Hulp krijgen', @@ -11287,6 +11281,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, markAllAsRead: 'Klik met de rechtermuisknop om alles als gelezen te markeren.', markAllAsReadTouchScreen: 'Houd ingedrukt om alles als gelezen te markeren.', accountMovedToTopBar: 'Ga naar je account en persoonlijke instellingen.', + connectionsMoved: 'Al je connecties op één plek', }, discardChangesConfirmation: { title: 'Wijzigingen negeren?', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index f089dae13a68..3dae273a0bc0 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3587,13 +3587,12 @@ ${amount} dla ${merchant} - ${date}`, Połącz ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'twój' : 'do'} ${integrationName}, aby automatycznie kategoryzować wydatki i synchronizować dane, co znacznie ułatwi zamknięcie miesiąca. 1. Kliknij *Workspaces*. - 2. Wybierz swoją przestrzeń roboczą. - 3. Kliknij *Accounting*. + 2. Wybierz swoje miejsce pracy. + 3. Kliknij *Connections*. 4. Znajdź ${integrationName}. 5. Kliknij *Connect*. - [Przejdź do księgowości](${workspaceAccountingLink}). - `), + [Przejdź do połączeń](${workspaceAccountingLink}).`), }, connectCorporateCardTask: { title: ({corporateCardLink}) => `Połącz [swoje karty firmowe](${corporateCardLink})`, @@ -4893,6 +4892,7 @@ ${amount} dla ${merchant} - ${date}`, readOnlyActionTitle: 'Nie tak szybko…', readOnlyActionPrompt: 'Twoja rola w przestrzeni roboczej może wyświetlać te ustawienia, ale nie może ich edytować.', noAccessActionPrompt: 'Twoja rola w tym zespole nie ma dostępu do tych ustawień. Jeśli go potrzebujesz, poproś administratora.', + connections: 'Połączenia', }, createdForClient: { title: 'Utworzyłeś przestrzeń roboczą dla swojego klienta!', @@ -6463,10 +6463,6 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy title: 'Organizuj', subtitle: 'Grupuj i analizuj wydatki, zapisuj każdy zapłacony podatek.', }, - integrateSection: { - title: 'Integruj', - subtitle: 'Połącz Expensify z popularnymi produktami finansowymi.', - }, distanceRates: { title: 'Stawki za dystans', subtitle: 'Dodawaj, aktualizuj i egzekwuj stawki.', @@ -6692,27 +6688,18 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy }, connections: { title: 'Księgowość', - subtitle: 'Synchronizuj swój plan kont i więcej.', - }, - mcp: { - title: 'MCP', - subtitle: 'Połącz asystenta AI ze swoim kontem Expensify.', }, receiptPartners: { title: 'Partnerzy paragonów', - subtitle: 'Automatycznie importuj paragony.', }, connectionsWarningModal: { featureEnabledTitle: 'Nie tak szybko...', featureEnabledText: 'Aby włączyć lub wyłączyć tę funkcję, musisz zmienić ustawienia importu księgowego.', - disconnectText: 'Aby wyłączyć księgowość, musisz odłączyć połączenie księgowe od swojego obszaru roboczego.', manageSettings: 'Zarządzaj ustawieniami', }, receiptPartnersWarningModal: { featureEnabledTitle: 'Odłącz Uber', - disconnectText: 'Aby wyłączyć tę funkcję, najpierw odłącz integrację Uber for Business.', description: 'Czy na pewno chcesz odłączyć tę integrację?', - confirmText: 'Rozumiem', }, workflowWarningModal: { featureEnabledTitle: 'Nie tak szybko...', @@ -6729,14 +6716,12 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy subtitle: 'Ustaw godzinową stawkę rozliczeniową do śledzenia czasu.', defaultHourlyRate: 'Domyślna stawka godzinowa', }, - hrWarningModal: {disconnectText: ({integration}: {integration: string}) => `Aby wyłączyć HR, najpierw odłącz ${integration} od tego workspace’a.`}, vendors: { title: 'Dostawcy', subtitle: 'Dopasuj wydatki z karty do kontrahentów zaimportowanych z twojego oprogramowania księgowego.', disabledTitle: 'Nie tak szybko...', disabledMessage: 'Aby włączyć lub wyłączyć tę funkcję, musisz zmienić ustawienia importu księgowego.', }, - recruitingWarningModal: {disconnectText: ({integration}: {integration: string}) => `Aby wyłączyć Rekrutację, najpierw odłącz ${integration} od tego workspace.`}, }, reports: { reportsCustomTitleExamples: 'Przykłady:', @@ -7249,7 +7234,6 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, disconnect: 'Rozłącz', reinstall: 'Zainstaluj ponownie konektor', disconnectTitle: (connectionName = 'integracja') => `Odłącz ${connectionName}`, - connectTitle: (connectionName) => `Połącz ${connectionName}`, syncError: (connectionName) => `Nie można połączyć się z ${connectionName}`, accounts: 'Plan kont', taxes: 'Podatki', @@ -7266,12 +7250,10 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'Domyślny pracownik NetSuite', }, disconnectPrompt: (connectionName = 'ta integracja') => `Czy na pewno chcesz odłączyć ${connectionName}?`, - connectPrompt: (connectionName) => `Czy na pewno chcesz połączyć ${connectionName}? Spowoduje to usunięcie wszystkich istniejących połączeń księgowych.`, enterCredentials: 'Wprowadź swoje dane logowania', reconnect: 'Połącz ponownie', updateCredentials: 'Zaktualizuj dane logowania', claimOffer: { - badgeText: 'Oferta dostępna!', xero: { headline: 'Korzystaj z Xero za darmo przez 6 miesięcy!', description: 'Nowy w Xero? Klienci Expensify otrzymują 6 miesięcy za darmo. Odbierz swoją ofertę poniżej.', @@ -8651,11 +8633,6 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, hr: { title: 'HR', - connectionsSubtitle: - 'Połącz się ze swoim systemem HR, aby synchronizować dane pracowników, automatycznie dopasowywać zwroty do właściwych osób i utrzymywać wydatki zespołu w porządku bez ręcznej pracy.', - subtitle: 'Połącz narzędzia HR i utrzymuj zgody pracowników w synchronizacji.', - alreadyConnectedTitle: 'Nie można połączyć się z wieloma platformami HR', - alreadyConnectedPrompt: 'Musisz odłączyć swoją obecną platformę HR, zanim podłączysz inną.', connectionDescription: (providerName: string) => `Połącz ${providerName}, aby synchronizować akceptacje pracowników z Twoim miejscem pracy.`, providerApprovalMode: (providerName: string) => `Tryb zatwierdzania ${providerName}`, providerFinalApprover: (providerName: string) => `Ostateczny zatwierdzający ${providerName}`, @@ -8707,14 +8684,9 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, recruiting: { title: 'Rekrutacja', - connectionsSubtitle: - 'Połącz się ze swoim systemem rekrutacyjnym, aby synchronizować dane kandydatów, automatycznie dopasowywać zwroty kosztów do właściwych osób i utrzymywać poprawność wydatków zespołu bez ręcznej pracy.', - alreadyConnectedTitle: 'Nie można połączyć się z wieloma platformami ATS', - alreadyConnectedPrompt: 'Musisz odłączyć swój obecny ATS, zanim podłączysz inny.', syncing: 'Synchronizowanie kandydatów', setupIncomplete: (setupLink: string | undefined) => `Połączono. ${setupLink ? `Dokończ konfigurację` : 'Zakończ konfigurację'}, aby zaimportować kandydatów.`, - dontSeeYourATS: `Nie widzisz tutaj swojego ATS? Zapytaj Concierge, a my możemy go dodać.`, importSettings: 'Ustawienia importu', defaultApprover: 'Domyślny zatwierdzający', approverFields: {recruiter: 'Rekruter', recruitingCoordinator: 'Koordynator rekrutacji'}, @@ -8735,7 +8707,6 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, offices: {title: 'Biuro', description: 'Wybierz biura kandydatów, które chcesz zsynchronizować z tym miejscem pracy', toggleTitle: 'Biura', allSelected: 'Wszystkie biura'}, enableJobStagesOrTags: 'Włącz etapy zleceń lub tagi, aby kontynuować', }, - subtitle: 'Połącz narzędzia rekrutacyjne i utrzymuj zgody kandydatów w synchronizacji.', syncResults: { importedCount: () => ({ one: '1 kandydat', @@ -8754,9 +8725,6 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, }, merge: { - connections: 'Połączenia', - connect: 'Połącz', - findIntegration: 'Znajdź integrację', syncNow: 'Synchronizuj teraz', disconnect: 'Odłącz', disconnectTitle: (providerName: string) => `Odłącz ${providerName}`, @@ -8943,6 +8911,32 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, noPaymentMethodsFound: 'Nie znaleziono metod płatności', noPaymentMethodsFoundDescription: 'Dodaj metody płatności w Dynamics 365 Business Central i ponownie zsynchronizuj połączenie', }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Wszystko', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Księgowość', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'HR i ludzie', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Rekrutacja', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Paragony', + [CONST.TAB.CONNECTIONS.AI]: 'AI i MCP', + }, + findConnections: 'Znajdź połączenia', + configure: 'Skonfiguruj', + fix: 'Napraw', + active: 'Aktywne', + broken: 'Uszkodzone', + brokenConnection: 'Nie można zsynchronizować z powodu przerwanego połączenia', + replaceConnectionTitle: 'Zastąpić połączenie?', + offer: 'Oferta', + replaceConnectionPrompt: (connectionName: string) => `To usunie twoje bieżące połączenie ${connectionName}.`, + hrisListing: (providerName: string) => `${providerName} (HRIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: 'Nie widzisz swojej? Zaproponuj integrację, przyjrzymy się temu.', + noResultsPrompt: 'Dostosuj wyszukiwanie lub', + suggestAnIntegration: 'zaproponuj integrację', + allConnectedTitle: 'Wszystko połączone', + allConnectedPrompt: 'Wszystko tutaj jest już połączone. Możesz też', + }, }, getAssistancePage: { title: 'Uzyskaj pomoc', @@ -11303,6 +11297,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, markAllAsRead: 'Kliknij prawym przyciskiem myszy, aby oznaczyć wszystko jako przeczytane.', markAllAsReadTouchScreen: 'Przytrzymaj, aby oznaczyć wszystko jako przeczytane.', accountMovedToTopBar: 'Uzyskaj dostęp do swojego konta i ustawień osobistych.', + connectionsMoved: 'Wszystkie twoje połączenia w jednym miejscu', }, discardChangesConfirmation: { title: 'Odrzucić zmiany?', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 90b99df062ba..d9ba86cbac21 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3546,12 +3546,11 @@ ${amount} para ${merchant} - ${date}`, 1. Clique em *Workspaces*. 2. Selecione seu workspace. - 3. Clique em *Accounting*. + 3. Clique em *Connections*. 4. Encontre ${integrationName}. 5. Clique em *Connect*. - [Leve-me para a contabilidade](${workspaceAccountingLink}). - `), + [Ir para conexões](${workspaceAccountingLink}).`), }, connectCorporateCardTask: { title: ({corporateCardLink}) => `Conecte [seus cartões corporativos](${corporateCardLink})`, @@ -4856,6 +4855,7 @@ ${amount} para ${merchant} - ${date}`, readOnlyActionTitle: 'Calma aí...', readOnlyActionPrompt: 'Sua função no workspace pode ver essas configurações, mas não pode editá-las.', noAccessActionPrompt: 'Sua função no espaço de trabalho não tem acesso a essas configurações. Peça a um administrador se você precisar disso.', + connections: 'Conexões', }, createdForClient: { title: 'Você criou um espaço de trabalho para seu cliente!', @@ -6438,10 +6438,6 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS title: 'Organizar', subtitle: 'Agrupe e analise os gastos, registre todos os impostos pagos.', }, - integrateSection: { - title: 'Integrar', - subtitle: 'Conecte o Expensify a produtos financeiros populares.', - }, distanceRates: { title: 'Taxas de distância', subtitle: 'Adicione, atualize e aplique tarifas.', @@ -6669,27 +6665,18 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS }, connections: { title: 'Contabilidade', - subtitle: 'Sincronize seu plano de contas e muito mais.', - }, - mcp: { - title: 'MCP', - subtitle: 'Conecte um assistente de IA à sua conta do Expensify.', }, receiptPartners: { title: 'Parceiros de recibos', - subtitle: 'Importe recibos automaticamente.', }, connectionsWarningModal: { featureEnabledTitle: 'Não tão rápido...', featureEnabledText: 'Para ativar ou desativar este recurso, você precisará alterar suas configurações de importação contábil.', - disconnectText: 'Para desativar a contabilidade, você precisará desconectar sua conexão contábil do seu workspace.', manageSettings: 'Gerenciar configurações', }, receiptPartnersWarningModal: { featureEnabledTitle: 'Desconectar Uber', - disconnectText: 'Para desativar este recurso, desconecte primeiro a integração do Uber for Business.', description: 'Tem certeza de que deseja desconectar esta integração?', - confirmText: 'Entendi', }, workflowWarningModal: { featureEnabledTitle: 'Não tão rápido...', @@ -6706,14 +6693,12 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS subtitle: 'Defina uma taxa horária faturável para o controle de tempo.', defaultHourlyRate: 'Taxa horária padrão', }, - hrWarningModal: {disconnectText: ({integration}: {integration: string}) => `Para desativar RH, desconecte primeiro a integração ${integration} deste workspace.`}, vendors: { title: 'Fornecedores', subtitle: 'Combine as despesas do cartão com fornecedores importados do seu software de contabilidade.', disabledTitle: 'Calma lá...', disabledMessage: 'Para ativar ou desativar este recurso, você precisará alterar suas configurações de importação contábil.', }, - recruitingWarningModal: {disconnectText: ({integration}: {integration: string}) => `Para desativar Recrutamento, primeiro desconecte ${integration} deste workspace.`}, }, reports: { reportsCustomTitleExamples: 'Exemplos:', @@ -7226,7 +7211,6 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, disconnect: 'Desconectar', reinstall: 'Reinstalar conector', disconnectTitle: (connectionName = 'integração') => `Desconectar ${connectionName}`, - connectTitle: (connectionName) => `Conectar ${connectionName}`, syncError: (connectionName) => `Não é possível conectar ao ${connectionName}`, accounts: 'Plano de contas', taxes: 'Impostos', @@ -7243,12 +7227,10 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'Padrão de funcionário do NetSuite', }, disconnectPrompt: (connectionName = 'esta integração') => `Tem certeza de que deseja desconectar ${connectionName}?`, - connectPrompt: (connectionName) => `Tem certeza de que deseja conectar ${connectionName}? Isso removerá quaisquer conexões contábeis existentes.`, enterCredentials: 'Insira suas credenciais', reconnect: 'Reconectar', updateCredentials: 'Atualizar credenciais', claimOffer: { - badgeText: 'Oferta disponível!', xero: { headline: 'Use Xero grátis por 6 meses!', description: 'Novo no Xero? Clientes Expensify ganham 6 meses grátis. Resgate sua oferta abaixo.', @@ -8634,11 +8616,6 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, hr: { title: 'RH', - connectionsSubtitle: - 'Conecte-se ao seu sistema de RH para sincronizar os dados de funcionários, associar automaticamente os reembolsos às pessoas corretas e manter as despesas da sua equipe precisas, sem trabalho manual.', - subtitle: 'Conecte ferramentas de RH e mantenha as aprovações de funcionários em sincronia.', - alreadyConnectedTitle: 'Não é possível conectar a várias plataformas de RH', - alreadyConnectedPrompt: 'Você precisa desconectar sua plataforma de RH atual antes de conectar outra.', connectionDescription: (providerName: string) => `Conecte ${providerName} para manter as aprovações de funcionários sincronizadas com seu workspace.`, providerApprovalMode: (providerName: string) => `Modo de aprovação do ${providerName}`, providerFinalApprover: (providerName: string) => `Aprovador final de ${providerName}`, @@ -8690,14 +8667,9 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, recruiting: { title: 'Recrutamento', - connectionsSubtitle: - 'Conecte-se ao seu sistema de recrutamento para sincronizar os dados de candidatos, associar automaticamente os reembolsos às pessoas certas e manter as despesas da sua equipe precisas sem trabalho manual.', - alreadyConnectedTitle: 'Não é possível conectar a várias plataformas de ATS', - alreadyConnectedPrompt: 'Você precisa desconectar o seu ATS atual antes de conectar outro.', syncing: 'Sincronizando candidatos', setupIncomplete: (setupLink: string | undefined) => `Conectado. ${setupLink ? `Concluir configuração` : 'Concluir configuração'} para importar candidatos.`, - dontSeeYourATS: `Não está vendo seu ATS aqui? Pergunte ao Concierge e podemos adicioná-lo.`, importSettings: 'Importar configurações', defaultApprover: 'Aprovador padrão', approverFields: {recruiter: 'Recrutador', recruitingCoordinator: 'Coordenador de recrutamento'}, @@ -8723,7 +8695,6 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, enableJobStagesOrTags: 'Ative Estágios de Trabalho ou Tags para continuar', }, - subtitle: 'Conecte ferramentas de recrutamento e mantenha as aprovações de candidatos em sincronia.', syncResults: { importedCount: () => ({ one: '1 candidato', @@ -8742,9 +8713,6 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, }, merge: { - connections: 'Conexões', - connect: 'Conectar', - findIntegration: 'Encontrar integração', syncNow: 'Sincronizar agora', disconnect: 'Desconectar', disconnectTitle: (providerName: string) => `Desconectar ${providerName}`, @@ -8934,6 +8902,32 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, noPaymentMethodsFound: 'Nenhuma forma de pagamento encontrada', noPaymentMethodsFoundDescription: 'Adicione formas de pagamento no Dynamics 365 Business Central e sincronize a conexão novamente', }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Tudo', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Contabilidade', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'RH e Pessoas', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Recrutamento', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Recibos', + [CONST.TAB.CONNECTIONS.AI]: 'IA e MCP', + }, + findConnections: 'Encontrar conexões', + configure: 'Configurar', + fix: 'Corrigir', + active: 'Ativo', + broken: 'Quebrado', + brokenConnection: 'Não é possível sincronizar devido à conexão interrompida', + replaceConnectionTitle: 'Substituir conexão?', + offer: 'Oferta', + replaceConnectionPrompt: (connectionName: string) => `Isso vai remover sua conexão atual com ${connectionName}.`, + hrisListing: (providerName: string) => `${providerName} (RHIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: 'Não encontrou a sua? Sugira uma integração e nós vamos analisar.', + noResultsPrompt: 'Ajuste sua pesquisa ou', + suggestAnIntegration: 'sugerir uma integração', + allConnectedTitle: 'Tudo conectado', + allConnectedPrompt: 'Tudo aqui já está conectado. Você também pode', + }, }, getAssistancePage: { title: 'Obter ajuda', @@ -11280,6 +11274,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, markAllAsRead: 'Clique com o botão direito para marcar tudo como lido.', markAllAsReadTouchScreen: 'Mantenha pressionado para marcar tudo como lido.', accountMovedToTopBar: 'Acesse sua conta e configurações pessoais.', + connectionsMoved: 'Todas as suas conexões em um só lugar', }, discardChangesConfirmation: { title: 'Descartar alterações?', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 3b9bfcea6fbb..06093035dc3f 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3436,16 +3436,15 @@ ${amount},商户:${merchant} - 日期:${date}`, `连接${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '' : '到'} [${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '你的' : ''} ${integrationName}](${workspaceAccountingLink})`, description: ({integrationName, workspaceAccountingLink}) => Str.dedent(` - 连接 ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '你的' : '至'} ${integrationName},实现自动费用编码与同步,让月末结账轻松无比。 + 连接 ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '你的' : '到'} ${integrationName},实现自动费用编码与同步,让月末结算轻松无忧。 1. 点击 *Workspaces*。 - 2. 选择您的工作区。 - 3. 点击 *Accounting*。 + 2. 选择你的工作区。 + 3. 点击 *Connections*。 4. 找到 ${integrationName}。 5. 点击 *Connect*。 - [带我前往会计页面](${workspaceAccountingLink})。 - `), + [前往连接页面](${workspaceAccountingLink})。`), }, connectCorporateCardTask: { title: ({corporateCardLink}) => `连接[您的公司卡](${corporateCardLink})`, @@ -4713,6 +4712,7 @@ ${amount},商户:${merchant} - 日期:${date}`, readOnlyActionTitle: '别急……', readOnlyActionPrompt: '你的工作区角色可以查看这些设置,但不能编辑。', noAccessActionPrompt: '您的工作区角色无权访问这些设置。如需访问,请联系管理员。', + connections: '连接', }, createdForClient: { title: '您已为客户创建了工作区!', @@ -6230,10 +6230,6 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM title: '整理', subtitle: '分组并分析支出,记录每一笔已缴税款。', }, - integrateSection: { - title: '集成', - subtitle: '将 Expensify 连接到常用的金融产品。', - }, distanceRates: { title: '距离费率', subtitle: '添加、更新并执行费率。', @@ -6433,27 +6429,18 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM }, connections: { title: '会计', - subtitle: '同步您的科目表等数据。', - }, - mcp: { - title: 'MCP', - subtitle: '将 AI 助手连接到您的 Expensify 账户。', }, receiptPartners: { title: '收据合作伙伴', - subtitle: '自动导入收据。', }, connectionsWarningModal: { featureEnabledTitle: '别急……', featureEnabledText: '若要启用或停用此功能,您需要更改会计导入设置。', - disconnectText: '要停用会计功能,您需要从工作区中断开您的会计连接。', manageSettings: '管理设置', }, receiptPartnersWarningModal: { featureEnabledTitle: '断开与 Uber 的连接', - disconnectText: '若要禁用此功能,请先断开 Uber for Business 集成。', description: '确定要断开与此集成的连接吗?', - confirmText: '明白了', }, workflowWarningModal: { featureEnabledTitle: '别急……', @@ -6469,14 +6456,12 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM subtitle: '为时间跟踪设置可计费的小时费率。', defaultHourlyRate: '默认时薪', }, - hrWarningModal: {disconnectText: ({integration}: {integration: string}) => `若要禁用人力资源功能,请先将此工作区与 ${integration} 断开连接。`}, vendors: { title: '供应商', subtitle: '将卡片报销与从您的会计软件导入的供应商进行匹配。', disabledTitle: '别急...', disabledMessage: '要启用或禁用此功能,您需要更改会计导入设置。', }, - recruitingWarningModal: {disconnectText: ({integration}: {integration: string}) => `若要停用招聘功能,请先将此工作区与 ${integration} 断开连接。`}, }, reports: { reportsCustomTitleExamples: '示例:', @@ -6974,7 +6959,6 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM disconnect: '断开连接', reinstall: '重新安装连接器', disconnectTitle: (connectionName = '集成') => `断开连接 ${connectionName}`, - connectTitle: (connectionName) => `连接 ${connectionName}`, syncError: (connectionName) => `无法连接到 ${connectionName}`, accounts: '会计科目表', taxes: '税费', @@ -6991,12 +6975,10 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'NetSuite 员工默认值', }, disconnectPrompt: (connectionName = '此集成') => `你确定要断开与 ${connectionName} 的连接吗?`, - connectPrompt: (connectionName) => `确定要连接 ${connectionName} 吗?这将删除所有现有的会计连接。`, enterCredentials: '请输入您的凭证', reconnect: '重新连接', updateCredentials: '更新凭证', claimOffer: { - badgeText: '优惠可用!', xero: { headline: '免费使用 Xero 长达 6 个月!', description: '第一次使用 Xero?Expensify 客户可享受 6 个月免费试用。请在下方领取优惠。', @@ -8324,10 +8306,6 @@ ${reportName}`, }, hr: { title: '人力资源', - connectionsSubtitle: '连接您的 HR 系统以同步员工数据,自动将报销匹配到正确的员工,并在无需手动操作的情况下保持团队报销的准确性。', - subtitle: '连接人力资源工具,保持员工审批同步。', - alreadyConnectedTitle: '无法连接到多个人力资源平台', - alreadyConnectedPrompt: '在连接其他人力资源平台之前,您必须先断开当前的人力资源平台。', connectionDescription: (providerName: string) => `连接 ${providerName},以在您的工作区中同步员工审批。`, providerApprovalMode: (providerName: string) => `${providerName} 审批模式`, providerFinalApprover: (providerName: string) => `${providerName} 最终审批人`, @@ -8379,13 +8357,9 @@ ${reportName}`, }, recruiting: { title: '招聘', - connectionsSubtitle: '连接招聘系统以同步候选人数据,自动将报销匹配到正确的人员,并在无需手动操作的情况下保持团队报销数据的准确。', - alreadyConnectedTitle: '无法连接到多个 ATS 平台', - alreadyConnectedPrompt: '在连接其他 ATS 之前,必须先断开当前的 ATS。', syncing: '正在同步候选人', setupIncomplete: (setupLink: string | undefined) => `已连接。${setupLink ? `完成设置` : '完成设置'} 个可导入候选项。`, - dontSeeYourATS: `没有看到你的 ATS?询问 Concierge,我们可以帮你添加。`, importSettings: '导入设置', defaultApprover: '默认审批人', approverFields: {recruiter: '招聘人员', recruitingCoordinator: '招聘协调员'}, @@ -8401,7 +8375,6 @@ ${reportName}`, offices: {title: '办公室', description: '选择你想与此工作区同步的候选人办公地点', toggleTitle: '办公室', allSelected: '所有办公室'}, enableJobStagesOrTags: '启用职位阶段或标签以继续', }, - subtitle: '连接招聘工具并保持候选人审批同步。', syncResults: { importedCount: () => ({ one: '1 名候选人', @@ -8416,9 +8389,6 @@ ${reportName}`, approvalModeDescriptions: {basic: '选择单个审批人', advanced: `候选人的招聘人员或协调人将成为其报销审批人`, custom: '在 Expensify 中手动设置审批人'}, }, merge: { - connections: '连接', - connect: '连接', - findIntegration: '查找集成', syncNow: '立即同步', disconnect: '断开连接', disconnectTitle: (providerName: string) => `断开 ${providerName}`, @@ -8598,6 +8568,32 @@ ${reportName}`, noPaymentMethodsFound: '未找到付款方式', noPaymentMethodsFoundDescription: '请在 Dynamics 365 Business Central 中添加付款方式,然后重新同步连接', }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: '全部', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: '会计', + [CONST.TAB.CONNECTIONS.PEOPLE]: '人力资源与员工', + [CONST.TAB.CONNECTIONS.RECRUITING]: '招聘', + [CONST.TAB.CONNECTIONS.RECEIPTS]: '收据', + [CONST.TAB.CONNECTIONS.AI]: 'AI 和 MCP', + }, + findConnections: '查找关联', + configure: '配置', + fix: '修复', + active: '有效', + broken: '损坏', + brokenConnection: '由于连接中断,无法同步', + replaceConnectionTitle: '替换连接?', + offer: '优惠', + replaceConnectionPrompt: (connectionName: string) => `这将移除你当前的 ${connectionName} 连接。`, + hrisListing: (providerName: string) => `${providerName}(HRIS)`, + atsListing: (providerName: string) => `${providerName}(ATS)`, + suggestIntegration: '没找到适合你的?建议一个集成,我们会进行评估。', + noResultsPrompt: '请调整您的搜索条件或', + suggestAnIntegration: '建议一个集成', + allConnectedTitle: '全部已连接', + allConnectedPrompt: '这里的一切都已连接完成。您也可以', + }, }, getAssistancePage: { title: '获取帮助', @@ -10875,6 +10871,7 @@ ${reportName}`, markAllAsRead: '右键点击即可将所有内容标记为已读。', markAllAsReadTouchScreen: '长按即可将所有内容标记为已读。', accountMovedToTopBar: '访问你的账户和个人设置。', + connectionsMoved: '所有连接尽在一处', }, discardChangesConfirmation: { title: '放弃更改?', From 01c378773e09afa04ae2f26fb5a6df3e905812dc Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 6 Oct 2026 01:32:41 +0530 Subject: [PATCH 36/51] Remove stale locale keys and require write access on the offer page Signed-off-by: krishna2323 --- src/languages/de.ts | 9 +-------- src/languages/el.ts | 9 +-------- src/languages/es.ts | 9 +-------- src/languages/fr.ts | 9 +-------- src/languages/it.ts | 9 +-------- src/languages/ja.ts | 9 +-------- src/languages/nl.ts | 9 +-------- src/languages/pl.ts | 9 +-------- src/languages/pt-BR.ts | 9 +-------- src/languages/zh-hans.ts | 9 +-------- src/pages/workspace/accounting/ClaimOfferPage.tsx | 1 + 11 files changed, 11 insertions(+), 80 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 505fd4e890b9..9b1575d947de 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -4900,22 +4900,14 @@ ${amount} für ${merchant} – ${date}`, description: 'Großartige Neuigkeiten 🎉. Kontaktiere uns, wenn sie Hilfe bei der Einrichtung benötigen.', }, mcp: { - connectors: 'Konnektoren', - connectorsSubtitle: 'Verbinde einen KI-Assistenten mit deinem Expensify-Konto.', - connect: 'Verbinden', - helpPrompt: 'Brauchst du Hilfe beim Verbinden?', - helpLink: 'Lies unsere Anleitung.', claude: { title: 'Claude', - subtitle: 'von Anthropic', }, cursor: { title: 'Cursor', - subtitle: 'von Anysphere', }, chatgpt: { title: 'ChatGPT', - subtitle: 'von OpenAI', }, }, receiptPartners: { @@ -8998,6 +8990,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc suggestIntegration: 'Sie sehen Ihre nicht? Schlagen Sie eine Integration vor, wir schauen sie uns an.', noResultsPrompt: 'Bitte passen Sie Ihre Suche an oder', suggestAnIntegration: 'Integration vorschlagen', + noResultsPromptEnd: '.', allConnectedTitle: 'Sie sind vollständig verbunden', allConnectedPrompt: 'Hier ist bereits alles verbunden. Sie können auch', }, diff --git a/src/languages/el.ts b/src/languages/el.ts index e0da32940dc8..77a80a8ee65d 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -4976,22 +4976,14 @@ ${amount} για ${merchant} - ${date}`, description: 'Καλά νέα 🎉. Επικοινωνήστε μαζί μας αν χρειαστούν βοήθεια με τη ρύθμιση.', }, mcp: { - connectors: 'Συνδέσεις', - connectorsSubtitle: 'Συνδέστε έναν βοηθό AI με τον λογαριασμό σας στο Expensify.', - connect: 'Σύνδεση', - helpPrompt: 'Χρειάζεστε βοήθεια με τη σύνδεση;', - helpLink: 'Διαβάστε τον οδηγό μας.', claude: { title: 'Claude', - subtitle: 'από την Anthropic', }, cursor: { title: 'Cursor', - subtitle: 'από την Anysphere', }, chatgpt: { title: 'ChatGPT', - subtitle: 'από την OpenAI', }, }, receiptPartners: { @@ -9163,6 +9155,7 @@ ${reportName}`, suggestIntegration: 'Δεν βλέπετε τη δική σας; Προτείνετε μια ενσωμάτωση και θα το εξετάσουμε.', noResultsPrompt: 'Παρακαλούμε προσαρμόστε την αναζήτησή σας ή', suggestAnIntegration: 'προτείνετε μια ενσωμάτωση', + noResultsPromptEnd: '.', allConnectedTitle: 'Έχετε συνδεθεί πλήρως', allConnectedPrompt: 'Όλα εδώ είναι ήδη συνδεδεμένα. Μπορείτε επίσης να', }, diff --git a/src/languages/es.ts b/src/languages/es.ts index ea08221d069b..f8067ebd3f96 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4830,22 +4830,14 @@ ${amount} para ${merchant} - ${date}`, description: 'Excelentes noticias 🎉. Contáctanos si necesitan ayuda con la configuración.', }, mcp: { - connectors: 'Conectores', - connectorsSubtitle: 'Conecta un asistente de IA a tu cuenta de Expensify.', - connect: 'Conectar', - helpPrompt: '¿Necesitas ayuda para conectar?', - helpLink: 'Lee nuestra guía.', claude: { title: 'Claude', - subtitle: 'de Anthropic', }, cursor: { title: 'Cursor', - subtitle: 'de Anysphere', }, chatgpt: { title: 'ChatGPT', - subtitle: 'de OpenAI', }, }, receiptPartners: { @@ -8826,6 +8818,7 @@ ${reportName}`, suggestIntegration: '¿No ves la tuya? Sugiere una integración y lo estudiaremos.', noResultsPrompt: 'Ajusta tu búsqueda o', suggestAnIntegration: 'sugiere una integración', + noResultsPromptEnd: '.', allConnectedTitle: 'Ya está todo conectado', allConnectedPrompt: 'Todo aquí ya está conectado. También puedes', }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 92950fe82d42..d53cfbd2fa0c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -4906,22 +4906,14 @@ ${amount} pour ${merchant} - ${date}`, description: "Excellente nouvelle 🎉. Contactez-nous si votre client a besoin d'aide pour la configuration.", }, mcp: { - connectors: 'Connecteurs', - connectorsSubtitle: 'Connectez un assistant IA à votre compte Expensify.', - connect: 'Connecter', - helpPrompt: "Besoin d'aide pour la connexion ?", - helpLink: 'Consultez notre guide.', claude: { title: 'Claude', - subtitle: 'par Anthropic', }, cursor: { title: 'Cursor', - subtitle: 'par Anysphere', }, chatgpt: { title: 'ChatGPT', - subtitle: 'par OpenAI', }, }, receiptPartners: { @@ -9019,6 +9011,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e suggestIntegration: 'Vous ne voyez pas la vôtre ? Suggérez une intégration, nous allons l’examiner.', noResultsPrompt: 'Veuillez modifier votre recherche ou', suggestAnIntegration: 'suggérer une intégration', + noResultsPromptEnd: '.', allConnectedTitle: 'Vous êtes entièrement connecté', allConnectedPrompt: 'Tout est déjà connecté ici. Vous pouvez aussi', }, diff --git a/src/languages/it.ts b/src/languages/it.ts index 7b8853f3bec5..f151e882ca48 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -4869,22 +4869,14 @@ ${amount} per ${merchant} - ${date}`, description: 'Ottime notizie 🎉. Contattaci se hanno bisogno di aiuto con la configurazione.', }, mcp: { - connectors: 'Connettori', - connectorsSubtitle: 'Collega un assistente IA al tuo account Expensify.', - connect: 'Connetti', - helpPrompt: 'Hai bisogno di aiuto con la connessione?', - helpLink: 'Leggi la nostra guida.', claude: { title: 'Claude', - subtitle: 'di Anthropic', }, cursor: { title: 'Cursor', - subtitle: 'di Anysphere', }, chatgpt: { title: 'ChatGPT', - subtitle: 'di OpenAI', }, }, receiptPartners: { @@ -8944,6 +8936,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, suggestIntegration: "Non vedi la tua? Suggerisci un'integrazione, la valuteremo.", noResultsPrompt: 'Modifica la tua ricerca oppure', suggestAnIntegration: "suggerisci un'integrazione", + noResultsPromptEnd: '.', allConnectedTitle: 'Sei tutto connesso', allConnectedPrompt: 'Qui è già tutto collegato. Puoi anche', }, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 74ee202aa5d1..ce05015a2d7e 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -4832,22 +4832,14 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' description: '素晴らしいニュースです 🎉。セットアップにサポートが必要な場合はお問い合わせください。', }, mcp: { - connectors: 'コネクタ', - connectorsSubtitle: 'AIアシスタントをExpensifyアカウントに接続します。', - connect: '接続', - helpPrompt: '接続にお困りですか?', - helpLink: 'ガイドをご覧ください。', claude: { title: 'Claude', - subtitle: 'Anthropic 提供', }, cursor: { title: 'Cursor', - subtitle: 'Anysphere 提供', }, chatgpt: { title: 'ChatGPT', - subtitle: 'OpenAI 提供', }, }, receiptPartners: { @@ -8822,6 +8814,7 @@ ${reportName}`, suggestIntegration: 'お探しのものが見つかりませんか?連携を提案してください、こちらで検討します。', noResultsPrompt: '検索条件を調整するか', suggestAnIntegration: '連携を提案する', + noResultsPromptEnd: '。', allConnectedTitle: 'すべて接続されました', allConnectedPrompt: 'ここにあるものはすべてすでに連携されています。さらに、次のこともできます', }, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index c79c03920229..fc36b5a69249 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -4871,22 +4871,14 @@ ${amount} voor ${merchant} - ${date}`, description: 'Geweldig nieuws 🎉. Neem contact met ons op als ze hulp nodig hebben bij de configuratie.', }, mcp: { - connectors: 'Connectoren', - connectorsSubtitle: 'Verbind een AI-assistent met je Expensify-account.', - connect: 'Verbinden', - helpPrompt: 'Hulp nodig bij het verbinden?', - helpLink: 'Lees onze handleiding.', claude: { title: 'Claude', - subtitle: 'van Anthropic', }, cursor: { title: 'Cursor', - subtitle: 'van Anysphere', }, chatgpt: { title: 'ChatGPT', - subtitle: 'van OpenAI', }, }, receiptPartners: { @@ -8919,6 +8911,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, suggestIntegration: 'Zie je de jouwe niet? Stel een integratie voor, dan kijken we ernaar.', noResultsPrompt: 'Pas je zoekopdracht aan of', suggestAnIntegration: 'stel een integratie voor', + noResultsPromptEnd: '.', allConnectedTitle: 'Je bent helemaal verbonden', allConnectedPrompt: 'Alles hier is al verbonden. Je kunt ook', }, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 3dae273a0bc0..84a2724a308d 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -4899,22 +4899,14 @@ ${amount} dla ${merchant} - ${date}`, description: 'Świetna wiadomość 🎉. Skontaktuj się z nami, jeśli potrzebują pomocy przy konfiguracji.', }, mcp: { - connectors: 'Konektory', - connectorsSubtitle: 'Połącz asystenta AI ze swoim kontem Expensify.', - connect: 'Połącz', - helpPrompt: 'Potrzebujesz pomocy z połączeniem?', - helpLink: 'Przeczytaj nasz przewodnik.', claude: { title: 'Claude', - subtitle: 'od Anthropic', }, cursor: { title: 'Cursor', - subtitle: 'od Anysphere', }, chatgpt: { title: 'ChatGPT', - subtitle: 'od OpenAI', }, }, receiptPartners: { @@ -8934,6 +8926,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, suggestIntegration: 'Nie widzisz swojej? Zaproponuj integrację, przyjrzymy się temu.', noResultsPrompt: 'Dostosuj wyszukiwanie lub', suggestAnIntegration: 'zaproponuj integrację', + noResultsPromptEnd: '.', allConnectedTitle: 'Wszystko połączone', allConnectedPrompt: 'Wszystko tutaj jest już połączone. Możesz też', }, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index d9ba86cbac21..1bdd5f53dee1 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -4862,22 +4862,14 @@ ${amount} para ${merchant} - ${date}`, description: 'Ótimas notícias 🎉. Entre em contato conosco se precisarem de ajuda com a configuração.', }, mcp: { - connectors: 'Conectores', - connectorsSubtitle: 'Conecte um assistente de IA à sua conta do Expensify.', - connect: 'Conectar', - helpPrompt: 'Precisa de ajuda para conectar?', - helpLink: 'Leia nosso guia.', claude: { title: 'Claude', - subtitle: 'da Anthropic', }, cursor: { title: 'Cursor', - subtitle: 'da Anysphere', }, chatgpt: { title: 'ChatGPT', - subtitle: 'da OpenAI', }, }, receiptPartners: { @@ -8925,6 +8917,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, suggestIntegration: 'Não encontrou a sua? Sugira uma integração e nós vamos analisar.', noResultsPrompt: 'Ajuste sua pesquisa ou', suggestAnIntegration: 'sugerir uma integração', + noResultsPromptEnd: '.', allConnectedTitle: 'Tudo conectado', allConnectedPrompt: 'Tudo aqui já está conectado. Você também pode', }, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 06093035dc3f..5c3d16a0d49f 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4719,22 +4719,14 @@ ${amount},商户:${merchant} - 日期:${date}`, description: '好消息 🎉。如果他们在设置方面需要帮助,请联系我们。', }, mcp: { - connectors: '连接器', - connectorsSubtitle: '将 AI 助手连接到您的 Expensify 账户。', - connect: '连接', - helpPrompt: '需要帮助连接吗?', - helpLink: '阅读我们的指南。', claude: { title: 'Claude', - subtitle: '由 Anthropic 提供', }, cursor: { title: 'Cursor', - subtitle: '由 Anysphere 提供', }, chatgpt: { title: 'ChatGPT', - subtitle: '由 OpenAI 提供', }, }, receiptPartners: { @@ -8591,6 +8583,7 @@ ${reportName}`, suggestIntegration: '没找到适合你的?建议一个集成,我们会进行评估。', noResultsPrompt: '请调整您的搜索条件或', suggestAnIntegration: '建议一个集成', + noResultsPromptEnd: '。', allConnectedTitle: '全部已连接', allConnectedPrompt: '这里的一切都已连接完成。您也可以', }, diff --git a/src/pages/workspace/accounting/ClaimOfferPage.tsx b/src/pages/workspace/accounting/ClaimOfferPage.tsx index 802ef74e6978..1d74bcdbb3d2 100644 --- a/src/pages/workspace/accounting/ClaimOfferPage.tsx +++ b/src/pages/workspace/accounting/ClaimOfferPage.tsx @@ -147,6 +147,7 @@ function ClaimOfferPage({route, policy}: ClaimOfferPageProps) { policyID={policyID} accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN]} policyFeature={CONST.POLICY.POLICY_FEATURE.MORE_FEATURES} + policyFeatureAccess={CONST.POLICY.POLICY_FEATURE_ACCESS.WRITE} shouldBeBlocked={!config} > Date: Tue, 6 Oct 2026 02:00:24 +0530 Subject: [PATCH 37/51] Put the unified Connections page behind the unifiedConnections beta Signed-off-by: krishna2323 --- src/CONST/index.ts | 6 + src/ROUTES.ts | 106 ++- src/SCREENS.ts | 5 + .../ConnectToSageIntacctFlow/index.tsx | 6 +- .../FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts | 5 + .../SearchRouter/useNavigationSuggestions.tsx | 15 + src/components/SearchBar/CompactSearchBar.tsx | 45 ++ src/languages/de.ts | 111 ++- src/languages/el.ts | 105 ++- src/languages/en.ts | 48 +- src/languages/es.ts | 105 ++- src/languages/fr.ts | 105 ++- src/languages/it.ts | 107 ++- src/languages/ja.ts | 105 ++- src/languages/nl.ts | 101 ++- src/languages/pl.ts | 101 ++- src/languages/pt-BR.ts | 99 ++- src/languages/zh-hans.ts | 101 ++- .../ModalStackNavigators/index.tsx | 7 +- .../Navigators/WorkspaceSplitNavigator.tsx | 5 + .../Navigation/linkingConfig/OldRoutes.ts | 2 - .../RELATIONS/WORKSPACE_TO_RHP.ts | 55 +- src/libs/Navigation/linkingConfig/config.ts | 23 +- src/libs/Navigation/types.ts | 23 +- src/libs/ReportUtils.ts | 2 +- .../hooks/useGettingStartedItems.ts | 2 +- src/pages/workspace/WorkspaceInitialPage.tsx | 15 +- .../WorkspaceMoreFeaturesPage/index.tsx | 236 +++++- .../accounting/AccountingContext/index.tsx | 46 +- .../workspace/accounting/ClaimOfferPage.tsx | 15 +- .../accounting/PolicyAccountingPage.tsx | 753 ++++++++++++++---- .../NetSuiteSetupPage/index.native.tsx | 8 +- .../netsuite/NetSuiteSetupPage/index.tsx | 2 +- .../NetSuiteExistingConnectionsPage.tsx | 10 +- .../QuickBooksDesktopSetupFlowSyncPage.tsx | 7 +- .../qbo/QuickbooksOnlineSetupPage/index.tsx | 2 +- src/pages/workspace/accounting/utils.tsx | 24 +- .../accounting/xero/XeroSetupPage/index.tsx | 2 +- .../connections/ConnectionsAccountingPage.tsx | 648 +++++++++++++++ .../connections/ConnectionsHRPage.tsx | 54 ++ .../connections/ConnectionsMergePageBase.tsx | 161 ++++ .../ConnectionsMergeProviderCard.tsx | 233 ++++++ .../ConnectionsReceiptPartnersPage.tsx | 269 +++++++ .../connections/ConnectionsRecruitingPage.tsx | 44 + .../connections/WorkspaceConnectionsPage.tsx | 3 + .../useAccountingConnectionListings.ts | 3 +- .../connections/useMergeConnectionListings.ts | 4 +- .../useReceiptPartnerConnectionListings.ts | 7 +- ...seRedirectUnconnectedPanelToConnections.ts | 6 +- src/pages/workspace/connections/utils.ts | 11 +- .../withUnifiedConnectionsBeta.tsx | 30 + src/pages/workspace/getWorkspaceMenuItems.ts | 103 ++- src/pages/workspace/hr/WorkspaceHRPage.tsx | 7 +- src/pages/workspace/mcp/WorkspaceMCPPage.tsx | 129 +++ .../merge/MergeConnectionsPageBase.tsx | 167 +++- .../workspace/merge/MergeProviderCard.tsx | 153 ++-- .../WorkspaceReceiptPartnersPage.tsx | 342 ++++++-- .../recruiting/WorkspaceRecruitingPage.tsx | 22 +- .../upgrade/WorkspaceUpgradePage.tsx | 10 +- tests/ui/WorkspaceMoreFeaturesPageTest.tsx | 99 +++ .../AccountingConnectionConfirmationTest.tsx | 99 ++- ...ctionsAccountingPageQBOTokenExpiryTest.tsx | 223 ++++++ .../PolicyAccountingPageAutoStartFlowTest.tsx | 183 +++++ ...PolicyAccountingPageQBOTokenExpiryTest.tsx | 8 +- tests/unit/SearchRouterNavigationTest.ts | 34 +- .../QuickbooksOnlineSetupPageWebTest.tsx | 2 +- .../unit/components/XeroSetupPageWebTest.tsx | 2 +- tests/unit/getWorkspaceMenuItemsTest.ts | 206 ++++- .../unit/hooks/useGettingStartedItems.test.ts | 2 +- .../GettingStartedSectionTest.tsx | 2 +- .../netsuite/NetSuiteSetupPageTest.tsx | 2 +- tests/utils/TestNavigationContainer.tsx | 2 +- 72 files changed, 4935 insertions(+), 850 deletions(-) create mode 100644 src/components/SearchBar/CompactSearchBar.tsx create mode 100644 src/pages/workspace/connections/ConnectionsAccountingPage.tsx create mode 100644 src/pages/workspace/connections/ConnectionsHRPage.tsx create mode 100644 src/pages/workspace/connections/ConnectionsMergePageBase.tsx create mode 100644 src/pages/workspace/connections/ConnectionsMergeProviderCard.tsx create mode 100644 src/pages/workspace/connections/ConnectionsReceiptPartnersPage.tsx create mode 100644 src/pages/workspace/connections/ConnectionsRecruitingPage.tsx create mode 100644 src/pages/workspace/connections/withUnifiedConnectionsBeta.tsx create mode 100644 src/pages/workspace/mcp/WorkspaceMCPPage.tsx create mode 100644 tests/unit/ConnectionsAccountingPageQBOTokenExpiryTest.tsx create mode 100644 tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 9c5b2554dbf2..2a630752fec4 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1151,6 +1151,7 @@ const CONST = { INSIGHTS_PAGE: 'insightsPage', INSIGHTS_COMPARE: 'insightsCompare', PAYMENT_HISTORY: 'paymentHistory', + UNIFIED_CONNECTIONS: 'unifiedConnections', }, BUTTON_STATES: { DEFAULT: 'default', @@ -9805,6 +9806,10 @@ const CONST = { MEMBERS: 'WorkspaceInitial-Members', ROOMS: 'WorkspaceInitial-Rooms', REPORTS: 'WorkspaceInitial-Reports', + ACCOUNTING: 'WorkspaceInitial-Accounting', + HR: 'WorkspaceInitial-HR', + RECRUITING: 'WorkspaceInitial-Recruiting', + RECEIPT_PARTNERS: 'WorkspaceInitial-ReceiptPartners', CONNECTIONS: 'WorkspaceInitial-Connections', CATEGORIES: 'WorkspaceInitial-Categories', TAGS: 'WorkspaceInitial-Tags', @@ -9820,6 +9825,7 @@ const CONST = { INVOICES: 'WorkspaceInitial-Invoices', MORE_FEATURES: 'WorkspaceInitial-MoreFeatures', VENDORS: 'WorkspaceInitial-Vendors', + MCP: 'WorkspaceInitial-MCP', }, OVERVIEW: { AVATAR: 'WorkspaceOverview-Avatar', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index c8f81f2c1844..ba0759ddcef9 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -691,11 +691,16 @@ const DYNAMIC_ROUTES = { }, SAGE_INTACCT_PREREQUISITES: { path: 'sage-intacct/prerequisites', - entryScreens: [SCREENS.WORKSPACE.CONNECTIONS, SCREENS.WORKSPACE.ACCOUNTING.EXISTING_SAGE_INTACCT_CONNECTIONS], + entryScreens: [ + SCREENS.WORKSPACE.ACCOUNTING.ROOT, + SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING, + SCREENS.WORKSPACE.CONNECTIONS, + SCREENS.WORKSPACE.ACCOUNTING.EXISTING_SAGE_INTACCT_CONNECTIONS, + ], }, POLICY_ACCOUNTING_CERTINIA_EXPORT: { path: 'certinia/export', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING], }, POLICY_ACCOUNTING_CERTINIA_PREFERRED_EXPORTER: { path: 'certinia-preferred-exporter/select', @@ -715,7 +720,7 @@ const DYNAMIC_ROUTES = { }, POLICY_ACCOUNTING_CERTINIA_ADVANCED: { path: 'certinia/advanced', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING], }, POLICY_ACCOUNTING_CERTINIA_FX_EXPENSE_ACCOUNT: { path: 'certinia-fx-expense-account/select', @@ -727,7 +732,7 @@ const DYNAMIC_ROUTES = { }, POLICY_ACCOUNTING_CERTINIA_COMPANY_SELECTOR: { path: 'certinia/company', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING], }, POLICY_ACCOUNTING_NETSUITE_EXPORT_EXPENSES_VENDOR_SELECT: { path: 'vendor/select', @@ -743,7 +748,7 @@ const DYNAMIC_ROUTES = { }, POLICY_ACCOUNTING_NETSUITE_EXPORT: { path: 'connections/netsuite/export', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING, SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT], }, POLICY_ACCOUNTING_NETSUITE_RECEIVABLE_ACCOUNT_SELECT: { path: 'receivable-account/select', @@ -796,7 +801,7 @@ const DYNAMIC_ROUTES = { }, POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT: { path: 'quickbooks-online/export', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING, SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT], }, POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_COMPANY_CARD_EXPENSE_ACCOUNT: { path: 'company-card-expense-account', @@ -812,7 +817,7 @@ const DYNAMIC_ROUTES = { }, POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT: { path: 'quickbooks-desktop/export', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING, SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT], }, POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT: { path: 'qbd-company-card-expense-account', @@ -902,7 +907,7 @@ const DYNAMIC_ROUTES = { }, POLICY_ACCOUNTING_XERO_EXPORT: { path: 'xero/export', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING, SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT], }, POLICY_ACCOUNTING_XERO_PREFERRED_EXPORTER_SELECT: { path: 'xero-preferred-exporter/select', @@ -938,7 +943,7 @@ const DYNAMIC_ROUTES = { }, WORKSPACE_ACCOUNTING_QUICKBOOKS_DESKTOP_ADVANCED: { path: 'quickbooks-desktop/advanced', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING], }, POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_FX_EXPENSE_ACCOUNT_SELECT: { path: 'qbd-fx-expense-account-select', @@ -1158,7 +1163,7 @@ const DYNAMIC_ROUTES = { }, POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT: { path: 'sage-intacct/export', - entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT], + entryScreens: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING, SCREENS.WORKSPACE.DYNAMIC_COMPANY_CARD_EXPORT], }, WORKSPACE_OVERVIEW_PLAN: { path: 'plan', @@ -1283,14 +1288,14 @@ const DYNAMIC_ROUTES = { // suffix inherits their `:policyID` (same as WORKSPACE_INVITE above). path: 'hr-sync-results', // The results screen opens automatically when an HR sync finishes, and a sync can complete - // while the user is on the HR settings, the Connections page, or the members list. - entryScreens: [SCREENS.WORKSPACE.HR, SCREENS.WORKSPACE.CONNECTIONS, SCREENS.WORKSPACE.MEMBERS], + // while the user is on the HR page, the Connections page or its HR panel, or the members list. + entryScreens: [SCREENS.WORKSPACE.HR, SCREENS.WORKSPACE.CONNECTIONS, SCREENS.WORKSPACE.CONNECTIONS_HR, SCREENS.WORKSPACE.MEMBERS], }, WORKSPACE_RECRUITING_SYNC_RESULTS: { // The results screen opens automatically when a recruiting sync finishes, and a sync can complete - // while the user is on the recruiting settings, the Connections page, or the members list. + // while the user is on the recruiting page, the Connections page or its recruiting panel, or the members list. path: 'recruiting-sync-results', - entryScreens: [SCREENS.WORKSPACE.RECRUITING, SCREENS.WORKSPACE.CONNECTIONS, SCREENS.WORKSPACE.MEMBERS], + entryScreens: [SCREENS.WORKSPACE.RECRUITING, SCREENS.WORKSPACE.CONNECTIONS, SCREENS.WORKSPACE.CONNECTIONS_RECRUITING, SCREENS.WORKSPACE.MEMBERS], }, WORKSPACE_OWNER_CHANGE_CHECK: { path: 'change-owner/:policyID/:accountID/:error', @@ -1313,12 +1318,12 @@ const DYNAMIC_ROUTES = { }, WORKSPACE_RECEIPT_PARTNERS_INVITE_EDIT: { path: ':integration/invite/edit', - entryScreens: [SCREENS.WORKSPACE.RECEIPT_PARTNERS], + entryScreens: [SCREENS.WORKSPACE.RECEIPT_PARTNERS, SCREENS.WORKSPACE.CONNECTIONS_RECEIPT_PARTNERS], getRoute: (integration: string) => `${integration}/invite/edit` as const, }, WORKSPACE_RECEIPT_PARTNERS_INVITE: { path: ':integration/invite', - entryScreens: [SCREENS.WORKSPACE.RECEIPT_PARTNERS], + entryScreens: [SCREENS.WORKSPACE.RECEIPT_PARTNERS, SCREENS.WORKSPACE.CONNECTIONS_RECEIPT_PARTNERS], getRoute: (integration: string) => `${integration}/invite` as const, }, EXPENSIFY_CARD_DETAILS: { @@ -3412,11 +3417,31 @@ const ROUTES = { }, POLICY_ACCOUNTING: { route: 'workspaces/:policyID/accounting', - getRoute: (policyID: string | undefined) => { + getRoute: ( + policyID: string | undefined, + newConnectionName?: ConnectionName, + integrationToDisconnect?: ConnectionName, + shouldDisconnectIntegrationBeforeConnecting?: boolean, + isIntuitEnterpriseSuite?: boolean, + ) => { if (!policyID) { Log.warn('Invalid policyID is used to build the POLICY_ACCOUNTING route'); } - return `workspaces/${policyID}/accounting` as const; + + let queryParams = ''; + if (newConnectionName) { + queryParams += `?newConnectionName=${newConnectionName}`; + if (integrationToDisconnect) { + queryParams += `&integrationToDisconnect=${integrationToDisconnect}`; + } + if (shouldDisconnectIntegrationBeforeConnecting !== undefined) { + queryParams += `&shouldDisconnectIntegrationBeforeConnecting=${shouldDisconnectIntegrationBeforeConnecting}`; + } + if (isIntuitEnterpriseSuite !== undefined) { + queryParams += `&isIntuitEnterpriseSuite=${isIntuitEnterpriseSuite}`; + } + } + return `workspaces/${policyID}/accounting${queryParams}` as const; }, }, WORKSPACE_CONNECTIONS: { @@ -3448,6 +3473,42 @@ const ROUTES = { return `workspaces/${policyID}/connections${queryParams}` as const; }, }, + WORKSPACE_CONNECTIONS_ACCOUNTING: { + route: 'workspaces/:policyID/connections/accounting', + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the WORKSPACE_CONNECTIONS_ACCOUNTING route'); + } + return `workspaces/${policyID}/connections/accounting` as const; + }, + }, + WORKSPACE_CONNECTIONS_HR: { + route: 'workspaces/:policyID/connections/hr', + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the WORKSPACE_CONNECTIONS_HR route'); + } + return `workspaces/${policyID}/connections/hr` as const; + }, + }, + WORKSPACE_CONNECTIONS_RECRUITING: { + route: 'workspaces/:policyID/connections/recruiting', + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the WORKSPACE_CONNECTIONS_RECRUITING route'); + } + return `workspaces/${policyID}/connections/recruiting` as const; + }, + }, + WORKSPACE_CONNECTIONS_RECEIPT_PARTNERS: { + route: 'workspaces/:policyID/connections/receipt-partners', + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the WORKSPACE_CONNECTIONS_RECEIPT_PARTNERS route'); + } + return `workspaces/${policyID}/connections/receipt-partners` as const; + }, + }, WORKSPACE_ACCOUNTING_QUICKBOOKS_ONLINE_ADVANCED: { route: 'workspaces/:policyID/accounting/quickbooks-online/advanced', getRoute: (policyID: string | undefined) => { @@ -3982,6 +4043,15 @@ const ROUTES = { route: 'policy/:policyID/copy-settings/confirm', getRoute: (policyID: string) => `policy/${policyID}/copy-settings/confirm` as const, }, + WORKSPACE_MCP: { + route: 'workspaces/:policyID/mcp', + getRoute: (policyID: string | undefined) => { + if (!policyID) { + Log.warn('Invalid policyID is used to build the WORKSPACE_MCP route'); + } + return `workspaces/${policyID}/mcp` as const; + }, + }, WORKSPACE_RECEIPT_PARTNERS: { route: 'workspaces/:policyID/receipt-partners', getRoute: (policyID: string | undefined, backTo?: string) => { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 807e64129ab7..3f41a7548e9c 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -974,7 +974,12 @@ const SCREENS = { UPGRADE: 'Workspace_Upgrade', DYNAMIC_WORKSPACE_DOWNGRADE: 'Dynamic_Workspace_Downgrade', DYNAMIC_PAY_AND_DOWNGRADE: 'Dynamic_Workspace_Pay_And_Downgrade', + MCP: 'Workspace_MCP', CONNECTIONS: 'Workspace_Connections', + CONNECTIONS_ACCOUNTING: 'Workspace_Connections_Accounting', + CONNECTIONS_HR: 'Workspace_Connections_HR', + CONNECTIONS_RECRUITING: 'Workspace_Connections_Recruiting', + CONNECTIONS_RECEIPT_PARTNERS: 'Workspace_Connections_Receipt_Partners', RECEIPT_PARTNERS: 'Workspace_Receipt_Partners', DYNAMIC_RECEIPT_PARTNERS_INVITE: 'Dynamic_Workspace_Receipt_Partners_Invite', DYNAMIC_RECEIPT_PARTNERS_INVITE_EDIT: 'Dynamic_Workspace_Receipt_Partners_Invite_Edit', diff --git a/src/components/ConnectToSageIntacctFlow/index.tsx b/src/components/ConnectToSageIntacctFlow/index.tsx index a471bb264049..3aa2d1a4e6da 100644 --- a/src/components/ConnectToSageIntacctFlow/index.tsx +++ b/src/components/ConnectToSageIntacctFlow/index.tsx @@ -1,9 +1,12 @@ import useHasReusablePoliciesConnectedTo from '@hooks/useHasReusablePoliciesConnectedTo'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import {isAuthenticationError} from '@libs/actions/connections'; import Navigation from '@libs/Navigation/Navigation'; +import {getAccountingConnectionsRoute} from '@pages/workspace/connections/utils'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -18,6 +21,7 @@ function ConnectToSageIntacctFlow({policyID}: ConnectToSageIntacctFlowProps) { const hasReusablePoliciesConnectedToSageIntacct = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, policyID); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const shouldGoToEnterCredentials = isAuthenticationError(policy, CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT); + const {isBetaEnabled} = usePermissions(); useEffect(() => { if (shouldGoToEnterCredentials) { @@ -25,7 +29,7 @@ function ConnectToSageIntacctFlow({policyID}: ConnectToSageIntacctFlowProps) { return; } if (!hasReusablePoliciesConnectedToSageIntacct) { - Navigation.navigate(`${ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)}/${DYNAMIC_ROUTES.SAGE_INTACCT_PREREQUISITES.path}`); + Navigation.navigate(`${getAccountingConnectionsRoute(isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS), policyID)}/${DYNAMIC_ROUTES.SAGE_INTACCT_PREREQUISITES.path}`); return; } Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXISTING_CONNECTIONS.getRoute(policyID)); diff --git a/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts b/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts index 8e86729fa934..a080dbe0b495 100644 --- a/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts +++ b/src/components/FocusTrap/WIDE_LAYOUT_INACTIVE_SCREENS.ts @@ -18,12 +18,15 @@ const WIDE_LAYOUT_INACTIVE_SCREENS: string[] = [ SCREENS.SETTINGS.ABOUT, SCREENS.WORKSPACES_LIST, SCREENS.SETTINGS.SUBSCRIPTION.ROOT, + SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.CONNECTIONS, SCREENS.WORKSPACE.INITIAL, SCREENS.WORKSPACE.PROFILE, SCREENS.WORKSPACE.WORKFLOWS, SCREENS.WORKSPACE.INVOICES, SCREENS.WORKSPACE.MEMBERS, + SCREENS.WORKSPACE.HR, + SCREENS.WORKSPACE.RECRUITING, SCREENS.WORKSPACE.CATEGORIES, SCREENS.WORKSPACE.VENDORS, SCREENS.WORKSPACE.MORE_FEATURES, @@ -39,6 +42,8 @@ const WIDE_LAYOUT_INACTIVE_SCREENS: string[] = [ SCREENS.SETTINGS.SAVE_THE_WORLD, SCREENS.WORKSPACE.RULES, SCREENS.WORKSPACE.PER_DIEM, + SCREENS.WORKSPACE.MCP, + SCREENS.WORKSPACE.RECEIPT_PARTNERS, SCREENS.WORKSPACE.TIME_TRACKING, ]; diff --git a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index 706ff2ea1954..2b5924367a38 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -77,6 +77,7 @@ const SEARCH_ROUTER_ICON_NAMES = [ ...SEARCH_TYPE_MENU_ICON_NAMES, 'Users', 'Hashtag', + 'Sync', 'Connect', 'Briefcase', 'Tag', @@ -89,6 +90,8 @@ const SEARCH_ROUTER_ICON_NAMES = [ 'Clock', 'InvoiceGeneric', 'Bolt', + 'Bot', + 'UserPlus', ] as const; // Saved searches are user-defined searches, not canned destinations, so they are excluded from go-to navigation suggestions. @@ -138,6 +141,12 @@ type BuildWorkspaceNavigationItemsParams = { isVendorMatchingBetaEnabled: boolean; + /** Whether the Merge ATS beta gating the Recruiting feature is enabled. */ + isRecruitingBetaEnabled: boolean; + + /** Whether the unified Connections beta replaces the separate connection pages. */ + isUnifiedConnectionsBetaEnabled: boolean; + /** Whether navigation should use the narrow-layout Workspace flow. */ shouldUseNarrowLayout: boolean; @@ -285,6 +294,8 @@ function buildWorkspaceNavigationItems({ icons, isOffline, isVendorMatchingBetaEnabled, + isRecruitingBetaEnabled, + isUnifiedConnectionsBetaEnabled, shouldUseNarrowLayout, convertToDisplayString, getItemText, @@ -303,6 +314,8 @@ function buildWorkspaceNavigationItems({ icons, policyCategories: policyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policy.id}`], isVendorMatchingBetaEnabled, + isRecruitingBetaEnabled, + isUnifiedConnectionsBetaEnabled, convertToDisplayString, }); @@ -437,6 +450,8 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) icons, isOffline: !!isOffline, isVendorMatchingBetaEnabled: isBetaEnabled(CONST.BETAS.VENDOR_MATCHING), + isRecruitingBetaEnabled: isBetaEnabled(CONST.BETAS.MERGE_ATS), + isUnifiedConnectionsBetaEnabled: isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS), shouldUseNarrowLayout, convertToDisplayString, getItemText: (item) => translate(item.translationKey), diff --git a/src/components/SearchBar/CompactSearchBar.tsx b/src/components/SearchBar/CompactSearchBar.tsx new file mode 100644 index 000000000000..c623e6196f2c --- /dev/null +++ b/src/components/SearchBar/CompactSearchBar.tsx @@ -0,0 +1,45 @@ +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import React from 'react'; + +import type {SharedSearchBarProps} from './BaseSearchBar'; + +import BaseSearchBar from './BaseSearchBar'; + +type CompactSearchBarProps = SharedSearchBarProps; + +/** + * The compact list-style search input (placeholder instead of a floating label), matching the one used above selection lists. + */ +function CompactSearchBar({ref, label, style, inputValue, onChangeText, onSubmitEditing, shouldShowEmptyState, emptyStateContainerStyle}: CompactSearchBarProps) { + const styles = useThemeStyles(); + const theme = useTheme(); + // The compact search input is sized by the physical device width (`isSmallScreenWidth`), not `shouldUseNarrowLayout`, so + // it stays compact when rendered inside an RHP/narrow pane on web/desktop and only grows to the tall size on mobile. + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + + return ( + + ); +} + +export default CompactSearchBar; diff --git a/src/languages/de.ts b/src/languages/de.ts index 9b1575d947de..88447501afa0 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3566,15 +3566,16 @@ ${amount} für ${merchant} – ${date}`, `Verbinden${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '' : 'zu'} [${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'dein' : ''} ${integrationName}](${workspaceAccountingLink})`, description: ({integrationName, workspaceAccountingLink}) => Str.dedent(` - Verbinden Sie ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'Ihr' : 'bis'} ${integrationName} für automatische Spesenkodierung und Synchronisierung, die den Monatsabschluss zum Kinderspiel macht. + Verbinde ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'dein' : 'bis'} ${integrationName} für automatische Spesenkodierung und Synchronisierung, die den Monatsabschluss zum Kinderspiel macht. - 1. Klicken Sie auf *Workspaces*. - 2. Wählen Sie Ihren Workspace aus. - 3. Klicken Sie auf *Connections*. - 4. Suchen Sie ${integrationName}. - 5. Klicken Sie auf *Connect*. + 1. Klicke auf *Workspaces*. + 2. Wähle deinen Workspace aus. + 3. Klicke auf *Accounting*. + 4. Suche nach ${integrationName}. + 5. Klicke auf *Connect*. - [Zu den Verbindungen](${workspaceAccountingLink}).`), + [Zur Buchhaltung](${workspaceAccountingLink}). + `), }, connectCorporateCardTask: { title: ({corporateCardLink}) => `[Geschäftskarten verbinden](${corporateCardLink})`, @@ -4900,15 +4901,50 @@ ${amount} für ${merchant} – ${date}`, description: 'Großartige Neuigkeiten 🎉. Kontaktiere uns, wenn sie Hilfe bei der Einrichtung benötigen.', }, mcp: { + connectors: 'Konnektoren', + connectorsSubtitle: 'Verbinde einen KI-Assistenten mit deinem Expensify-Konto.', + connect: 'Verbinden', + helpPrompt: 'Brauchst du Hilfe beim Verbinden?', + helpLink: 'Lies unsere Anleitung.', claude: { title: 'Claude', + subtitle: 'von Anthropic', }, cursor: { title: 'Cursor', + subtitle: 'von Anysphere', }, chatgpt: { title: 'ChatGPT', + subtitle: 'von OpenAI', + }, + }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Alle', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Buchhaltung', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'Personalwesen & Mitarbeitende', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Personalbeschaffung', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Belege', + [CONST.TAB.CONNECTIONS.AI]: 'KI & MCP', }, + findConnections: 'Verbindungen finden', + configure: 'Konfigurieren', + fix: 'Beheben', + active: 'Aktiv', + broken: 'Defekt', + brokenConnection: 'Kann aufgrund einer unterbrochenen Verbindung nicht synchronisieren', + replaceConnectionTitle: 'Verbindung ersetzen?', + offer: 'Angebot', + replaceConnectionPrompt: (connectionName: string) => `Dadurch wird Ihre aktuelle ${connectionName}-Verbindung entfernt.`, + hrisListing: (providerName: string) => `${providerName} (HRIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: 'Sie sehen Ihre nicht? Schlagen Sie eine Integration vor, wir schauen sie uns an.', + noResultsPrompt: 'Bitte passen Sie Ihre Suche an oder', + suggestAnIntegration: 'Integration vorschlagen', + noResultsPromptEnd: '.', + allConnectedTitle: 'Sie sind vollständig verbunden', + allConnectedPrompt: 'Hier ist bereits alles verbunden. Sie können auch', }, receiptPartners: { uber: { @@ -6486,6 +6522,10 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU title: 'Organisieren', subtitle: 'Ausgaben gruppieren und analysieren, jede gezahlte Steuer erfassen.', }, + integrateSection: { + title: 'Integrieren', + subtitle: 'Verbinde Expensify mit beliebten Finanzprodukten.', + }, distanceRates: { title: 'Kilometersätze', subtitle: 'Sätze Tarife fest, aktualisiere sie und setze sie durch.', @@ -6708,18 +6748,27 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU }, connections: { title: 'Buchhaltung', + subtitle: 'Synchronisieren Sie Ihren Kontenplan und mehr.', + }, + mcp: { + title: 'MCP', + subtitle: 'Verbinde einen KI-Assistenten mit deinem Expensify-Konto.', }, receiptPartners: { title: 'Belegpartner', + subtitle: 'Belege automatisch importieren.', }, connectionsWarningModal: { featureEnabledTitle: 'Nicht so schnell ...', featureEnabledText: 'Um diese Funktion zu aktivieren oder zu deaktivieren, musst du deine Einstellungen für den Buchhaltungsimport ändern.', + disconnectText: 'Um die Buchhaltung zu deaktivieren, musst du die Buchhaltungsanbindung von deinem Workspace trennen.', manageSettings: 'Einstellungen verwalten', }, receiptPartnersWarningModal: { featureEnabledTitle: 'Uber-Verknüpfung trennen', + disconnectText: 'Um diese Funktion zu deaktivieren, trenne bitte zuerst die Uber for Business-Integration.', description: 'Möchtest du diese Integration wirklich trennen?', + confirmText: 'Verstanden', }, workflowWarningModal: { featureEnabledTitle: 'Nicht so schnell ...', @@ -6736,12 +6785,16 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU subtitle: 'Legen Sie einen abrechnungsfähigen Stundensatz für die Zeiterfassung fest.', defaultHourlyRate: 'Standardstundensatz', }, + hrWarningModal: {disconnectText: ({integration}: {integration: string}) => `Um HR zu deaktivieren, trennen Sie bitte zuerst ${integration} von diesem Workspace.`}, vendors: { title: 'Lieferanten', subtitle: 'Ordnen Sie Kartenausgaben den aus Ihrer Buchhaltungssoftware importierten Lieferanten zu.', disabledTitle: 'Nicht so schnell...', disabledMessage: 'Um diese Funktion zu aktivieren oder zu deaktivieren, müssen Sie Ihre Buchhaltungsimporteinstellungen ändern.', }, + recruitingWarningModal: { + disconnectText: ({integration}: {integration: string}) => `Um Recruiting zu deaktivieren, trennen Sie bitte zuerst die Verbindung von ${integration} mit diesem Workspace.`, + }, }, reports: { reportsCustomTitleExamples: 'Beispiele:', @@ -7257,6 +7310,7 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, disconnect: 'Trennen', reinstall: 'Connector neu installieren', disconnectTitle: (connectionName = 'Integration') => `${connectionName} trennen`, + connectTitle: (connectionName) => `${connectionName} verbinden`, syncError: (connectionName) => `Verbindung mit ${connectionName} nicht möglich`, accounts: 'Kontenplan', taxes: 'Steuern', @@ -7273,10 +7327,12 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'Standardmäßige NetSuite-Mitarbeiterperson', }, disconnectPrompt: (connectionName = 'diese Integration') => `Möchtest du ${connectionName} wirklich trennen?`, + connectPrompt: (connectionName) => `Sind Sie sicher, dass Sie ${connectionName} verbinden möchten? Dadurch werden alle bestehenden Buchhaltungsverbindungen entfernt.`, enterCredentials: 'Gib deine Anmeldedaten ein', reconnect: 'Erneut verbinden', updateCredentials: 'Anmeldedaten aktualisieren', claimOffer: { + badgeText: 'Angebot verfügbar!', xero: { headline: 'Hol dir Xero 6 Monate lang kostenlos!', description: 'Neu bei Xero? Expensify-Kund*innen erhalten 6 Monate kostenlos. Fordern Sie unten Ihr Angebot an.', @@ -8680,6 +8736,11 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc }, hr: { title: 'Personalwesen', + connectionsSubtitle: + 'Verbinden Sie sich mit Ihrem HR-System, um Mitarbeiterdaten zu synchronisieren, Erstattungen automatisch den richtigen Personen zuzuordnen und die Ausgaben Ihres Teams ohne manuellen Aufwand korrekt zu halten.', + subtitle: 'HR-Tools verbinden und Mitarbeitergenehmigungen synchron halten.', + alreadyConnectedTitle: 'Es kann keine Verbindung zu mehreren HR-Plattformen hergestellt werden', + alreadyConnectedPrompt: 'Sie müssen Ihre aktuelle HR-Plattform trennen, bevor Sie eine andere verbinden.', connectionDescription: (providerName: string) => `Verbinden Sie ${providerName}, um Mitarbeitergenehmigungen mit Ihrem Workspace zu synchronisieren.`, providerApprovalMode: (providerName: string) => `${providerName}-Genehmigungsmodus`, providerFinalApprover: (providerName: string) => `${providerName} Endgenehmigende*r`, @@ -8731,9 +8792,14 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc }, recruiting: { title: 'Personalbeschaffung', + connectionsSubtitle: + 'Verbinden Sie Ihr Recruiting-System, um Kandidatendaten zu synchronisieren, Erstattungen automatisch den richtigen Personen zuzuordnen und die Ausgaben Ihres Teams ohne manuelle Arbeit korrekt zu halten.', + alreadyConnectedTitle: 'Es kann keine Verbindung zu mehreren ATS-Plattformen hergestellt werden', + alreadyConnectedPrompt: 'Sie müssen Ihr aktuelles ATS trennen, bevor Sie ein anderes verbinden.', syncing: 'Kandidaten werden synchronisiert', setupIncomplete: (setupLink: string | undefined) => `Verbunden. ${setupLink ? `Einrichtung abschließen` : 'Einrichtung abschließen'} zum Importieren von Kandidaten.`, + dontSeeYourATS: `Sie sehen Ihr ATS hier nicht? Fragen Sie Concierge, und wir können es hinzufügen.`, importSettings: 'Import-Einstellungen', defaultApprover: 'Standardgenehmiger', approverFields: {recruiter: 'Personalvermittler', recruitingCoordinator: 'Recruiting-Koordinator'}, @@ -8759,6 +8825,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc }, enableJobStagesOrTags: 'Aktivieren Sie Jobphasen oder Tags, um fortzufahren', }, + subtitle: 'Verknüpfen Sie Recruiting-Tools und halten Sie Kandidatengenehmigungen synchron.', syncResults: { importedCount: () => ({ one: '1 Kandidat', @@ -8778,6 +8845,9 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc }, }, merge: { + connections: 'Verbindungen', + connect: 'Verbinden', + findIntegration: 'Integration finden', syncNow: 'Jetzt synchronisieren', disconnect: 'Trennen', disconnectTitle: (providerName: string) => `${providerName} trennen`, @@ -8967,33 +9037,6 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc noPaymentMethodsFound: 'Keine Zahlungsformen gefunden', noPaymentMethodsFoundDescription: 'Bitte fügen Sie Zahlungsformen in Dynamics 365 Business Central hinzu und synchronisieren Sie die Verbindung erneut', }, - connections: { - tabs: { - [CONST.TAB.CONNECTIONS.ALL]: 'Alle', - [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Buchhaltung', - [CONST.TAB.CONNECTIONS.PEOPLE]: 'Personalwesen & Mitarbeitende', - [CONST.TAB.CONNECTIONS.RECRUITING]: 'Personalbeschaffung', - [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Belege', - [CONST.TAB.CONNECTIONS.AI]: 'KI & MCP', - }, - findConnections: 'Verbindungen finden', - configure: 'Konfigurieren', - fix: 'Beheben', - active: 'Aktiv', - broken: 'Defekt', - brokenConnection: 'Kann aufgrund einer unterbrochenen Verbindung nicht synchronisieren', - replaceConnectionTitle: 'Verbindung ersetzen?', - offer: 'Angebot', - replaceConnectionPrompt: (connectionName: string) => `Dadurch wird Ihre aktuelle ${connectionName}-Verbindung entfernt.`, - hrisListing: (providerName: string) => `${providerName} (HRIS)`, - atsListing: (providerName: string) => `${providerName} (ATS)`, - suggestIntegration: 'Sie sehen Ihre nicht? Schlagen Sie eine Integration vor, wir schauen sie uns an.', - noResultsPrompt: 'Bitte passen Sie Ihre Suche an oder', - suggestAnIntegration: 'Integration vorschlagen', - noResultsPromptEnd: '.', - allConnectedTitle: 'Sie sind vollständig verbunden', - allConnectedPrompt: 'Hier ist bereits alles verbunden. Sie können auch', - }, }, getAssistancePage: { title: 'Hilfe erhalten', diff --git a/src/languages/el.ts b/src/languages/el.ts index 77a80a8ee65d..838020da2a1c 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -3615,15 +3615,15 @@ ${amount} για ${merchant} - ${date}`, `Σύνδεση${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '' : 'προς'} [${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'σας' : ''} ${integrationName}](${workspaceAccountingLink})`, description: ({integrationName, workspaceAccountingLink}) => Str.dedent(` - Συνδέστε το ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'σας' : 'σε'} ${integrationName} για αυτόματη κωδικοποίηση και συγχρονισμό εξόδων, ώστε το κλείσιμο μήνα να γίνεται παιχνιδάκι. + Συνδέστε το ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'σας' : 'σε'} ${integrationName} για αυτόματη κατηγοριοποίηση και συγχρονισμό εξόδων που κάνουν το κλείσιμο μήνα παιχνιδάκι. 1. Κάντε κλικ στο *χώρο εργασίας*. 2. Επιλέξτε τον χώρο εργασίας σας. - 3. Κάντε κλικ στις *συνδέσεις*. + 3. Κάντε κλικ στο *λογιστική*. 4. Βρείτε το ${integrationName}. 5. Κάντε κλικ στο *σύνδεση*. - [Μετάβαση στις συνδέσεις](${workspaceAccountingLink}).`), + [Μετάβαση στη λογιστική](${workspaceAccountingLink}).`), }, connectCorporateCardTask: { title: ({corporateCardLink}) => `Συνδέστε [τις εταιρικές σας κάρτες](${corporateCardLink})`, @@ -4976,16 +4976,51 @@ ${amount} για ${merchant} - ${date}`, description: 'Καλά νέα 🎉. Επικοινωνήστε μαζί μας αν χρειαστούν βοήθεια με τη ρύθμιση.', }, mcp: { + connectors: 'Συνδέσεις', + connectorsSubtitle: 'Συνδέστε έναν βοηθό AI με τον λογαριασμό σας στο Expensify.', + connect: 'Σύνδεση', + helpPrompt: 'Χρειάζεστε βοήθεια με τη σύνδεση;', + helpLink: 'Διαβάστε τον οδηγό μας.', claude: { title: 'Claude', + subtitle: 'από την Anthropic', }, cursor: { title: 'Cursor', + subtitle: 'από την Anysphere', }, chatgpt: { title: 'ChatGPT', + subtitle: 'από την OpenAI', }, }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Όλα', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Λογιστική', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'ΔΑΔ & ανθρώπινο δυναμικό', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Προσλήψεις', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Αποδείξεις', + [CONST.TAB.CONNECTIONS.AI]: 'ΤΝ & MCP', + }, + findConnections: 'Εύρεση συνδέσεων', + configure: 'Ρυθμίστε', + fix: 'Διόρθωση', + active: 'Ενεργό', + broken: 'Χαλασμένο', + brokenConnection: 'Δεν είναι δυνατός ο συγχρονισμός λόγω κατεστραμμένης σύνδεσης', + replaceConnectionTitle: 'Αντικατάσταση σύνδεσης;', + offer: 'Προσφορά', + replaceConnectionPrompt: (connectionName: string) => `Αυτό θα καταργήσει την τρέχουσα σύνδεση ${connectionName}.`, + hrisListing: (providerName: string) => `${providerName} (HRIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: 'Δεν βλέπετε τη δική σας; Προτείνετε μια ενσωμάτωση και θα το εξετάσουμε.', + noResultsPrompt: 'Παρακαλούμε προσαρμόστε την αναζήτησή σας ή', + suggestAnIntegration: 'προτείνετε μια ενσωμάτωση', + noResultsPromptEnd: '.', + allConnectedTitle: 'Έχετε συνδεθεί πλήρως', + allConnectedPrompt: 'Όλα εδώ είναι ήδη συνδεδεμένα. Μπορείτε επίσης να', + }, receiptPartners: { uber: { subtitle: (organizationName: string) => @@ -6597,6 +6632,10 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ title: 'Οργανώστε', subtitle: 'Ομαδοποιήστε και αναλύστε τις δαπάνες, καταγράψτε κάθε φόρο που πληρώθηκε.', }, + integrateSection: { + title: 'Ενσωμάτωση', + subtitle: 'Συνδέστε το Expensify με δημοφιλή χρηματοοικονομικά προϊόντα.', + }, distanceRates: { title: 'Τιμές χιλιομετρικής αποζημίωσης', subtitle: 'Προσθέστε, ενημερώστε και εφαρμόστε τιμές.', @@ -6830,18 +6869,30 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ }, connections: { title: 'Λογιστική', + subtitle: 'Συγχρονίστε το λογιστικό σας σχέδιο και άλλα.', + }, + mcp: { + title: 'MCP', + subtitle: 'Συνδέστε έναν βοηθό AI με τον λογαριασμό σας στο Expensify.', }, receiptPartners: { title: 'Συνεργάτες αποδείξεων', + subtitle: 'Αυτόματη εισαγωγή αποδείξεων.', }, connectionsWarningModal: { featureEnabledTitle: 'Όχι και τόσο γρήγορα...', featureEnabledText: 'Για να ενεργοποιήσετε ή να απενεργοποιήσετε αυτή τη λειτουργία, θα χρειαστεί να αλλάξετε τις ρυθμίσεις εισαγωγής λογιστικής.', + disconnectText: 'Για να απενεργοποιήσετε τη λογιστική, θα χρειαστεί να αποσυνδέσετε τη λογιστική σύνδεση από τον χώρο εργασίας σας.', manageSettings: 'Διαχείριση ρυθμίσεων', }, receiptPartnersWarningModal: { featureEnabledTitle: 'Αποσύνδεση του Uber', + disconnectText: 'Για να απενεργοποιήσετε αυτή τη λειτουργία, αποσυνδέστε πρώτα την ενσωμάτωση Uber for Business.', description: 'Είστε βέβαιοι ότι θέλετε να αποσυνδέσετε αυτήν την ενοποίηση;', + confirmText: 'Εντάξει', + }, + hrWarningModal: { + disconnectText: ({integration}: {integration: string}) => `Για να απενεργοποιήσετε το HR, αποσυνδέστε πρώτα το ${integration} από αυτόν τον χώρο εργασίας.`, }, workflowWarningModal: { featureEnabledTitle: 'Όχι και τόσο γρήγορα...', @@ -6864,6 +6915,10 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ disabledTitle: 'Όχι τόσο γρήγορα...', disabledMessage: 'Για να ενεργοποιήσετε ή να απενεργοποιήσετε αυτήν τη λειτουργία, θα πρέπει να αλλάξετε τις ρυθμίσεις εισαγωγής λογιστικής.', }, + recruitingWarningModal: { + disconnectText: ({integration}: {integration: string}) => + `Για να απενεργοποιήσετε την προσέλκυση προσωπικού, αποσυνδέστε πρώτα το ${integration} από αυτόν τον χώρο εργασίας.`, + }, }, reports: { reportsCustomTitleExamples: 'Παραδείγματα:', @@ -7388,6 +7443,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ disconnect: 'Αποσύνδεση', reinstall: 'Επανεγκατάσταση συνδέτη', disconnectTitle: (connectionName = 'ενσωμάτωση') => `Αποσύνδεση ${connectionName}`, + connectTitle: (connectionName) => `Συνδέστε ${connectionName}`, syncError: (connectionName) => `Δεν είναι δυνατή η σύνδεση με το ${connectionName}`, accounts: 'Λογιστικό σχέδιο', taxes: 'Φόροι', @@ -7404,10 +7460,12 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'Προεπιλογή εργαζομένου NetSuite', }, disconnectPrompt: (connectionName = 'αυτή η ενοποίηση') => `Είστε βέβαιοι ότι θέλετε να αποσυνδέσετε το ${connectionName};`, + connectPrompt: (connectionName) => `Είστε βέβαιοι ότι θέλετε να συνδέσετε το ${connectionName}; Αυτό θα αφαιρέσει τυχόν υπάρχουσες λογιστικές συνδέσεις.`, reconnect: 'Επανασύνδεση', enterCredentials: 'Εισαγάγετε τα διαπιστευτήριά σας', updateCredentials: 'Ενημέρωση διαπιστευτηρίων', claimOffer: { + badgeText: 'Προσφορά διαθέσιμη!', xero: { headline: 'Αποκτήστε το Xero δωρεάν για 6 μήνες!', description: @@ -7654,6 +7712,11 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ }, hr: { title: 'HR', + connectionsSubtitle: + 'Συνδεθείτε με το σύστημα HR σας για να συγχρονίζετε τα στοιχεία των εργαζομένων, να αντιστοιχίζετε αυτόματα τις αποζημιώσεις στα σωστά άτομα και να διατηρείτε τα έξοδα της ομάδας σας ακριβή χωρίς χειροκίνητη εργασία.', + subtitle: 'Συνδέστε εργαλεία HR και διατηρήστε τις εγκρίσεις εργαζομένων συγχρονισμένες.', + alreadyConnectedTitle: 'Δεν είναι δυνατή η σύνδεση σε πολλές πλατφόρμες HR', + alreadyConnectedPrompt: 'Πρέπει να αποσυνδέσετε την τρέχουσα πλατφόρμα HR προτού συνδέσετε μια άλλη.', connectionDescription: (providerName: string) => `Συνδέστε το ${providerName} για να διατηρείτε τις εγκρίσεις εργαζομένων συγχρονισμένες με τον χώρο εργασίας σας.`, providerApprovalMode: (providerName: string) => `λειτουργία έγκρισης ${providerName}`, providerFinalApprover: (providerName: string) => `τελικός εγκριτής ${providerName}`, @@ -7708,9 +7771,14 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ }, recruiting: { title: 'Προσλήψεις', + connectionsSubtitle: + 'Συνδεθείτε με το σύστημα προσλήψεών σας για να συγχρονίζετε τα δεδομένα υποψηφίων, να αντιστοιχίζετε αυτόματα τις αποζημιώσεις στα σωστά άτομα και να διατηρείτε τις δαπάνες της ομάδας σας ακριβείς χωρίς χειροκίνητη εργασία.', + alreadyConnectedTitle: 'Αδυναμία σύνδεσης σε πολλές πλατφόρμες ATS', + alreadyConnectedPrompt: 'Πρέπει να αποσυνδέσετε το τρέχον ATS σας πριν συνδέσετε κάποιο άλλο.', syncing: 'Γίνεται συγχρονισμός υποψηφίων', setupIncomplete: (setupLink: string | undefined) => `Συνδέθηκε. ${setupLink ? `Ολοκληρώστε τη ρύθμιση` : 'Ολοκληρώστε τη ρύθμιση'} για εισαγωγή υποψηφίων.`, + dontSeeYourATS: `Δεν βλέπετε εδώ το ATS σας; Ρωτήστε το Concierge και μπορούμε να το προσθέσουμε.`, importSettings: 'Ρυθμίσεις εισαγωγής', defaultApprover: 'Προεπιλεγμένος εγκρίνων', approverFields: {recruiter: 'Στρατολογητής', recruitingCoordinator: 'Συντονιστής προσλήψεων'}, @@ -7736,6 +7804,7 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ }, enableJobStagesOrTags: 'Ενεργοποιήστε τα στάδια εργασίας ή τις ετικέτες για να συνεχίσετε', }, + subtitle: 'Συνδέστε τα εργαλεία προσλήψεων και διατηρήστε τις εγκρίσεις υποψηφίων συγχρονισμένες.', syncResults: { importedCount: () => ({ one: '1 υποψήφιος', @@ -7755,6 +7824,9 @@ _Για πιο αναλυτικές οδηγίες, [επισκεφθείτε τ }, }, merge: { + connections: 'Συνδέσεις', + connect: 'Σύνδεση', + findIntegration: 'Βρείτε ενοποίηση', syncNow: 'Συγχρονισμός τώρα', disconnect: 'Αποσύνδεση', disconnectTitle: (providerName: string) => `Αποσύνδεση ${providerName}`, @@ -9132,33 +9204,6 @@ ${reportName}`, noPaymentMethodsFound: 'Δεν βρέθηκαν μέθοδοι πληρωμής', noPaymentMethodsFoundDescription: 'Προσθέστε μεθόδους πληρωμής στο Dynamics 365 Business Central και συγχρονίστε ξανά τη σύνδεση', }, - connections: { - tabs: { - [CONST.TAB.CONNECTIONS.ALL]: 'Όλα', - [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Λογιστική', - [CONST.TAB.CONNECTIONS.PEOPLE]: 'ΔΑΔ & ανθρώπινο δυναμικό', - [CONST.TAB.CONNECTIONS.RECRUITING]: 'Προσλήψεις', - [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Αποδείξεις', - [CONST.TAB.CONNECTIONS.AI]: 'ΤΝ & MCP', - }, - findConnections: 'Εύρεση συνδέσεων', - configure: 'Ρυθμίστε', - fix: 'Διόρθωση', - active: 'Ενεργό', - broken: 'Χαλασμένο', - brokenConnection: 'Δεν είναι δυνατός ο συγχρονισμός λόγω κατεστραμμένης σύνδεσης', - replaceConnectionTitle: 'Αντικατάσταση σύνδεσης;', - offer: 'Προσφορά', - replaceConnectionPrompt: (connectionName: string) => `Αυτό θα καταργήσει την τρέχουσα σύνδεση ${connectionName}.`, - hrisListing: (providerName: string) => `${providerName} (HRIS)`, - atsListing: (providerName: string) => `${providerName} (ATS)`, - suggestIntegration: 'Δεν βλέπετε τη δική σας; Προτείνετε μια ενσωμάτωση και θα το εξετάσουμε.', - noResultsPrompt: 'Παρακαλούμε προσαρμόστε την αναζήτησή σας ή', - suggestAnIntegration: 'προτείνετε μια ενσωμάτωση', - noResultsPromptEnd: '.', - allConnectedTitle: 'Έχετε συνδεθεί πλήρως', - allConnectedPrompt: 'Όλα εδώ είναι ήδη συνδεδεμένα. Μπορείτε επίσης να', - }, }, getAssistancePage: { title: 'Λάβετε βοήθεια', diff --git a/src/languages/en.ts b/src/languages/en.ts index 7771926198fe..1dca1c3c7eab 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3684,11 +3684,11 @@ const translations = { 1. Click *Workspaces*. 2. Select your workspace. - 3. Click *Connections*. + 3. Click *Accounting*. 4. Find ${integrationName}. 5. Click *Connect*. - [Take me to connections](${workspaceAccountingLink}). + [Take me to accounting](${workspaceAccountingLink}). `), }, connectCorporateCardTask: { @@ -5042,14 +5042,22 @@ const translations = { description: 'Great news 🎉. Reach out to us if they need any help with the setup.', }, mcp: { + connectors: 'Connectors', + connectorsSubtitle: 'Connect an AI assistant to your Expensify account.', + connect: 'Connect', + helpPrompt: 'Need help connecting?', + helpLink: 'Read our guide.', claude: { title: 'Claude', + subtitle: 'by Anthropic', }, cursor: { title: 'Cursor', + subtitle: 'by Anysphere', }, chatgpt: { title: 'ChatGPT', + subtitle: 'by OpenAI', }, }, connections: { @@ -6800,6 +6808,10 @@ const translations = { title: 'Organize', subtitle: 'Group and analyze spend, record every tax paid.', }, + integrateSection: { + title: 'Integrate', + subtitle: 'Connect Expensify to popular financial products.', + }, distanceRates: { title: 'Distance rates', subtitle: 'Add, update, and enforce rates.', @@ -7032,18 +7044,33 @@ const translations = { }, connections: { title: 'Accounting', + subtitle: 'Sync your chart of accounts and more.', + }, + mcp: { + title: 'MCP', + subtitle: 'Connect an AI assistant to your Expensify account.', }, receiptPartners: { title: 'Receipt partners', + subtitle: 'Automatically import receipts.', }, connectionsWarningModal: { featureEnabledTitle: 'Not so fast...', featureEnabledText: "To enable or disable this feature, you'll need to change your accounting import settings.", + disconnectText: "To disable accounting, you'll need to disconnect your accounting connection from your workspace.", manageSettings: 'Manage settings', }, receiptPartnersWarningModal: { featureEnabledTitle: 'Disconnect Uber', + disconnectText: 'To disable this feature, please disconnect the Uber for Business integration first.', description: 'Are you sure you want to disconnect this integration?', + confirmText: 'Got it', + }, + hrWarningModal: { + disconnectText: ({integration}: {integration: string}) => `To disable HR, please disconnect ${integration} from this workspace first.`, + }, + recruitingWarningModal: { + disconnectText: ({integration}: {integration: string}) => `To disable Recruiting, please disconnect ${integration} from this workspace first.`, }, workflowWarningModal: { featureEnabledTitle: 'Not so fast...', @@ -7587,6 +7614,7 @@ const translations = { disconnect: 'Disconnect', reinstall: 'Reinstall connector', disconnectTitle: (connectionName = 'integration') => `Disconnect ${connectionName}`, + connectTitle: (connectionName: string) => `Connect ${connectionName}`, syncError: (connectionName: string) => `Can't connect to ${connectionName}`, accounts: 'Chart of accounts', @@ -7604,12 +7632,14 @@ const translations = { [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'NetSuite employee default', }, disconnectPrompt: (connectionName = 'this integration') => `Are you sure you want to disconnect ${connectionName}?`, + connectPrompt: (connectionName: string) => `Are you sure you want to connect ${connectionName}? This will remove any existing accounting connections.`, reconnect: 'Reconnect', enterCredentials: 'Enter your credentials', updateCredentials: 'Update credentials', qboConnectionExpiring: ({date}: {date: string}) => `Your QuickBooks Online connection expires on ${date}.`, qboConnectionExpired: ({date}: {date: string}) => `Your QuickBooks Online connection expired on ${date}.`, claimOffer: { + badgeText: 'Offer available!', xero: { headline: 'Get Xero free for 6 months!', description: 'New to Xero? Expensify customers get 6 months free. Claim your offer below.', @@ -7854,6 +7884,11 @@ const translations = { }, hr: { title: 'HR', + connectionsSubtitle: + "Connect to your HR system to sync employee data, auto-match reimbursements to the right people, and keep your team's expenses accurate without the manual work.", + subtitle: 'Connect HR tools and keep employee approvals in sync.', + alreadyConnectedTitle: 'Cannot connect to multiple HR platforms', + alreadyConnectedPrompt: 'You must disconnect your current HR platform before connecting another.', connectionDescription: (providerName: string) => `Connect ${providerName} to keep employee approvals in sync with your workspace.`, providerApprovalMode: (providerName: string) => `${providerName} approval mode`, providerFinalApprover: (providerName: string) => `${providerName} final approver`, @@ -7908,6 +7943,11 @@ const translations = { }, recruiting: { title: 'Recruiting', + subtitle: 'Connect Recruiting tools and keep candidate approvals in sync.', + connectionsSubtitle: + "Connect to your recruiting system to sync candidate data, auto-match reimbursements to the right people, and keep your team's expenses accurate without the manual work.", + alreadyConnectedTitle: 'Cannot connect to multiple ATS platforms', + alreadyConnectedPrompt: 'You must disconnect your current ATS before connecting another.', syncing: 'Syncing candidates', syncResults: { importedCount: () => ({ @@ -7917,6 +7957,7 @@ const translations = { }, setupIncomplete: (setupLink: string | undefined) => `Connected. ${setupLink ? `Complete setup` : 'Complete setup'} to import candidates.`, + dontSeeYourATS: `Don't see your ATS here? Ask Concierge and we can add it.`, importSettings: 'Import settings', defaultApprover: 'Default approver', approverField: `First approver`, @@ -7957,6 +7998,9 @@ const translations = { }, }, merge: { + connections: 'Connections', + connect: 'Connect', + findIntegration: 'Find integration', syncNow: 'Sync now', disconnect: 'Disconnect', disconnectTitle: (providerName: string) => `Disconnect ${providerName}`, diff --git a/src/languages/es.ts b/src/languages/es.ts index f8067ebd3f96..646b6fb6fc15 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3469,15 +3469,16 @@ ${amount} para ${merchant} - ${date}`, `Conéctate${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '' : ' a'} [${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'tu' : ''} ${integrationName}](${workspaceAccountingLink})`, description: ({integrationName, workspaceAccountingLink}) => Str.dedent(` - Conecta ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'tu' : 'a'} ${integrationName} para la clasificación y sincronización automática de gastos, lo que facilita el cierre de fin de mes. + Conéctate ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'tu' : 'a'} ${integrationName} para la clasificación y sincronización automática de gastos, lo que facilita el cierre de fin de mes. - 1. Haz clic en *Workspaces*. + 1. Haz clic en *Espacios de trabajo*. 2. Selecciona tu espacio de trabajo. - 3. Haz clic en *Connections*. + 3. Haz clic en *Contabilidad*. 4. Busca ${integrationName}. - 5. Haz clic en *Connect*. + 5. Haz clic en *Conectar*. - [Llévame a Conexiones](${workspaceAccountingLink}).`), + [Ir a contabilidad](${workspaceAccountingLink}). + `), }, connectCorporateCardTask: { title: ({corporateCardLink}) => `Conecta [tu tarjeta corporativa](${corporateCardLink})`, @@ -4830,16 +4831,51 @@ ${amount} para ${merchant} - ${date}`, description: 'Excelentes noticias 🎉. Contáctanos si necesitan ayuda con la configuración.', }, mcp: { + connectors: 'Conectores', + connectorsSubtitle: 'Conecta un asistente de IA a tu cuenta de Expensify.', + connect: 'Conectar', + helpPrompt: '¿Necesitas ayuda para conectar?', + helpLink: 'Lee nuestra guía.', claude: { title: 'Claude', + subtitle: 'de Anthropic', }, cursor: { title: 'Cursor', + subtitle: 'de Anysphere', }, chatgpt: { title: 'ChatGPT', + subtitle: 'de OpenAI', }, }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Todo', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Contabilidad', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'RR. HH. y Personas', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Reclutamiento', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Recibos', + [CONST.TAB.CONNECTIONS.AI]: 'IA y MCP', + }, + findConnections: 'Buscar conexiones', + configure: 'Configurar', + fix: 'Corregir', + active: 'Activo', + broken: 'Roto', + brokenConnection: 'No se puede sincronizar debido a una conexión interrumpida', + replaceConnectionTitle: '¿Reemplazar la conexión?', + offer: 'Oferta', + replaceConnectionPrompt: (connectionName: string) => `Esto eliminará tu conexión actual de ${connectionName}.`, + hrisListing: (providerName: string) => `${providerName} (HRIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: '¿No ves la tuya? Sugiere una integración y lo estudiaremos.', + noResultsPrompt: 'Ajusta tu búsqueda o', + suggestAnIntegration: 'sugiere una integración', + noResultsPromptEnd: '.', + allConnectedTitle: 'Ya está todo conectado', + allConnectedPrompt: 'Todo aquí ya está conectado. También puedes', + }, receiptPartners: { uber: { subtitle: (organizationName) => (organizationName ? `Conectado a ${organizationName}` : 'Automatice los gastos de viajes y entrega de comidas en toda su organización.'), @@ -6348,6 +6384,10 @@ ${amount} para ${merchant} - ${date}`, title: 'Organizar', subtitle: 'Agrupa y analiza el gasto, registra cada impuesto pagado.', }, + integrateSection: { + title: 'Integrar', + subtitle: 'Conecta Expensify a otros productos financieros populares.', + }, distanceRates: { title: 'Tasas de distancia', subtitle: 'Añade, actualiza y haz cumplir las tasas.', @@ -6584,19 +6624,29 @@ ${amount} para ${merchant} - ${date}`, }, connections: { title: 'Contabilidad', + subtitle: 'Sincroniza tu plan de cuentas y otras opciones.', + }, + mcp: { + title: 'MCP', + subtitle: 'Conecta un asistente de IA a tu cuenta de Expensify.', }, receiptPartners: { title: 'Socios de recibos', + subtitle: 'Importación automática de recibos.', }, connectionsWarningModal: { featureEnabledTitle: 'No tan rápido...', featureEnabledText: 'Para activar o desactivar esta función, cambia la configuración de importación contable.', + disconnectText: 'Para desactivar la contabilidad, desconecta tu conexión contable del espacio de trabajo.', manageSettings: 'Gestionar la configuración', }, receiptPartnersWarningModal: { featureEnabledTitle: 'Desconectar Uber', + disconnectText: 'Para desactivar esta función, desconecta primero la integración de Uber for Business.', description: '¿Está seguro de que desea desconectar esta integración?', + confirmText: 'Entendido', }, + hrWarningModal: {disconnectText: ({integration}: {integration: string}) => `Para deshabilitar RR. HH., primero desconecta ${integration} de este espacio de trabajo.`}, workflowWarningModal: { featureEnabledTitle: 'No tan rápido...', featureEnabledText: @@ -6612,6 +6662,7 @@ ${amount} para ${merchant} - ${date}`, subtitle: 'Establecer una tarifa por hora facturable para el seguimiento de tiempo.', defaultHourlyRate: 'Tarifa por hora predeterminada', }, + recruitingWarningModal: {disconnectText: ({integration}: {integration: string}) => `Para desactivar Recruiting, primero desconecta ${integration} de este espacio de trabajo.`}, }, reports: { reportsCustomTitleExamples: 'Ejemplos:', @@ -7065,6 +7116,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, disconnect: 'Desconectar', reinstall: 'Reinstalar el conector', disconnectTitle: (connectionName = 'integración') => `Desconectar ${connectionName}`, + connectTitle: (connectionName) => `Conectar ${connectionName}`, syncError: (connectionName) => `No se puede conectar a ${connectionName}`, accounts: 'Plan de cuentas', taxes: 'Impuestos', @@ -7081,10 +7133,12 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'Predeterminado del empleado NetSuite', }, disconnectPrompt: (connectionName = 'integración') => `¿Estás seguro de que quieres desconectar ${connectionName}?`, + connectPrompt: (connectionName) => `¿Estás seguro de que quieres conectar a ${connectionName}? Esto eliminará cualquier conexión contable existente.`, enterCredentials: 'Ingresa tus credenciales', reconnect: 'Reconectar', updateCredentials: 'Actualizar credenciales', claimOffer: { + badgeText: '¡Oferta disponible!', xero: { headline: '¡Obtén Xero gratis por 6 meses!', description: @@ -7395,6 +7449,11 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, }, hr: { title: 'HR', + connectionsSubtitle: + 'Conéctate a tu sistema de RR. HH. para sincronizar los datos de empleados, emparejar automáticamente los reembolsos con las personas correctas y mantener los gastos de tu equipo precisos sin trabajo manual.', + subtitle: 'Conecta herramientas de HR y mantén sincronizadas las aprobaciones de empleados.', + alreadyConnectedTitle: 'No se puede conectar a varias plataformas de RR. HH.', + alreadyConnectedPrompt: 'Debes desconectar tu plataforma de RR. HH. actual antes de conectar otra.', connectionDescription: (providerName: string) => `Conecta ${providerName} para mantener sincronizadas las aprobaciones de empleados con tu espacio de trabajo.`, providerApprovalMode: (providerName: string) => `Modo de aprobación de ${providerName}`, providerFinalApprover: (providerName: string) => `Aprobador final de ${providerName}`, @@ -7446,9 +7505,14 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, }, recruiting: { title: 'Reclutamiento', + connectionsSubtitle: + 'Conéctate a tu sistema de selección para sincronizar los datos de los candidatos, vincular automáticamente los reembolsos con las personas correctas y mantener los gastos de tu equipo precisos sin trabajo manual.', + alreadyConnectedTitle: 'No se puede conectar a varias plataformas ATS', + alreadyConnectedPrompt: 'Debes desconectar tu ATS actual antes de conectar otro.', syncing: 'Sincronizando candidatos', setupIncomplete: (setupLink: string | undefined) => `Conectado. ${setupLink ? `Completar configuración` : 'Completar configuración'} para importar candidatos.`, + dontSeeYourATS: `¿No ves tu ATS aquí? Pregunta a Concierge y podremos añadirlo.`, importSettings: 'Importar ajustes', defaultApprover: 'Aprobador predeterminado', approverFields: {recruiter: 'Reclutador', recruitingCoordinator: 'Coordinador de selección'}, @@ -7474,6 +7538,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, }, enableJobStagesOrTags: 'Habilita las etapas de trabajo o las etiquetas para continuar', }, + subtitle: 'Conecta herramientas de selección y mantén sincronizadas las aprobaciones de candidatos.', syncResults: { importedCount: () => ({ one: '1 candidato', @@ -7492,6 +7557,9 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, }, }, merge: { + connections: 'Conexiones', + connect: 'Conectar', + findIntegration: 'Buscar integración', syncNow: 'Sincronizar ahora', disconnect: 'Desconectar', disconnectTitle: (providerName: string) => `Desconectar ${providerName}`, @@ -8795,33 +8863,6 @@ ${reportName}`, noPaymentMethodsFound: 'No se encontraron formas de pago', noPaymentMethodsFoundDescription: 'Añade formas de pago en Dynamics 365 Business Central y vuelve a sincronizar la conexión', }, - connections: { - tabs: { - [CONST.TAB.CONNECTIONS.ALL]: 'Todo', - [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Contabilidad', - [CONST.TAB.CONNECTIONS.PEOPLE]: 'RR. HH. y Personas', - [CONST.TAB.CONNECTIONS.RECRUITING]: 'Reclutamiento', - [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Recibos', - [CONST.TAB.CONNECTIONS.AI]: 'IA y MCP', - }, - findConnections: 'Buscar conexiones', - configure: 'Configurar', - fix: 'Corregir', - active: 'Activo', - broken: 'Roto', - brokenConnection: 'No se puede sincronizar debido a una conexión interrumpida', - replaceConnectionTitle: '¿Reemplazar la conexión?', - offer: 'Oferta', - replaceConnectionPrompt: (connectionName: string) => `Esto eliminará tu conexión actual de ${connectionName}.`, - hrisListing: (providerName: string) => `${providerName} (HRIS)`, - atsListing: (providerName: string) => `${providerName} (ATS)`, - suggestIntegration: '¿No ves la tuya? Sugiere una integración y lo estudiaremos.', - noResultsPrompt: 'Ajusta tu búsqueda o', - suggestAnIntegration: 'sugiere una integración', - noResultsPromptEnd: '.', - allConnectedTitle: 'Ya está todo conectado', - allConnectedPrompt: 'Todo aquí ya está conectado. También puedes', - }, }, getAssistancePage: { title: 'Obtener ayuda', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index d53cfbd2fa0c..fe229ab98a2b 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3573,15 +3573,16 @@ ${amount} pour ${merchant} - ${date}`, `Connecter${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '' : 'à'} [${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'votre' : ''} ${integrationName}](${workspaceAccountingLink})`, description: ({integrationName, workspaceAccountingLink}) => Str.dedent(` - Connectez ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'votre' : 'à'} ${integrationName} pour un codage automatique des dépenses et une synchronisation qui rendront la clôture de fin de mois très simple. + Connectez ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'votre' : 'à'} ${integrationName} pour profiter d’un codage automatique des dépenses et d’une synchronisation qui simplifient la clôture de fin de mois. 1. Cliquez sur *Espaces de travail*. 2. Sélectionnez votre espace de travail. - 3. Cliquez sur *Connexions*. - 4. Trouvez ${integrationName}. + 3. Cliquez sur *Comptabilité*. + 4. Recherchez ${integrationName}. 5. Cliquez sur *Connecter*. - [Aller aux connexions](${workspaceAccountingLink}).`), + [Accéder à la comptabilité](${workspaceAccountingLink}). + `), }, connectCorporateCardTask: { title: ({corporateCardLink}) => `Connectez [vos cartes d’entreprise](${corporateCardLink})`, @@ -4906,16 +4907,51 @@ ${amount} pour ${merchant} - ${date}`, description: "Excellente nouvelle 🎉. Contactez-nous si votre client a besoin d'aide pour la configuration.", }, mcp: { + connectors: 'Connecteurs', + connectorsSubtitle: 'Connectez un assistant IA à votre compte Expensify.', + connect: 'Connecter', + helpPrompt: "Besoin d'aide pour la connexion ?", + helpLink: 'Consultez notre guide.', claude: { title: 'Claude', + subtitle: 'par Anthropic', }, cursor: { title: 'Cursor', + subtitle: 'par Anysphere', }, chatgpt: { title: 'ChatGPT', + subtitle: 'par OpenAI', }, }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Tous', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Comptabilité', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'RH & Personnel', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Recrutement', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Reçus', + [CONST.TAB.CONNECTIONS.AI]: 'IA et MCP', + }, + findConnections: 'Trouver des connexions', + configure: 'Configurer', + fix: 'Corriger', + active: 'Actif', + broken: 'Cassé', + brokenConnection: 'Synchronisation impossible en raison d’une connexion interrompue', + replaceConnectionTitle: 'Remplacer la connexion ?', + offer: 'Offre', + replaceConnectionPrompt: (connectionName: string) => `Cela supprimera votre connexion ${connectionName} actuelle.`, + hrisListing: (providerName: string) => `${providerName} (SIRH)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: 'Vous ne voyez pas la vôtre ? Suggérez une intégration, nous allons l’examiner.', + noResultsPrompt: 'Veuillez modifier votre recherche ou', + suggestAnIntegration: 'suggérer une intégration', + noResultsPromptEnd: '.', + allConnectedTitle: 'Vous êtes entièrement connecté', + allConnectedPrompt: 'Tout est déjà connecté ici. Vous pouvez aussi', + }, receiptPartners: { uber: { subtitle: (organizationName: string) => @@ -6492,6 +6528,10 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. title: 'Organiser', subtitle: 'Regroupez et analysez les dépenses, enregistrez chaque taxe payée.', }, + integrateSection: { + title: 'Intégrer', + subtitle: 'Connectez Expensify à des produits financiers populaires.', + }, distanceRates: { title: 'Taux de distance', subtitle: 'Ajoutez, mettez à jour et appliquez les taux.', @@ -6724,18 +6764,27 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. }, connections: { title: 'Comptabilité', + subtitle: 'Synchronisez votre plan comptable et plus encore.', + }, + mcp: { + title: 'MCP', + subtitle: 'Connectez un assistant IA à votre compte Expensify.', }, receiptPartners: { title: 'Partenaires de reçus', + subtitle: 'Importer automatiquement les reçus.', }, connectionsWarningModal: { featureEnabledTitle: 'Pas si vite…', featureEnabledText: 'Pour activer ou désactiver cette fonctionnalité, vous devez modifier vos paramètres d’import comptable.', + disconnectText: 'Pour désactiver la comptabilité, vous devez déconnecter votre connexion comptable de votre espace de travail.', manageSettings: 'Gérer les paramètres', }, receiptPartnersWarningModal: { featureEnabledTitle: 'Dissocier Uber', + disconnectText: 'Pour désactiver cette fonctionnalité, veuillez d’abord déconnecter l’intégration Uber for Business.', description: 'Voulez-vous vraiment déconnecter cette intégration ?', + confirmText: 'Compris', }, workflowWarningModal: { featureEnabledTitle: 'Pas si vite…', @@ -6752,12 +6801,16 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. subtitle: 'Définissez un taux horaire facturable pour le suivi du temps.', defaultHourlyRate: 'Taux horaire par défaut', }, + hrWarningModal: {disconnectText: ({integration}: {integration: string}) => `Pour désactiver les RH, veuillez d'abord déconnecter ${integration} de cet espace de travail.`}, vendors: { title: 'Fournisseurs', subtitle: 'Faites correspondre les dépenses de carte aux fournisseurs importés depuis votre logiciel de comptabilité.', disabledTitle: 'Pas si vite...', disabledMessage: 'Pour activer ou désactiver cette fonctionnalité, vous devrez modifier vos paramètres d’importation comptable.', }, + recruitingWarningModal: { + disconnectText: ({integration}: {integration: string}) => `Pour désactiver le recrutement, veuillez d’abord déconnecter ${integration} de cet espace de travail.`, + }, }, reports: { reportsCustomTitleExamples: 'Exemples :', @@ -7276,6 +7329,7 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, disconnect: 'Déconnecter', reinstall: 'Réinstaller le connecteur', disconnectTitle: (connectionName = 'intégration') => `Déconnecter ${connectionName}`, + connectTitle: (connectionName) => `Connecter ${connectionName}`, syncError: (connectionName) => `Impossible de se connecter à ${connectionName}`, accounts: 'Plan comptable', taxes: 'Taxes', @@ -7292,10 +7346,12 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'Par défaut employé NetSuite', }, disconnectPrompt: (connectionName = 'cette intégration') => `Voulez-vous vraiment déconnecter ${connectionName} ?`, + connectPrompt: (connectionName) => `Voulez-vous vraiment connecter ${connectionName} ? Cette action supprimera toutes les connexions comptables existantes.`, enterCredentials: 'Saisissez vos identifiants', reconnect: 'Reconnecter', updateCredentials: 'Mettre à jour les identifiants', claimOffer: { + badgeText: 'Offre disponible !', xero: { headline: 'Obtenez Xero gratuitement pendant 6 mois !', description: @@ -8697,6 +8753,11 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e }, hr: { title: 'RH', + connectionsSubtitle: + 'Connectez votre système RH pour synchroniser les données des employé·es, faire correspondre automatiquement les remboursements aux bonnes personnes et garder les dépenses de votre équipe exactes, sans travail manuel.', + subtitle: 'Connectez vos outils RH et gardez les approbations des employés synchronisées.', + alreadyConnectedTitle: 'Impossible de se connecter à plusieurs plateformes RH', + alreadyConnectedPrompt: 'Vous devez déconnecter votre plateforme RH actuelle avant d’en connecter une autre.', connectionDescription: (providerName: string) => `Connectez ${providerName} pour synchroniser les approbations des employés avec votre espace de travail.`, providerApprovalMode: (providerName: string) => `Mode d'approbation ${providerName}`, providerFinalApprover: (providerName: string) => `Approbateur final ${providerName}`, @@ -8748,9 +8809,14 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e }, recruiting: { title: 'Recrutement', + connectionsSubtitle: + 'Connectez votre système de recrutement pour synchroniser les données des candidats, faire correspondre automatiquement les remboursements aux bonnes personnes et maintenir des dépenses d’équipe exactes sans travail manuel.', + alreadyConnectedTitle: 'Impossible de se connecter à plusieurs plateformes ATS', + alreadyConnectedPrompt: 'Vous devez déconnecter votre ATS actuel avant d’en connecter un autre.', syncing: 'Synchronisation des candidats', setupIncomplete: (setupLink: string | undefined) => `Connecté. ${setupLink ? `Terminer la configuration` : 'Terminer la configuration'} pour importer des candidats.`, + dontSeeYourATS: `Vous ne voyez pas votre ATS ici ? Demandez à Concierge et nous pourrons l’ajouter.`, importSettings: 'Paramètres d’importation', defaultApprover: 'Approbateur par défaut', approverFields: {recruiter: 'Recruteur', recruitingCoordinator: 'Coordinateur recrutement'}, @@ -8777,6 +8843,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e }, enableJobStagesOrTags: 'Activez les étapes de mission ou les Tags pour continuer', }, + subtitle: 'Connectez les outils de recrutement et synchronisez les validations de candidats.', syncResults: { importedCount: () => ({ one: '1 candidat', @@ -8796,6 +8863,9 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e }, }, merge: { + connections: 'Connexions', + connect: 'Connecter', + findIntegration: 'Rechercher une intégration', syncNow: 'Synchroniser maintenant', disconnect: 'Déconnecter', disconnectTitle: (providerName: string) => `Déconnecter ${providerName}`, @@ -8988,33 +9058,6 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e noPaymentMethodsFound: 'Aucun mode de règlement trouvé', noPaymentMethodsFoundDescription: 'Veuillez ajouter des modes de règlement dans Dynamics 365 Business Central et synchroniser à nouveau la connexion', }, - connections: { - tabs: { - [CONST.TAB.CONNECTIONS.ALL]: 'Tous', - [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Comptabilité', - [CONST.TAB.CONNECTIONS.PEOPLE]: 'RH & Personnel', - [CONST.TAB.CONNECTIONS.RECRUITING]: 'Recrutement', - [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Reçus', - [CONST.TAB.CONNECTIONS.AI]: 'IA et MCP', - }, - findConnections: 'Trouver des connexions', - configure: 'Configurer', - fix: 'Corriger', - active: 'Actif', - broken: 'Cassé', - brokenConnection: 'Synchronisation impossible en raison d’une connexion interrompue', - replaceConnectionTitle: 'Remplacer la connexion ?', - offer: 'Offre', - replaceConnectionPrompt: (connectionName: string) => `Cela supprimera votre connexion ${connectionName} actuelle.`, - hrisListing: (providerName: string) => `${providerName} (SIRH)`, - atsListing: (providerName: string) => `${providerName} (ATS)`, - suggestIntegration: 'Vous ne voyez pas la vôtre ? Suggérez une intégration, nous allons l’examiner.', - noResultsPrompt: 'Veuillez modifier votre recherche ou', - suggestAnIntegration: 'suggérer une intégration', - noResultsPromptEnd: '.', - allConnectedTitle: 'Vous êtes entièrement connecté', - allConnectedPrompt: 'Tout est déjà connecté ici. Vous pouvez aussi', - }, }, getAssistancePage: { title: 'Obtenir de l’aide', diff --git a/src/languages/it.ts b/src/languages/it.ts index f151e882ca48..c5c0fec307e6 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3551,15 +3551,16 @@ ${amount} per ${merchant} - ${date}`, `Collega${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '' : 'a'} [${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'il tuo' : ''} ${integrationName}](${workspaceAccountingLink})`, description: ({integrationName, workspaceAccountingLink}) => Str.dedent(` - Collega ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'tuo' : 'a'} ${integrationName} per la codifica automatica delle spese e la sincronizzazione che rendono la chiusura di fine mese semplicissima. + Collega ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'il tuo' : 'a'} ${integrationName} per la codifica automatica delle spese e la sincronizzazione che rendono la chiusura di fine mese semplicissima. - 1. Clicca su *Workspace*. - 2. Seleziona il tuo workspace. - 3. Clicca su *Connessioni*. + 1. Fai clic su *Spazi di lavoro*. + 2. Seleziona il tuo spazio di lavoro. + 3. Fai clic su *Contabilità*. 4. Trova ${integrationName}. - 5. Clicca su *Connetti*. + 5. Fai clic su *Connetti*. - [Portami alle connessioni](${workspaceAccountingLink}).`), + [Portami alla contabilità](${workspaceAccountingLink}). + `), }, connectCorporateCardTask: { title: ({corporateCardLink}) => `Collega [le tue carte aziendali](${corporateCardLink})`, @@ -4869,15 +4870,50 @@ ${amount} per ${merchant} - ${date}`, description: 'Ottime notizie 🎉. Contattaci se hanno bisogno di aiuto con la configurazione.', }, mcp: { + connectors: 'Connettori', + connectorsSubtitle: 'Collega un assistente IA al tuo account Expensify.', + connect: 'Connetti', + helpPrompt: 'Hai bisogno di aiuto con la connessione?', + helpLink: 'Leggi la nostra guida.', claude: { title: 'Claude', + subtitle: 'di Anthropic', }, cursor: { title: 'Cursor', + subtitle: 'di Anysphere', }, chatgpt: { title: 'ChatGPT', + subtitle: 'di OpenAI', + }, + }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Tutto', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Contabilità', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'Risorse umane e persone', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Assunzioni', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Ricevute', + [CONST.TAB.CONNECTIONS.AI]: 'IA e MCP', }, + findConnections: 'Trova connessioni', + configure: 'Configura', + fix: 'Correggi', + active: 'Attivo', + broken: 'Guasto', + brokenConnection: 'Impossibile sincronizzare a causa di una connessione interrotta', + replaceConnectionTitle: 'Sostituire la connessione?', + offer: 'Offerta', + replaceConnectionPrompt: (connectionName: string) => `Questo rimuoverà la tua connessione ${connectionName} attuale.`, + hrisListing: (providerName: string) => `${providerName} (HRIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: "Non vedi la tua? Suggerisci un'integrazione, la valuteremo.", + noResultsPrompt: 'Modifica la tua ricerca oppure', + suggestAnIntegration: "suggerisci un'integrazione", + noResultsPromptEnd: '.', + allConnectedTitle: 'Sei tutto connesso', + allConnectedPrompt: 'Qui è già tutto collegato. Puoi anche', }, receiptPartners: { uber: { @@ -6439,6 +6475,10 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. title: 'Organizza', subtitle: 'Raggruppa e analizza le spese, registra ogni imposta pagata.', }, + integrateSection: { + title: 'Integra', + subtitle: 'Collega Expensify ai più diffusi prodotti finanziari.', + }, distanceRates: { title: 'Tariffe chilometriche', subtitle: 'Aggiungi, aggiorna e applica le tariffe.', @@ -6670,18 +6710,27 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. }, connections: { title: 'Contabilità', + subtitle: 'Sincronizza il tuo piano dei conti e altro ancora.', + }, + mcp: { + title: 'MCP', + subtitle: 'Collega un assistente IA al tuo account Expensify.', }, receiptPartners: { title: 'Partner ricevute', + subtitle: 'Importa automaticamente le ricevute.', }, connectionsWarningModal: { featureEnabledTitle: 'Non così in fretta...', featureEnabledText: 'Per attivare o disattivare questa funzionalità, devi modificare le impostazioni di importazione contabile.', + disconnectText: 'Per disattivare la contabilità, devi disconnettere la connessione contabile dal tuo spazio di lavoro.', manageSettings: 'Gestisci impostazioni', }, receiptPartnersWarningModal: { featureEnabledTitle: 'Disconnetti Uber', + disconnectText: 'Per disattivare questa funzionalità, disconnetti prima l’integrazione Uber for Business.', description: 'Sei sicuro di voler disconnettere questa integrazione?', + confirmText: 'Ho capito', }, workflowWarningModal: { featureEnabledTitle: 'Non così in fretta...', @@ -6698,12 +6747,14 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. subtitle: 'Imposta una tariffa oraria fatturabile per il rilevamento del tempo.', defaultHourlyRate: 'Tariffa oraria predefinita', }, + hrWarningModal: {disconnectText: ({integration}: {integration: string}) => `Per disattivare HR, scollega prima ${integration} da questo workspace.`}, vendors: { title: 'Fornitori', subtitle: 'Abbina le spese della carta ai fornitori importati dal tuo software di contabilità.', disabledTitle: 'Non così in fretta...', disabledMessage: 'Per abilitare o disabilitare questa funzione, dovrai modificare le impostazioni di importazione della contabilità.', }, + recruitingWarningModal: {disconnectText: ({integration}: {integration: string}) => `Per disattivare Recruiting, scollega prima ${integration} da questo spazio di lavoro.`}, }, reports: { reportsCustomTitleExamples: 'Esempi:', @@ -7217,6 +7268,7 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, disconnect: 'Disconnetti', reinstall: 'Reinstalla connettore', disconnectTitle: (connectionName = 'integrazione') => `Disconnetti ${connectionName}`, + connectTitle: (connectionName) => `Collega ${connectionName}`, syncError: (connectionName) => `Impossibile connettersi a ${connectionName}`, accounts: 'Piano dei conti', taxes: 'Tasse', @@ -7233,10 +7285,12 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'Impostazione predefinita dipendente NetSuite', }, disconnectPrompt: (connectionName = 'questa integrazione') => `Sei sicuro di voler disconnettere ${connectionName}?`, + connectPrompt: (connectionName) => `Sei sicuro di voler collegare ${connectionName}? Questo rimuoverà tutte le connessioni contabili esistenti.`, enterCredentials: 'Inserisci le tue credenziali', reconnect: 'Riconnetti', updateCredentials: 'Aggiorna credenziali', claimOffer: { + badgeText: 'Offerta disponibile!', xero: { headline: 'Ottieni Xero gratis per 6 mesi!', description: 'Nuovo su Xero? I clienti Expensify ricevono 6 mesi gratis. Richiedi la tua offerta qui sotto.', @@ -8626,6 +8680,11 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, hr: { title: 'Risorse umane', + connectionsSubtitle: + 'Collega il tuo sistema HR per sincronizzare i dati dei dipendenti, abbinare automaticamente i rimborsi alle persone giuste e mantenere le spese del tuo team accurate senza lavoro manuale.', + subtitle: 'Collega gli strumenti HR e mantieni sincronizzate le approvazioni dei dipendenti.', + alreadyConnectedTitle: 'Impossibile connettersi a più piattaforme HR', + alreadyConnectedPrompt: 'Devi disconnettere la tua attuale piattaforma HR prima di collegarne un’altra.', connectionDescription: (providerName: string) => `Collega ${providerName} per mantenere sincronizzate le approvazioni dei dipendenti con il tuo spazio di lavoro.`, providerApprovalMode: (providerName: string) => `Modalità di approvazione ${providerName}`, providerFinalApprover: (providerName: string) => `Approvatore finale ${providerName}`, @@ -8677,9 +8736,14 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, recruiting: { title: 'Assunzioni', + connectionsSubtitle: + 'Connetti il tuo sistema di recruiting per sincronizzare i dati dei candidati, abbinare automaticamente i rimborsi alle persone giuste e mantenere le spese del team accurate senza lavoro manuale.', + alreadyConnectedTitle: 'Impossibile connettersi a più piattaforme ATS', + alreadyConnectedPrompt: 'Devi disconnettere il tuo ATS attuale prima di collegarne un altro.', syncing: 'Sincronizzazione candidati', setupIncomplete: (setupLink: string | undefined) => `Connesso. ${setupLink ? `Completa la configurazione` : 'Completa configurazione'} per importare i candidati.`, + dontSeeYourATS: `Non vedi il tuo ATS qui? Chiedi a Concierge e possiamo aggiungerlo.`, importSettings: 'Impostazioni di importazione', defaultApprover: 'Approvatore predefinito', approverFields: {recruiter: 'Recruiter', recruitingCoordinator: 'Coordinatore recruiting'}, @@ -8705,6 +8769,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, enableJobStagesOrTags: 'Abilita le fasi di lavoro o i tag per continuare', }, + subtitle: 'Collega gli strumenti di recruiting e mantieni sincronizzate le approvazioni dei candidati.', syncResults: { importedCount: () => ({ one: '1 candidato', @@ -8724,6 +8789,9 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, }, merge: { + connections: 'Connessioni', + connect: 'Connetti', + findIntegration: 'Trova integrazione', syncNow: 'Sincronizza ora', disconnect: 'Disconnetti', disconnectTitle: (providerName: string) => `Disconnetti ${providerName}`, @@ -8913,33 +8981,6 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, noPaymentMethodsFound: 'Nessun metodo di pagamento trovato', noPaymentMethodsFoundDescription: 'Aggiungi metodi di pagamento in Dynamics 365 Business Central e sincronizza di nuovo la connessione', }, - connections: { - tabs: { - [CONST.TAB.CONNECTIONS.ALL]: 'Tutto', - [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Contabilità', - [CONST.TAB.CONNECTIONS.PEOPLE]: 'Risorse umane e persone', - [CONST.TAB.CONNECTIONS.RECRUITING]: 'Assunzioni', - [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Ricevute', - [CONST.TAB.CONNECTIONS.AI]: 'IA e MCP', - }, - findConnections: 'Trova connessioni', - configure: 'Configura', - fix: 'Correggi', - active: 'Attivo', - broken: 'Guasto', - brokenConnection: 'Impossibile sincronizzare a causa di una connessione interrotta', - replaceConnectionTitle: 'Sostituire la connessione?', - offer: 'Offerta', - replaceConnectionPrompt: (connectionName: string) => `Questo rimuoverà la tua connessione ${connectionName} attuale.`, - hrisListing: (providerName: string) => `${providerName} (HRIS)`, - atsListing: (providerName: string) => `${providerName} (ATS)`, - suggestIntegration: "Non vedi la tua? Suggerisci un'integrazione, la valuteremo.", - noResultsPrompt: 'Modifica la tua ricerca oppure', - suggestAnIntegration: "suggerisci un'integrazione", - noResultsPromptEnd: '.', - allConnectedTitle: 'Sei tutto connesso', - allConnectedPrompt: 'Qui è già tutto collegato. Puoi anche', - }, }, getAssistancePage: { title: 'Ottieni assistenza', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index ce05015a2d7e..c054ff7be3ed 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3522,15 +3522,16 @@ ${date} の ${merchant} への ${amount}`, `接続${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '' : 'から へ'} [${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' : ''} ${integrationName}](${workspaceAccountingLink})`, description: ({integrationName, workspaceAccountingLink}) => Str.dedent(` -${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' : '宛先'} ${integrationName} を連携して、経費の自動仕訳と同期を行い、月次決算をスムーズにしましょう。 +${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' : '宛先'} ${integrationName} を接続して、自動経費コード設定と同期を行い、月末締めをスムーズにしましょう。 - 1. 「*ワークスペース*」をクリックします。 + 1.「*ワークスペース*」をクリックします。 2. ワークスペースを選択します。 - 3. 「*接続*」をクリックします。 + 3.「*会計*」をクリックします。 4. ${integrationName} を探します。 - 5. 「*接続*」をクリックします。 + 5.「*接続*」をクリックします。 - [接続画面へ移動](${workspaceAccountingLink})。`), + [経理画面に移動](${workspaceAccountingLink})。 + `), }, connectCorporateCardTask: { title: ({corporateCardLink}) => `[法人カード](${corporateCardLink})を連携`, @@ -4832,16 +4833,51 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' description: '素晴らしいニュースです 🎉。セットアップにサポートが必要な場合はお問い合わせください。', }, mcp: { + connectors: 'コネクタ', + connectorsSubtitle: 'AIアシスタントをExpensifyアカウントに接続します。', + connect: '接続', + helpPrompt: '接続にお困りですか?', + helpLink: 'ガイドをご覧ください。', claude: { title: 'Claude', + subtitle: 'Anthropic 提供', }, cursor: { title: 'Cursor', + subtitle: 'Anysphere 提供', }, chatgpt: { title: 'ChatGPT', + subtitle: 'OpenAI 提供', }, }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'すべて', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: '会計', + [CONST.TAB.CONNECTIONS.PEOPLE]: '人事・人材', + [CONST.TAB.CONNECTIONS.RECRUITING]: '採用', + [CONST.TAB.CONNECTIONS.RECEIPTS]: '領収書', + [CONST.TAB.CONNECTIONS.AI]: 'AI と MCP', + }, + findConnections: '連携先を見つける', + configure: '設定', + fix: '修正', + active: '有効', + broken: '壊れています', + brokenConnection: '接続が切断されているため同期できません', + replaceConnectionTitle: '接続を置き換えますか?', + offer: 'オファー', + replaceConnectionPrompt: (connectionName: string) => `現在の ${connectionName} 接続が削除されます。`, + hrisListing: (providerName: string) => `${providerName}(HRIS)`, + atsListing: (providerName: string) => `${providerName}(ATS)`, + suggestIntegration: 'お探しのものが見つかりませんか?連携を提案してください、こちらで検討します。', + noResultsPrompt: '検索条件を調整するか', + suggestAnIntegration: '連携を提案する', + noResultsPromptEnd: '。', + allConnectedTitle: 'すべて接続されました', + allConnectedPrompt: 'ここにあるものはすべてすでに連携されています。さらに、次のこともできます', + }, receiptPartners: { uber: { subtitle: (organizationName: string) => (organizationName ? `${organizationName} に接続しました` : '組織全体の出張費や飲食デリバリー経費を自動化しましょう。'), @@ -6364,6 +6400,10 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO title: '整理', subtitle: '支出をグループ化して分析し、支払ったすべての税金を記録します。', }, + integrateSection: { + title: '連携する', + subtitle: 'Expensify を人気の金融サービスに接続しましょう。', + }, distanceRates: { title: '距離単価', subtitle: 'レートを追加、更新し、適用します。', @@ -6580,18 +6620,27 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO }, connections: { title: '会計', + subtitle: '勘定科目表などを同期します。', + }, + mcp: { + title: 'MCP', + subtitle: 'AIアシスタントをExpensifyアカウントに接続します。', }, receiptPartners: { title: '領収書パートナー', + subtitle: '領収書を自動で取り込みます。', }, connectionsWarningModal: { featureEnabledTitle: 'ちょっと待ってください…', featureEnabledText: 'この機能を有効または無効にするには、会計インポート設定を変更する必要があります。', + disconnectText: '会計機能を無効にするには、ワークスペースから会計連携を切断する必要があります。', manageSettings: '設定を管理', }, receiptPartnersWarningModal: { featureEnabledTitle: 'Uber との連携を解除', + disconnectText: 'この機能を無効にするには、まず Uber for Business の連携を解除してください。', description: 'この連携を解除してもよろしいですか?', + confirmText: '了解しました', }, workflowWarningModal: { featureEnabledTitle: 'ちょっと待ってください…', @@ -6608,12 +6657,16 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO subtitle: '時間追跡用の請求可能な時間単価を設定します。', defaultHourlyRate: 'デフォルトの時給率', }, + hrWarningModal: {disconnectText: ({integration}: {integration: string}) => `HR を無効にするには、まずこのワークスペースから ${integration} を切断してください。`}, vendors: { title: 'ベンダー', subtitle: '会計ソフトからインポートした取引先にカード経費を照合します。', disabledTitle: 'ちょっと待ってください...', disabledMessage: 'この機能を有効または無効にするには、会計インポート設定を変更する必要があります。', }, + recruitingWarningModal: { + disconnectText: ({integration}: {integration: string}) => `採用機能を無効にするには、まずこのワークスペースから ${integration} の連携を解除してください。`, + }, }, reports: { reportsCustomTitleExamples: '例:', @@ -7125,6 +7178,7 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から disconnect: '切断', reinstall: 'コネクタを再インストール', disconnectTitle: (connectionName = '連携') => `${connectionName}の接続を解除`, + connectTitle: (connectionName) => `${connectionName} を接続`, syncError: (connectionName) => `${connectionName} に接続できません`, accounts: '勘定科目表', taxes: '税金', @@ -7141,10 +7195,12 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'NetSuite 従業員のデフォルト', }, disconnectPrompt: (connectionName = 'この連携') => `${connectionName} の接続を本当に解除しますか?`, + connectPrompt: (connectionName) => `${connectionName} を接続してもよろしいですか?これにより、既存の会計連携はすべて削除されます。`, enterCredentials: '認証情報を入力してください', reconnect: '再接続', updateCredentials: '認証情報を更新', claimOffer: { + badgeText: 'オファーをご利用いただけます!', xero: { headline: 'Xero を6か月間無料で利用しましょう!', description: @@ -8515,6 +8571,10 @@ ${reportName}`, }, hr: { title: '人事', + connectionsSubtitle: '人事システムと連携して従業員データを同期し、精算を自動で正しい担当者に紐づけることで、手作業なしでチームの経費を正確に管理できます。', + subtitle: '人事ツールを連携して、従業員の承認を常に同期させます。', + alreadyConnectedTitle: '複数の人事プラットフォームには接続できません', + alreadyConnectedPrompt: '別の人事プラットフォームに接続する前に、現在の人事プラットフォームとの接続を解除する必要があります。', connectionDescription: (providerName: string) => `${providerName}を接続して、従業員の承認をワークスペースと同期させましょう。`, providerApprovalMode: (providerName: string) => `${providerName} 承認モード`, providerFinalApprover: (providerName: string) => `${providerName} 最終承認者`, @@ -8566,9 +8626,13 @@ ${reportName}`, }, recruiting: { title: '採用', + connectionsSubtitle: '採用システムに接続して候補者データを同期し、払い戻しを自動的に適切な人に紐づけることで、手作業なしでチームの経費精度を保てます。', + alreadyConnectedTitle: '複数の ATS プラットフォームには接続できません', + alreadyConnectedPrompt: '別の ATS を接続する前に、現在の ATS を必ず切断する必要があります。', syncing: '候補者を同期しています', setupIncomplete: (setupLink: string | undefined) => `接続済み。${setupLink ? `セットアップを完了` : '設定を完了'} をインポート候補として追加します。`, + dontSeeYourATS: `ご利用のATSが見つかりませんか?Concierge にお問い合わせいただければ、追加できます。`, importSettings: 'インポート設定', defaultApprover: 'デフォルト承認者', approverFields: {recruiter: '採用担当者', recruitingCoordinator: '採用コーディネーター'}, @@ -8589,6 +8653,7 @@ ${reportName}`, offices: {title: 'オフィス', description: 'このワークスペースと同期したい候補者のオフィスを選択してください', toggleTitle: 'オフィス', allSelected: 'すべてのオフィス'}, enableJobStagesOrTags: '続行するにはジョブステージまたはタグを有効にしてください', }, + subtitle: '採用ツールを連携して、候補者の承認を常に同期させます。', syncResults: { importedCount: () => ({ one: '1 名の候補者', @@ -8608,6 +8673,9 @@ ${reportName}`, }, }, merge: { + connections: '接続', + connect: '接続', + findIntegration: '連携を検索', syncNow: '今すぐ同期', disconnect: '切断', disconnectTitle: (providerName: string) => `${providerName}を切断`, @@ -8791,33 +8859,6 @@ ${reportName}`, noPaymentMethodsFound: '支払方法が見つかりません', noPaymentMethodsFoundDescription: 'Dynamics 365 Business Central で支払方法を追加し、接続を再度同期してください', }, - connections: { - tabs: { - [CONST.TAB.CONNECTIONS.ALL]: 'すべて', - [CONST.TAB.CONNECTIONS.ACCOUNTING]: '会計', - [CONST.TAB.CONNECTIONS.PEOPLE]: '人事・人材', - [CONST.TAB.CONNECTIONS.RECRUITING]: '採用', - [CONST.TAB.CONNECTIONS.RECEIPTS]: '領収書', - [CONST.TAB.CONNECTIONS.AI]: 'AI と MCP', - }, - findConnections: '連携先を見つける', - configure: '設定', - fix: '修正', - active: '有効', - broken: '壊れています', - brokenConnection: '接続が切断されているため同期できません', - replaceConnectionTitle: '接続を置き換えますか?', - offer: 'オファー', - replaceConnectionPrompt: (connectionName: string) => `現在の ${connectionName} 接続が削除されます。`, - hrisListing: (providerName: string) => `${providerName}(HRIS)`, - atsListing: (providerName: string) => `${providerName}(ATS)`, - suggestIntegration: 'お探しのものが見つかりませんか?連携を提案してください、こちらで検討します。', - noResultsPrompt: '検索条件を調整するか', - suggestAnIntegration: '連携を提案する', - noResultsPromptEnd: '。', - allConnectedTitle: 'すべて接続されました', - allConnectedPrompt: 'ここにあるものはすべてすでに連携されています。さらに、次のこともできます', - }, }, getAssistancePage: { title: 'サポートを受ける', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index fc36b5a69249..6e611fde922b 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3554,15 +3554,16 @@ ${amount} voor ${merchant} - ${date}`, `Verbind${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '' : 'naar'} [${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'jouw' : ''} ${integrationName}](${workspaceAccountingLink})`, description: ({integrationName, workspaceAccountingLink}) => Str.dedent(` - Verbind ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'je' : 'naar'} ${integrationName} voor automatische onkostencodering en -synchronisatie, zodat de maandafsluiting moeiteloos gaat. + Verbind ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'jouw' : 'naar'} ${integrationName} voor automatische kosten­codering en synchronisatie, zodat de maandafsluiting een fluitje van een cent wordt. 1. Klik op *Workspaces*. 2. Selecteer je workspace. - 3. Klik op *Connections*. + 3. Klik op *Accounting*. 4. Zoek ${integrationName}. 5. Klik op *Connect*. - [Ga naar verbindingen](${workspaceAccountingLink}).`), + [Breng me naar boekhouding](${workspaceAccountingLink}). + `), }, connectCorporateCardTask: { title: ({corporateCardLink}) => `Verbind [je zakelijke kaarten](${corporateCardLink})`, @@ -4871,16 +4872,51 @@ ${amount} voor ${merchant} - ${date}`, description: 'Geweldig nieuws 🎉. Neem contact met ons op als ze hulp nodig hebben bij de configuratie.', }, mcp: { + connectors: 'Connectoren', + connectorsSubtitle: 'Verbind een AI-assistent met je Expensify-account.', + connect: 'Verbinden', + helpPrompt: 'Hulp nodig bij het verbinden?', + helpLink: 'Lees onze handleiding.', claude: { title: 'Claude', + subtitle: 'van Anthropic', }, cursor: { title: 'Cursor', + subtitle: 'van Anysphere', }, chatgpt: { title: 'ChatGPT', + subtitle: 'van OpenAI', }, }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Alles', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Boekhouding', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'HR & People', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Werving', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Bonnetjes', + [CONST.TAB.CONNECTIONS.AI]: 'AI & MCP', + }, + findConnections: 'Verbindingen zoeken', + configure: 'Configureren', + fix: 'Oplossen', + active: 'Actief', + broken: 'Defect', + brokenConnection: 'Kan niet synchroniseren vanwege een verbroken verbinding', + replaceConnectionTitle: 'Verbinding vervangen?', + offer: 'Aanbieding', + replaceConnectionPrompt: (connectionName: string) => `Hiermee verwijder je je huidige ${connectionName}-verbinding.`, + hrisListing: (providerName: string) => `${providerName} (HRIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: 'Zie je de jouwe niet? Stel een integratie voor, dan kijken we ernaar.', + noResultsPrompt: 'Pas je zoekopdracht aan of', + suggestAnIntegration: 'stel een integratie voor', + noResultsPromptEnd: '.', + allConnectedTitle: 'Je bent helemaal verbonden', + allConnectedPrompt: 'Alles hier is al verbonden. Je kunt ook', + }, receiptPartners: { uber: { subtitle: (organizationName: string) => (organizationName ? `Verbonden met ${organizationName}` : 'Automatiseer reis- en maaltijdbezorgingskosten in uw hele organisatie.'), @@ -6432,6 +6468,10 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ title: 'Organiseren', subtitle: 'Groepeer en analyseer uitgaven, leg elke betaalde belasting vast.', }, + integrateSection: { + title: 'Integreren', + subtitle: 'Koppel Expensify aan populaire financiële producten.', + }, distanceRates: { title: 'Kilometertarieven', subtitle: 'Tarieven toevoegen, bijwerken en afdwingen.', @@ -6655,18 +6695,27 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ }, connections: { title: 'Boekhouding', + subtitle: 'Synchroniseer je grootboekrekeningschema en meer.', + }, + mcp: { + title: 'MCP', + subtitle: 'Verbind een AI-assistent met je Expensify-account.', }, receiptPartners: { title: 'Bonpartners', + subtitle: 'Automatisch bonnetjes importeren.', }, connectionsWarningModal: { featureEnabledTitle: 'Niet zo snel...', featureEnabledText: 'Om deze functie in of uit te schakelen, moet je je instellingen voor boekhoudkundige import wijzigen.', + disconnectText: 'Om de boekhouding uit te schakelen, moet je de boekhoudkoppeling van je werkruimte loskoppelen.', manageSettings: 'Instellingen beheren', }, receiptPartnersWarningModal: { featureEnabledTitle: 'Verbinding met Uber verbreken', + disconnectText: 'Om deze functie uit te schakelen, verbreek eerst de koppeling met de Uber for Business-integratie.', description: 'Weet je zeker dat je deze integratie wilt ontkoppelen?', + confirmText: 'Begrepen', }, workflowWarningModal: { featureEnabledTitle: 'Niet zo snel...', @@ -6683,12 +6732,14 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ subtitle: 'Stel een factureerbaar uurtarief in voor tijdregistratie.', defaultHourlyRate: 'Standaard uurtarief', }, + hrWarningModal: {disconnectText: ({integration}: {integration: string}) => `Om HR uit te schakelen, koppel eerst ${integration} los van deze workspace.`}, vendors: { title: 'Leveranciers', subtitle: 'Koppel kaartuitgaven aan leveranciers die zijn geïmporteerd uit je boekhoudsoftware.', disabledTitle: 'Niet zo snel...', disabledMessage: 'Om deze functie in of uit te schakelen, moet je je boekhoudimportinstellingen wijzigen.', }, + recruitingWarningModal: {disconnectText: ({integration}: {integration: string}) => `Om Werving uit te schakelen, koppel ${integration} eerst los van deze workspace.`}, }, reports: { reportsCustomTitleExamples: 'Voorbeelden:', @@ -7201,6 +7252,7 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, disconnect: 'Verbinding verbreken', reinstall: 'Connector opnieuw installeren', disconnectTitle: (connectionName = 'integratie') => `Verbinding met ${connectionName} verbreken`, + connectTitle: (connectionName) => `Verbind ${connectionName}`, syncError: (connectionName) => `Kan geen verbinding maken met ${connectionName}`, accounts: 'Rekeningschema', taxes: 'Belastingen', @@ -7217,10 +7269,12 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'Standaard NetSuite-medewerker', }, disconnectPrompt: (connectionName = 'deze integratie') => `Weet je zeker dat je ${connectionName} wilt ontkoppelen?`, + connectPrompt: (connectionName) => `Weet je zeker dat je ${connectionName} wilt koppelen? Hierdoor worden alle bestaande boekhoudkundige koppelingen verwijderd.`, enterCredentials: 'Voer je inloggegevens in', reconnect: 'Opnieuw verbinden', updateCredentials: 'Inloggegevens bijwerken', claimOffer: { + badgeText: 'Aanbieding beschikbaar!', xero: { headline: 'Krijg Xero 6 maanden gratis!', description: 'Nieuw bij Xero? Expensify-klanten krijgen 6 maanden gratis. Claim jouw aanbieding hieronder.', @@ -8605,6 +8659,11 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, hr: { title: 'HR', + connectionsSubtitle: + 'Verbind met je HR-systeem om werknemersgegevens te synchroniseren, terugbetalingen automatisch aan de juiste personen te koppelen en de uitgaven van je team nauwkeurig te houden zonder handmatig werk.', + subtitle: 'Koppel HR-tools en houd goedkeuringen van medewerkers gesynchroniseerd.', + alreadyConnectedTitle: 'Kan niet met meerdere HR-platformen verbinden', + alreadyConnectedPrompt: 'Je moet je huidige HR-platform loskoppelen voordat je een ander kunt verbinden.', connectionDescription: (providerName: string) => `Verbind ${providerName} om goedkeuringen van werknemers gesynchroniseerd te houden met je werkruimte.`, providerApprovalMode: (providerName: string) => `${providerName}-goedkeuringsmodus`, providerFinalApprover: (providerName: string) => `Laatste ${providerName}-fiatteur`, @@ -8656,9 +8715,14 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, recruiting: { title: 'Werving', + connectionsSubtitle: + 'Koppel je recruitmentsysteem om kandidaatgegevens te synchroniseren, vergoedingen automatisch aan de juiste personen te koppelen en de uitgaven van je team nauwkeurig te houden zonder handmatig werk.', + alreadyConnectedTitle: 'Kan niet met meerdere ATS-platformen verbinden', + alreadyConnectedPrompt: 'Je moet je huidige ATS loskoppelen voordat je een andere verbindt.', syncing: 'Kandidaten synchroniseren', setupIncomplete: (setupLink: string | undefined) => `Verbonden. ${setupLink ? `Installatie voltooien` : 'Voltooi installatie'} om kandidaten te importeren.`, + dontSeeYourATS: `Zie je je ATS hier niet? Vraag het aan Concierge en we kunnen het toevoegen.`, importSettings: 'Importinstellingen', defaultApprover: 'Standaardgoedkeurder', approverFields: {recruiter: 'Recruiter', recruitingCoordinator: 'Coördinator werving'}, @@ -8684,6 +8748,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, enableJobStagesOrTags: 'Schakel functiestadia of labels in om door te gaan', }, + subtitle: 'Koppel wervingstools en houd kandidaategoedkeuringen gesynchroniseerd.', syncResults: { importedCount: () => ({ one: '1 kandidaat', @@ -8702,6 +8767,9 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, }, merge: { + connections: 'Verbindingen', + connect: 'Verbinden', + findIntegration: 'Integratie zoeken', syncNow: 'Nu synchroniseren', disconnect: 'Ontkoppelen', disconnectTitle: (providerName: string) => `${providerName} ontkoppelen`, @@ -8888,33 +8956,6 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, noPaymentMethodsFound: 'Geen betalingswijzen gevonden', noPaymentMethodsFoundDescription: 'Voeg betalingswijzen toe in Dynamics 365 Business Central en synchroniseer de verbinding opnieuw', }, - connections: { - tabs: { - [CONST.TAB.CONNECTIONS.ALL]: 'Alles', - [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Boekhouding', - [CONST.TAB.CONNECTIONS.PEOPLE]: 'HR & People', - [CONST.TAB.CONNECTIONS.RECRUITING]: 'Werving', - [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Bonnetjes', - [CONST.TAB.CONNECTIONS.AI]: 'AI & MCP', - }, - findConnections: 'Verbindingen zoeken', - configure: 'Configureren', - fix: 'Oplossen', - active: 'Actief', - broken: 'Defect', - brokenConnection: 'Kan niet synchroniseren vanwege een verbroken verbinding', - replaceConnectionTitle: 'Verbinding vervangen?', - offer: 'Aanbieding', - replaceConnectionPrompt: (connectionName: string) => `Hiermee verwijder je je huidige ${connectionName}-verbinding.`, - hrisListing: (providerName: string) => `${providerName} (HRIS)`, - atsListing: (providerName: string) => `${providerName} (ATS)`, - suggestIntegration: 'Zie je de jouwe niet? Stel een integratie voor, dan kijken we ernaar.', - noResultsPrompt: 'Pas je zoekopdracht aan of', - suggestAnIntegration: 'stel een integratie voor', - noResultsPromptEnd: '.', - allConnectedTitle: 'Je bent helemaal verbonden', - allConnectedPrompt: 'Alles hier is al verbonden. Je kunt ook', - }, }, getAssistancePage: { title: 'Hulp krijgen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 84a2724a308d..fdb8cb25d14c 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3587,12 +3587,13 @@ ${amount} dla ${merchant} - ${date}`, Połącz ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'twój' : 'do'} ${integrationName}, aby automatycznie kategoryzować wydatki i synchronizować dane, co znacznie ułatwi zamknięcie miesiąca. 1. Kliknij *Workspaces*. - 2. Wybierz swoje miejsce pracy. - 3. Kliknij *Connections*. + 2. Wybierz swoją przestrzeń roboczą. + 3. Kliknij *Accounting*. 4. Znajdź ${integrationName}. 5. Kliknij *Connect*. - [Przejdź do połączeń](${workspaceAccountingLink}).`), + [Przejdź do księgowości](${workspaceAccountingLink}). + `), }, connectCorporateCardTask: { title: ({corporateCardLink}) => `Połącz [swoje karty firmowe](${corporateCardLink})`, @@ -4899,16 +4900,51 @@ ${amount} dla ${merchant} - ${date}`, description: 'Świetna wiadomość 🎉. Skontaktuj się z nami, jeśli potrzebują pomocy przy konfiguracji.', }, mcp: { + connectors: 'Konektory', + connectorsSubtitle: 'Połącz asystenta AI ze swoim kontem Expensify.', + connect: 'Połącz', + helpPrompt: 'Potrzebujesz pomocy z połączeniem?', + helpLink: 'Przeczytaj nasz przewodnik.', claude: { title: 'Claude', + subtitle: 'od Anthropic', }, cursor: { title: 'Cursor', + subtitle: 'od Anysphere', }, chatgpt: { title: 'ChatGPT', + subtitle: 'od OpenAI', }, }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Wszystko', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Księgowość', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'HR i ludzie', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Rekrutacja', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Paragony', + [CONST.TAB.CONNECTIONS.AI]: 'AI i MCP', + }, + findConnections: 'Znajdź połączenia', + configure: 'Skonfiguruj', + fix: 'Napraw', + active: 'Aktywne', + broken: 'Uszkodzone', + brokenConnection: 'Nie można zsynchronizować z powodu przerwanego połączenia', + replaceConnectionTitle: 'Zastąpić połączenie?', + offer: 'Oferta', + replaceConnectionPrompt: (connectionName: string) => `To usunie twoje bieżące połączenie ${connectionName}.`, + hrisListing: (providerName: string) => `${providerName} (HRIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: 'Nie widzisz swojej? Zaproponuj integrację, przyjrzymy się temu.', + noResultsPrompt: 'Dostosuj wyszukiwanie lub', + suggestAnIntegration: 'zaproponuj integrację', + noResultsPromptEnd: '.', + allConnectedTitle: 'Wszystko połączone', + allConnectedPrompt: 'Wszystko tutaj jest już połączone. Możesz też', + }, receiptPartners: { uber: { subtitle: (organizationName: string) => @@ -6455,6 +6491,10 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy title: 'Organizuj', subtitle: 'Grupuj i analizuj wydatki, zapisuj każdy zapłacony podatek.', }, + integrateSection: { + title: 'Integruj', + subtitle: 'Połącz Expensify z popularnymi produktami finansowymi.', + }, distanceRates: { title: 'Stawki za dystans', subtitle: 'Dodawaj, aktualizuj i egzekwuj stawki.', @@ -6680,18 +6720,27 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy }, connections: { title: 'Księgowość', + subtitle: 'Synchronizuj swój plan kont i więcej.', + }, + mcp: { + title: 'MCP', + subtitle: 'Połącz asystenta AI ze swoim kontem Expensify.', }, receiptPartners: { title: 'Partnerzy paragonów', + subtitle: 'Automatycznie importuj paragony.', }, connectionsWarningModal: { featureEnabledTitle: 'Nie tak szybko...', featureEnabledText: 'Aby włączyć lub wyłączyć tę funkcję, musisz zmienić ustawienia importu księgowego.', + disconnectText: 'Aby wyłączyć księgowość, musisz odłączyć połączenie księgowe od swojego obszaru roboczego.', manageSettings: 'Zarządzaj ustawieniami', }, receiptPartnersWarningModal: { featureEnabledTitle: 'Odłącz Uber', + disconnectText: 'Aby wyłączyć tę funkcję, najpierw odłącz integrację Uber for Business.', description: 'Czy na pewno chcesz odłączyć tę integrację?', + confirmText: 'Rozumiem', }, workflowWarningModal: { featureEnabledTitle: 'Nie tak szybko...', @@ -6708,12 +6757,14 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy subtitle: 'Ustaw godzinową stawkę rozliczeniową do śledzenia czasu.', defaultHourlyRate: 'Domyślna stawka godzinowa', }, + hrWarningModal: {disconnectText: ({integration}: {integration: string}) => `Aby wyłączyć HR, najpierw odłącz ${integration} od tego workspace’a.`}, vendors: { title: 'Dostawcy', subtitle: 'Dopasuj wydatki z karty do kontrahentów zaimportowanych z twojego oprogramowania księgowego.', disabledTitle: 'Nie tak szybko...', disabledMessage: 'Aby włączyć lub wyłączyć tę funkcję, musisz zmienić ustawienia importu księgowego.', }, + recruitingWarningModal: {disconnectText: ({integration}: {integration: string}) => `Aby wyłączyć Rekrutację, najpierw odłącz ${integration} od tego workspace.`}, }, reports: { reportsCustomTitleExamples: 'Przykłady:', @@ -7226,6 +7277,7 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, disconnect: 'Rozłącz', reinstall: 'Zainstaluj ponownie konektor', disconnectTitle: (connectionName = 'integracja') => `Odłącz ${connectionName}`, + connectTitle: (connectionName) => `Połącz ${connectionName}`, syncError: (connectionName) => `Nie można połączyć się z ${connectionName}`, accounts: 'Plan kont', taxes: 'Podatki', @@ -7242,10 +7294,12 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'Domyślny pracownik NetSuite', }, disconnectPrompt: (connectionName = 'ta integracja') => `Czy na pewno chcesz odłączyć ${connectionName}?`, + connectPrompt: (connectionName) => `Czy na pewno chcesz połączyć ${connectionName}? Spowoduje to usunięcie wszystkich istniejących połączeń księgowych.`, enterCredentials: 'Wprowadź swoje dane logowania', reconnect: 'Połącz ponownie', updateCredentials: 'Zaktualizuj dane logowania', claimOffer: { + badgeText: 'Oferta dostępna!', xero: { headline: 'Korzystaj z Xero za darmo przez 6 miesięcy!', description: 'Nowy w Xero? Klienci Expensify otrzymują 6 miesięcy za darmo. Odbierz swoją ofertę poniżej.', @@ -8625,6 +8679,11 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, hr: { title: 'HR', + connectionsSubtitle: + 'Połącz się ze swoim systemem HR, aby synchronizować dane pracowników, automatycznie dopasowywać zwroty do właściwych osób i utrzymywać wydatki zespołu w porządku bez ręcznej pracy.', + subtitle: 'Połącz narzędzia HR i utrzymuj zgody pracowników w synchronizacji.', + alreadyConnectedTitle: 'Nie można połączyć się z wieloma platformami HR', + alreadyConnectedPrompt: 'Musisz odłączyć swoją obecną platformę HR, zanim podłączysz inną.', connectionDescription: (providerName: string) => `Połącz ${providerName}, aby synchronizować akceptacje pracowników z Twoim miejscem pracy.`, providerApprovalMode: (providerName: string) => `Tryb zatwierdzania ${providerName}`, providerFinalApprover: (providerName: string) => `Ostateczny zatwierdzający ${providerName}`, @@ -8676,9 +8735,14 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, recruiting: { title: 'Rekrutacja', + connectionsSubtitle: + 'Połącz się ze swoim systemem rekrutacyjnym, aby synchronizować dane kandydatów, automatycznie dopasowywać zwroty kosztów do właściwych osób i utrzymywać poprawność wydatków zespołu bez ręcznej pracy.', + alreadyConnectedTitle: 'Nie można połączyć się z wieloma platformami ATS', + alreadyConnectedPrompt: 'Musisz odłączyć swój obecny ATS, zanim podłączysz inny.', syncing: 'Synchronizowanie kandydatów', setupIncomplete: (setupLink: string | undefined) => `Połączono. ${setupLink ? `Dokończ konfigurację` : 'Zakończ konfigurację'}, aby zaimportować kandydatów.`, + dontSeeYourATS: `Nie widzisz tutaj swojego ATS? Zapytaj Concierge, a my możemy go dodać.`, importSettings: 'Ustawienia importu', defaultApprover: 'Domyślny zatwierdzający', approverFields: {recruiter: 'Rekruter', recruitingCoordinator: 'Koordynator rekrutacji'}, @@ -8699,6 +8763,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, offices: {title: 'Biuro', description: 'Wybierz biura kandydatów, które chcesz zsynchronizować z tym miejscem pracy', toggleTitle: 'Biura', allSelected: 'Wszystkie biura'}, enableJobStagesOrTags: 'Włącz etapy zleceń lub tagi, aby kontynuować', }, + subtitle: 'Połącz narzędzia rekrutacyjne i utrzymuj zgody kandydatów w synchronizacji.', syncResults: { importedCount: () => ({ one: '1 kandydat', @@ -8717,6 +8782,9 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, }, merge: { + connections: 'Połączenia', + connect: 'Połącz', + findIntegration: 'Znajdź integrację', syncNow: 'Synchronizuj teraz', disconnect: 'Odłącz', disconnectTitle: (providerName: string) => `Odłącz ${providerName}`, @@ -8903,33 +8971,6 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, noPaymentMethodsFound: 'Nie znaleziono metod płatności', noPaymentMethodsFoundDescription: 'Dodaj metody płatności w Dynamics 365 Business Central i ponownie zsynchronizuj połączenie', }, - connections: { - tabs: { - [CONST.TAB.CONNECTIONS.ALL]: 'Wszystko', - [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Księgowość', - [CONST.TAB.CONNECTIONS.PEOPLE]: 'HR i ludzie', - [CONST.TAB.CONNECTIONS.RECRUITING]: 'Rekrutacja', - [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Paragony', - [CONST.TAB.CONNECTIONS.AI]: 'AI i MCP', - }, - findConnections: 'Znajdź połączenia', - configure: 'Skonfiguruj', - fix: 'Napraw', - active: 'Aktywne', - broken: 'Uszkodzone', - brokenConnection: 'Nie można zsynchronizować z powodu przerwanego połączenia', - replaceConnectionTitle: 'Zastąpić połączenie?', - offer: 'Oferta', - replaceConnectionPrompt: (connectionName: string) => `To usunie twoje bieżące połączenie ${connectionName}.`, - hrisListing: (providerName: string) => `${providerName} (HRIS)`, - atsListing: (providerName: string) => `${providerName} (ATS)`, - suggestIntegration: 'Nie widzisz swojej? Zaproponuj integrację, przyjrzymy się temu.', - noResultsPrompt: 'Dostosuj wyszukiwanie lub', - suggestAnIntegration: 'zaproponuj integrację', - noResultsPromptEnd: '.', - allConnectedTitle: 'Wszystko połączone', - allConnectedPrompt: 'Wszystko tutaj jest już połączone. Możesz też', - }, }, getAssistancePage: { title: 'Uzyskaj pomoc', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 1bdd5f53dee1..6dd8e74d1791 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3546,11 +3546,12 @@ ${amount} para ${merchant} - ${date}`, 1. Clique em *Workspaces*. 2. Selecione seu workspace. - 3. Clique em *Connections*. + 3. Clique em *Accounting*. 4. Encontre ${integrationName}. 5. Clique em *Connect*. - [Ir para conexões](${workspaceAccountingLink}).`), + [Leve-me para a contabilidade](${workspaceAccountingLink}). + `), }, connectCorporateCardTask: { title: ({corporateCardLink}) => `Conecte [seus cartões corporativos](${corporateCardLink})`, @@ -4862,16 +4863,51 @@ ${amount} para ${merchant} - ${date}`, description: 'Ótimas notícias 🎉. Entre em contato conosco se precisarem de ajuda com a configuração.', }, mcp: { + connectors: 'Conectores', + connectorsSubtitle: 'Conecte um assistente de IA à sua conta do Expensify.', + connect: 'Conectar', + helpPrompt: 'Precisa de ajuda para conectar?', + helpLink: 'Leia nosso guia.', claude: { title: 'Claude', + subtitle: 'da Anthropic', }, cursor: { title: 'Cursor', + subtitle: 'da Anysphere', }, chatgpt: { title: 'ChatGPT', + subtitle: 'da OpenAI', }, }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: 'Tudo', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Contabilidade', + [CONST.TAB.CONNECTIONS.PEOPLE]: 'RH e Pessoas', + [CONST.TAB.CONNECTIONS.RECRUITING]: 'Recrutamento', + [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Recibos', + [CONST.TAB.CONNECTIONS.AI]: 'IA e MCP', + }, + findConnections: 'Encontrar conexões', + configure: 'Configurar', + fix: 'Corrigir', + active: 'Ativo', + broken: 'Quebrado', + brokenConnection: 'Não é possível sincronizar devido à conexão interrompida', + replaceConnectionTitle: 'Substituir conexão?', + offer: 'Oferta', + replaceConnectionPrompt: (connectionName: string) => `Isso vai remover sua conexão atual com ${connectionName}.`, + hrisListing: (providerName: string) => `${providerName} (RHIS)`, + atsListing: (providerName: string) => `${providerName} (ATS)`, + suggestIntegration: 'Não encontrou a sua? Sugira uma integração e nós vamos analisar.', + noResultsPrompt: 'Ajuste sua pesquisa ou', + suggestAnIntegration: 'sugerir uma integração', + noResultsPromptEnd: '.', + allConnectedTitle: 'Tudo conectado', + allConnectedPrompt: 'Tudo aqui já está conectado. Você também pode', + }, receiptPartners: { uber: { subtitle: (organizationName: string) => @@ -6430,6 +6466,10 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS title: 'Organizar', subtitle: 'Agrupe e analise os gastos, registre todos os impostos pagos.', }, + integrateSection: { + title: 'Integrar', + subtitle: 'Conecte o Expensify a produtos financeiros populares.', + }, distanceRates: { title: 'Taxas de distância', subtitle: 'Adicione, atualize e aplique tarifas.', @@ -6657,18 +6697,27 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS }, connections: { title: 'Contabilidade', + subtitle: 'Sincronize seu plano de contas e muito mais.', + }, + mcp: { + title: 'MCP', + subtitle: 'Conecte um assistente de IA à sua conta do Expensify.', }, receiptPartners: { title: 'Parceiros de recibos', + subtitle: 'Importe recibos automaticamente.', }, connectionsWarningModal: { featureEnabledTitle: 'Não tão rápido...', featureEnabledText: 'Para ativar ou desativar este recurso, você precisará alterar suas configurações de importação contábil.', + disconnectText: 'Para desativar a contabilidade, você precisará desconectar sua conexão contábil do seu workspace.', manageSettings: 'Gerenciar configurações', }, receiptPartnersWarningModal: { featureEnabledTitle: 'Desconectar Uber', + disconnectText: 'Para desativar este recurso, desconecte primeiro a integração do Uber for Business.', description: 'Tem certeza de que deseja desconectar esta integração?', + confirmText: 'Entendi', }, workflowWarningModal: { featureEnabledTitle: 'Não tão rápido...', @@ -6685,12 +6734,14 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS subtitle: 'Defina uma taxa horária faturável para o controle de tempo.', defaultHourlyRate: 'Taxa horária padrão', }, + hrWarningModal: {disconnectText: ({integration}: {integration: string}) => `Para desativar RH, desconecte primeiro a integração ${integration} deste workspace.`}, vendors: { title: 'Fornecedores', subtitle: 'Combine as despesas do cartão com fornecedores importados do seu software de contabilidade.', disabledTitle: 'Calma lá...', disabledMessage: 'Para ativar ou desativar este recurso, você precisará alterar suas configurações de importação contábil.', }, + recruitingWarningModal: {disconnectText: ({integration}: {integration: string}) => `Para desativar Recrutamento, primeiro desconecte ${integration} deste workspace.`}, }, reports: { reportsCustomTitleExamples: 'Exemplos:', @@ -7203,6 +7254,7 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, disconnect: 'Desconectar', reinstall: 'Reinstalar conector', disconnectTitle: (connectionName = 'integração') => `Desconectar ${connectionName}`, + connectTitle: (connectionName) => `Conectar ${connectionName}`, syncError: (connectionName) => `Não é possível conectar ao ${connectionName}`, accounts: 'Plano de contas', taxes: 'Impostos', @@ -7219,10 +7271,12 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'Padrão de funcionário do NetSuite', }, disconnectPrompt: (connectionName = 'esta integração') => `Tem certeza de que deseja desconectar ${connectionName}?`, + connectPrompt: (connectionName) => `Tem certeza de que deseja conectar ${connectionName}? Isso removerá quaisquer conexões contábeis existentes.`, enterCredentials: 'Insira suas credenciais', reconnect: 'Reconectar', updateCredentials: 'Atualizar credenciais', claimOffer: { + badgeText: 'Oferta disponível!', xero: { headline: 'Use Xero grátis por 6 meses!', description: 'Novo no Xero? Clientes Expensify ganham 6 meses grátis. Resgate sua oferta abaixo.', @@ -8608,6 +8662,11 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, hr: { title: 'RH', + connectionsSubtitle: + 'Conecte-se ao seu sistema de RH para sincronizar os dados de funcionários, associar automaticamente os reembolsos às pessoas corretas e manter as despesas da sua equipe precisas, sem trabalho manual.', + subtitle: 'Conecte ferramentas de RH e mantenha as aprovações de funcionários em sincronia.', + alreadyConnectedTitle: 'Não é possível conectar a várias plataformas de RH', + alreadyConnectedPrompt: 'Você precisa desconectar sua plataforma de RH atual antes de conectar outra.', connectionDescription: (providerName: string) => `Conecte ${providerName} para manter as aprovações de funcionários sincronizadas com seu workspace.`, providerApprovalMode: (providerName: string) => `Modo de aprovação do ${providerName}`, providerFinalApprover: (providerName: string) => `Aprovador final de ${providerName}`, @@ -8659,9 +8718,14 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, recruiting: { title: 'Recrutamento', + connectionsSubtitle: + 'Conecte-se ao seu sistema de recrutamento para sincronizar os dados de candidatos, associar automaticamente os reembolsos às pessoas certas e manter as despesas da sua equipe precisas sem trabalho manual.', + alreadyConnectedTitle: 'Não é possível conectar a várias plataformas de ATS', + alreadyConnectedPrompt: 'Você precisa desconectar o seu ATS atual antes de conectar outro.', syncing: 'Sincronizando candidatos', setupIncomplete: (setupLink: string | undefined) => `Conectado. ${setupLink ? `Concluir configuração` : 'Concluir configuração'} para importar candidatos.`, + dontSeeYourATS: `Não está vendo seu ATS aqui? Pergunte ao Concierge e podemos adicioná-lo.`, importSettings: 'Importar configurações', defaultApprover: 'Aprovador padrão', approverFields: {recruiter: 'Recrutador', recruitingCoordinator: 'Coordenador de recrutamento'}, @@ -8687,6 +8751,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, enableJobStagesOrTags: 'Ative Estágios de Trabalho ou Tags para continuar', }, + subtitle: 'Conecte ferramentas de recrutamento e mantenha as aprovações de candidatos em sincronia.', syncResults: { importedCount: () => ({ one: '1 candidato', @@ -8705,6 +8770,9 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, }, merge: { + connections: 'Conexões', + connect: 'Conectar', + findIntegration: 'Encontrar integração', syncNow: 'Sincronizar agora', disconnect: 'Desconectar', disconnectTitle: (providerName: string) => `Desconectar ${providerName}`, @@ -8894,33 +8962,6 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, noPaymentMethodsFound: 'Nenhuma forma de pagamento encontrada', noPaymentMethodsFoundDescription: 'Adicione formas de pagamento no Dynamics 365 Business Central e sincronize a conexão novamente', }, - connections: { - tabs: { - [CONST.TAB.CONNECTIONS.ALL]: 'Tudo', - [CONST.TAB.CONNECTIONS.ACCOUNTING]: 'Contabilidade', - [CONST.TAB.CONNECTIONS.PEOPLE]: 'RH e Pessoas', - [CONST.TAB.CONNECTIONS.RECRUITING]: 'Recrutamento', - [CONST.TAB.CONNECTIONS.RECEIPTS]: 'Recibos', - [CONST.TAB.CONNECTIONS.AI]: 'IA e MCP', - }, - findConnections: 'Encontrar conexões', - configure: 'Configurar', - fix: 'Corrigir', - active: 'Ativo', - broken: 'Quebrado', - brokenConnection: 'Não é possível sincronizar devido à conexão interrompida', - replaceConnectionTitle: 'Substituir conexão?', - offer: 'Oferta', - replaceConnectionPrompt: (connectionName: string) => `Isso vai remover sua conexão atual com ${connectionName}.`, - hrisListing: (providerName: string) => `${providerName} (RHIS)`, - atsListing: (providerName: string) => `${providerName} (ATS)`, - suggestIntegration: 'Não encontrou a sua? Sugira uma integração e nós vamos analisar.', - noResultsPrompt: 'Ajuste sua pesquisa ou', - suggestAnIntegration: 'sugerir uma integração', - noResultsPromptEnd: '.', - allConnectedTitle: 'Tudo conectado', - allConnectedPrompt: 'Tudo aqui já está conectado. Você também pode', - }, }, getAssistancePage: { title: 'Obter ajuda', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 5c3d16a0d49f..8e47093d204d 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3436,15 +3436,16 @@ ${amount},商户:${merchant} - 日期:${date}`, `连接${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '' : '到'} [${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '你的' : ''} ${integrationName}](${workspaceAccountingLink})`, description: ({integrationName, workspaceAccountingLink}) => Str.dedent(` - 连接 ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '你的' : '到'} ${integrationName},实现自动费用编码与同步,让月末结算轻松无忧。 + 连接 ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? '你的' : '至'} ${integrationName},实现自动费用编码与同步,让月末结账轻松无比。 1. 点击 *Workspaces*。 - 2. 选择你的工作区。 - 3. 点击 *Connections*。 + 2. 选择您的工作区。 + 3. 点击 *Accounting*。 4. 找到 ${integrationName}。 5. 点击 *Connect*。 - [前往连接页面](${workspaceAccountingLink})。`), + [带我前往会计页面](${workspaceAccountingLink})。 + `), }, connectCorporateCardTask: { title: ({corporateCardLink}) => `连接[您的公司卡](${corporateCardLink})`, @@ -4719,16 +4720,51 @@ ${amount},商户:${merchant} - 日期:${date}`, description: '好消息 🎉。如果他们在设置方面需要帮助,请联系我们。', }, mcp: { + connectors: '连接器', + connectorsSubtitle: '将 AI 助手连接到您的 Expensify 账户。', + connect: '连接', + helpPrompt: '需要帮助连接吗?', + helpLink: '阅读我们的指南。', claude: { title: 'Claude', + subtitle: '由 Anthropic 提供', }, cursor: { title: 'Cursor', + subtitle: '由 Anysphere 提供', }, chatgpt: { title: 'ChatGPT', + subtitle: '由 OpenAI 提供', }, }, + connections: { + tabs: { + [CONST.TAB.CONNECTIONS.ALL]: '全部', + [CONST.TAB.CONNECTIONS.ACCOUNTING]: '会计', + [CONST.TAB.CONNECTIONS.PEOPLE]: '人力资源与员工', + [CONST.TAB.CONNECTIONS.RECRUITING]: '招聘', + [CONST.TAB.CONNECTIONS.RECEIPTS]: '收据', + [CONST.TAB.CONNECTIONS.AI]: 'AI 和 MCP', + }, + findConnections: '查找关联', + configure: '配置', + fix: '修复', + active: '有效', + broken: '损坏', + brokenConnection: '由于连接中断,无法同步', + replaceConnectionTitle: '替换连接?', + offer: '优惠', + replaceConnectionPrompt: (connectionName: string) => `这将移除你当前的 ${connectionName} 连接。`, + hrisListing: (providerName: string) => `${providerName}(HRIS)`, + atsListing: (providerName: string) => `${providerName}(ATS)`, + suggestIntegration: '没找到适合你的?建议一个集成,我们会进行评估。', + noResultsPrompt: '请调整您的搜索条件或', + suggestAnIntegration: '建议一个集成', + noResultsPromptEnd: '。', + allConnectedTitle: '全部已连接', + allConnectedPrompt: '这里的一切都已连接完成。您也可以', + }, receiptPartners: { uber: { subtitle: (organizationName: string) => (organizationName ? `已连接到 ${organizationName}` : '在整个组织内自动化管理差旅和外卖餐饮报销。'), @@ -6222,6 +6258,10 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM title: '整理', subtitle: '分组并分析支出,记录每一笔已缴税款。', }, + integrateSection: { + title: '集成', + subtitle: '将 Expensify 连接到常用的金融产品。', + }, distanceRates: { title: '距离费率', subtitle: '添加、更新并执行费率。', @@ -6421,18 +6461,27 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM }, connections: { title: '会计', + subtitle: '同步您的科目表等数据。', + }, + mcp: { + title: 'MCP', + subtitle: '将 AI 助手连接到您的 Expensify 账户。', }, receiptPartners: { title: '收据合作伙伴', + subtitle: '自动导入收据。', }, connectionsWarningModal: { featureEnabledTitle: '别急……', featureEnabledText: '若要启用或停用此功能,您需要更改会计导入设置。', + disconnectText: '要停用会计功能,您需要从工作区中断开您的会计连接。', manageSettings: '管理设置', }, receiptPartnersWarningModal: { featureEnabledTitle: '断开与 Uber 的连接', + disconnectText: '若要禁用此功能,请先断开 Uber for Business 集成。', description: '确定要断开与此集成的连接吗?', + confirmText: '明白了', }, workflowWarningModal: { featureEnabledTitle: '别急……', @@ -6448,12 +6497,14 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM subtitle: '为时间跟踪设置可计费的小时费率。', defaultHourlyRate: '默认时薪', }, + hrWarningModal: {disconnectText: ({integration}: {integration: string}) => `若要禁用人力资源功能,请先将此工作区与 ${integration} 断开连接。`}, vendors: { title: '供应商', subtitle: '将卡片报销与从您的会计软件导入的供应商进行匹配。', disabledTitle: '别急...', disabledMessage: '要启用或禁用此功能,您需要更改会计导入设置。', }, + recruitingWarningModal: {disconnectText: ({integration}: {integration: string}) => `若要停用招聘功能,请先将此工作区与 ${integration} 断开连接。`}, }, reports: { reportsCustomTitleExamples: '示例:', @@ -6951,6 +7002,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM disconnect: '断开连接', reinstall: '重新安装连接器', disconnectTitle: (connectionName = '集成') => `断开连接 ${connectionName}`, + connectTitle: (connectionName) => `连接 ${connectionName}`, syncError: (connectionName) => `无法连接到 ${connectionName}`, accounts: '会计科目表', taxes: '税费', @@ -6967,10 +7019,12 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM [CONST.INTEGRATION_ENTITY_MAP_TYPES.NETSUITE_DEFAULT]: 'NetSuite 员工默认值', }, disconnectPrompt: (connectionName = '此集成') => `你确定要断开与 ${connectionName} 的连接吗?`, + connectPrompt: (connectionName) => `确定要连接 ${connectionName} 吗?这将删除所有现有的会计连接。`, enterCredentials: '请输入您的凭证', reconnect: '重新连接', updateCredentials: '更新凭证', claimOffer: { + badgeText: '优惠可用!', xero: { headline: '免费使用 Xero 长达 6 个月!', description: '第一次使用 Xero?Expensify 客户可享受 6 个月免费试用。请在下方领取优惠。', @@ -8298,6 +8352,10 @@ ${reportName}`, }, hr: { title: '人力资源', + connectionsSubtitle: '连接您的 HR 系统以同步员工数据,自动将报销匹配到正确的员工,并在无需手动操作的情况下保持团队报销的准确性。', + subtitle: '连接人力资源工具,保持员工审批同步。', + alreadyConnectedTitle: '无法连接到多个人力资源平台', + alreadyConnectedPrompt: '在连接其他人力资源平台之前,您必须先断开当前的人力资源平台。', connectionDescription: (providerName: string) => `连接 ${providerName},以在您的工作区中同步员工审批。`, providerApprovalMode: (providerName: string) => `${providerName} 审批模式`, providerFinalApprover: (providerName: string) => `${providerName} 最终审批人`, @@ -8349,9 +8407,13 @@ ${reportName}`, }, recruiting: { title: '招聘', + connectionsSubtitle: '连接招聘系统以同步候选人数据,自动将报销匹配到正确的人员,并在无需手动操作的情况下保持团队报销数据的准确。', + alreadyConnectedTitle: '无法连接到多个 ATS 平台', + alreadyConnectedPrompt: '在连接其他 ATS 之前,必须先断开当前的 ATS。', syncing: '正在同步候选人', setupIncomplete: (setupLink: string | undefined) => `已连接。${setupLink ? `完成设置` : '完成设置'} 个可导入候选项。`, + dontSeeYourATS: `没有看到你的 ATS?询问 Concierge,我们可以帮你添加。`, importSettings: '导入设置', defaultApprover: '默认审批人', approverFields: {recruiter: '招聘人员', recruitingCoordinator: '招聘协调员'}, @@ -8367,6 +8429,7 @@ ${reportName}`, offices: {title: '办公室', description: '选择你想与此工作区同步的候选人办公地点', toggleTitle: '办公室', allSelected: '所有办公室'}, enableJobStagesOrTags: '启用职位阶段或标签以继续', }, + subtitle: '连接招聘工具并保持候选人审批同步。', syncResults: { importedCount: () => ({ one: '1 名候选人', @@ -8381,6 +8444,9 @@ ${reportName}`, approvalModeDescriptions: {basic: '选择单个审批人', advanced: `候选人的招聘人员或协调人将成为其报销审批人`, custom: '在 Expensify 中手动设置审批人'}, }, merge: { + connections: '连接', + connect: '连接', + findIntegration: '查找集成', syncNow: '立即同步', disconnect: '断开连接', disconnectTitle: (providerName: string) => `断开 ${providerName}`, @@ -8560,33 +8626,6 @@ ${reportName}`, noPaymentMethodsFound: '未找到付款方式', noPaymentMethodsFoundDescription: '请在 Dynamics 365 Business Central 中添加付款方式,然后重新同步连接', }, - connections: { - tabs: { - [CONST.TAB.CONNECTIONS.ALL]: '全部', - [CONST.TAB.CONNECTIONS.ACCOUNTING]: '会计', - [CONST.TAB.CONNECTIONS.PEOPLE]: '人力资源与员工', - [CONST.TAB.CONNECTIONS.RECRUITING]: '招聘', - [CONST.TAB.CONNECTIONS.RECEIPTS]: '收据', - [CONST.TAB.CONNECTIONS.AI]: 'AI 和 MCP', - }, - findConnections: '查找关联', - configure: '配置', - fix: '修复', - active: '有效', - broken: '损坏', - brokenConnection: '由于连接中断,无法同步', - replaceConnectionTitle: '替换连接?', - offer: '优惠', - replaceConnectionPrompt: (connectionName: string) => `这将移除你当前的 ${connectionName} 连接。`, - hrisListing: (providerName: string) => `${providerName}(HRIS)`, - atsListing: (providerName: string) => `${providerName}(ATS)`, - suggestIntegration: '没找到适合你的?建议一个集成,我们会进行评估。', - noResultsPrompt: '请调整您的搜索条件或', - suggestAnIntegration: '建议一个集成', - noResultsPromptEnd: '。', - allConnectedTitle: '全部已连接', - allConnectedPrompt: '这里的一切都已连接完成。您也可以', - }, }, getAssistancePage: { title: '获取帮助', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index f75e8c23c87f..a972a6418951 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -436,9 +436,10 @@ const SettingsModalStackNavigator = createModalStackNavigator('../../../../pages/settings/Profile/Contacts/SetDefaultContactMethodConfirmValidateCodePage').default, [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE]: () => require('../../../../pages/settings/Preferences/PriorityModePage').default, [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: () => require('../../../../pages/workspace/accounting/PolicyAccountingPage').default, - [SCREENS.WORKSPACE.HR]: () => require('../../../../pages/workspace/hr/WorkspaceHRPage').default, - [SCREENS.WORKSPACE.RECRUITING]: () => require('../../../../pages/workspace/recruiting/WorkspaceRecruitingPage').default, - [SCREENS.WORKSPACE.RECEIPT_PARTNERS]: () => require('../../../../pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage').default, + [SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING]: () => require('../../../../pages/workspace/connections/ConnectionsAccountingPage').default, + [SCREENS.WORKSPACE.CONNECTIONS_HR]: () => require('../../../../pages/workspace/connections/ConnectionsHRPage').default, + [SCREENS.WORKSPACE.CONNECTIONS_RECRUITING]: () => require('../../../../pages/workspace/connections/ConnectionsRecruitingPage').default, + [SCREENS.WORKSPACE.CONNECTIONS_RECEIPT_PARTNERS]: () => require('../../../../pages/workspace/connections/ConnectionsReceiptPartnersPage').default, [SCREENS.SETTINGS.PREFERENCES.LANGUAGE]: () => require('../../../../pages/settings/Preferences/LanguagePage').default, [SCREENS.SETTINGS.PREFERENCES.THEME]: () => require('../../../../pages/settings/Preferences/ThemePage').default, [SCREENS.SETTINGS.TROUBLESHOOT_SERVER]: () => require('../../../../pages/settings/Troubleshoot/ServerPage').default, diff --git a/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx index 3bf9aa15edd6..c2cc3fe466ff 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx @@ -24,6 +24,9 @@ const CENTRAL_PANE_WORKSPACE_SCREENS = { [SCREENS.WORKSPACE.INVOICES]: () => require('../../../../pages/workspace/invoices/WorkspaceInvoicesPage').default, [SCREENS.WORKSPACE.MEMBERS]: () => require('../../../../pages/workspace/WorkspaceMembersPage').default, [SCREENS.WORKSPACE.ROOMS]: () => require('../../../../pages/workspace/rooms/WorkspaceRoomsPage').default, + [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: () => require('../../../../pages/workspace/accounting/PolicyAccountingPage').default, + [SCREENS.WORKSPACE.HR]: () => require('../../../../pages/workspace/hr/WorkspaceHRPage').default, + [SCREENS.WORKSPACE.RECRUITING]: () => require('../../../../pages/workspace/recruiting/WorkspaceRecruitingPage').default, [SCREENS.WORKSPACE.CONNECTIONS]: () => require('../../../../pages/workspace/connections/WorkspaceConnectionsPage').default, [SCREENS.WORKSPACE.CATEGORIES]: () => require('../../../../pages/workspace/categories/WorkspaceCategoriesPage').default, [SCREENS.WORKSPACE.VENDORS]: () => require('../../../../pages/workspace/vendors/WorkspaceVendorsPage').default, @@ -34,6 +37,8 @@ const CENTRAL_PANE_WORKSPACE_SCREENS = { [SCREENS.WORKSPACE.EXPENSIFY_CARD]: () => require('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardPage').default, [SCREENS.WORKSPACE.COMPANY_CARDS]: () => require('../../../../pages/workspace/companyCards/WorkspaceCompanyCardsPage').default, [SCREENS.WORKSPACE.PER_DIEM]: () => require('../../../../pages/workspace/perDiem/WorkspacePerDiemPage').default, + [SCREENS.WORKSPACE.MCP]: () => require('../../../../pages/workspace/mcp/WorkspaceMCPPage').default, + [SCREENS.WORKSPACE.RECEIPT_PARTNERS]: () => require('../../../../pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage').default, [SCREENS.WORKSPACE.DISTANCE_RATES]: () => require('../../../../pages/workspace/distanceRates/PolicyDistanceRatesPage').default, [SCREENS.WORKSPACE.TRAVEL]: () => require('../../../../pages/workspace/travel/PolicyTravelPage').default, [SCREENS.WORKSPACE.RULES]: () => require('../../../../pages/workspace/rules/PolicyRulesPageRevamp').default, diff --git a/src/libs/Navigation/linkingConfig/OldRoutes.ts b/src/libs/Navigation/linkingConfig/OldRoutes.ts index fa8cbd6501a2..c3911599856f 100644 --- a/src/libs/Navigation/linkingConfig/OldRoutes.ts +++ b/src/libs/Navigation/linkingConfig/OldRoutes.ts @@ -27,8 +27,6 @@ const oldRoutes: Record = { '/workspaces/*/downgrade/': '/workspaces/$1/overview/plan/downgrade?policyID=$1', '/workspaces/*/downgrade': '/workspaces/$1/overview/plan/downgrade?policyID=$1', '/workspaces/downgrade': '/settings/subscription/downgrade', - // The MCP page merged into the unified Connections page. - '/workspaces/*/mcp': '/workspaces/$1/connections', '/settings/workspaces/*': '/workspaces/$1', '/settings/workspaces': '/workspaces', '/r/*/settings': '/r/$1/details/report-settings', diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 9bc9bc027946..092f383774c1 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -52,8 +52,7 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.ACCOUNTING.INTUIT_ENTERPRISE_SUITE_ENTITY_SELECTOR]: { path: ROUTES.POLICY_ACCOUNTING_INTUIT_ENTERPRISE_SUITE_ENTITY_SELECTOR.route, }, - [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: {path: ROUTES.POLICY_ACCOUNTING.route}, - [SCREENS.WORKSPACE.HR]: {path: ROUTES.WORKSPACE_HR.route}, - [SCREENS.WORKSPACE.RECRUITING]: {path: ROUTES.WORKSPACE_RECRUITING.route}, - [SCREENS.WORKSPACE.RECEIPT_PARTNERS]: {path: ROUTES.WORKSPACE_RECEIPT_PARTNERS.route}, + [SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING]: {path: ROUTES.WORKSPACE_CONNECTIONS_ACCOUNTING.route}, + [SCREENS.WORKSPACE.CONNECTIONS_HR]: {path: ROUTES.WORKSPACE_CONNECTIONS_HR.route}, + [SCREENS.WORKSPACE.CONNECTIONS_RECRUITING]: {path: ROUTES.WORKSPACE_CONNECTIONS_RECRUITING.route}, + [SCREENS.WORKSPACE.CONNECTIONS_RECEIPT_PARTNERS]: {path: ROUTES.WORKSPACE_CONNECTIONS_RECEIPT_PARTNERS.route}, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_IMPORT]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_IMPORT.route}, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_CHART_OF_ACCOUNTS]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_CHART_OF_ACCOUNTS.route}, [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_CLASSES]: {path: ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_CLASSES.route}, @@ -2462,6 +2462,12 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.PER_DIEM]: { path: ROUTES.WORKSPACE_PER_DIEM.route, }, + [SCREENS.WORKSPACE.MCP]: { + path: ROUTES.WORKSPACE_MCP.route, + }, + [SCREENS.WORKSPACE.RECEIPT_PARTNERS]: { + path: ROUTES.WORKSPACE_RECEIPT_PARTNERS.route, + }, [SCREENS.WORKSPACE.CONNECTIONS]: { path: ROUTES.WORKSPACE_CONNECTIONS.route, }, @@ -2477,6 +2483,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.ROOMS]: { path: ROUTES.WORKSPACE_ROOMS.route, }, + [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: { + path: ROUTES.POLICY_ACCOUNTING.route, + }, [SCREENS.WORKSPACE.CATEGORIES]: { path: ROUTES.WORKSPACE_CATEGORIES.route, }, @@ -2486,6 +2495,12 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.MORE_FEATURES]: { path: ROUTES.WORKSPACE_MORE_FEATURES.route, }, + [SCREENS.WORKSPACE.HR]: { + path: ROUTES.WORKSPACE_HR.route, + }, + [SCREENS.WORKSPACE.RECRUITING]: { + path: ROUTES.WORKSPACE_RECRUITING.route, + }, [SCREENS.WORKSPACE.TAGS]: { path: ROUTES.WORKSPACE_TAGS.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 169d75c8c776..a42d6d1190e4 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -814,16 +814,16 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.DISTANCE_RATES_COMMUTER_EXCLUSIONS]: { policyID: string; }; - [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: { + [SCREENS.WORKSPACE.CONNECTIONS_ACCOUNTING]: { policyID: string; }; - [SCREENS.WORKSPACE.HR]: { + [SCREENS.WORKSPACE.CONNECTIONS_HR]: { policyID: string; }; - [SCREENS.WORKSPACE.RECRUITING]: { + [SCREENS.WORKSPACE.CONNECTIONS_RECRUITING]: { policyID: string; }; - [SCREENS.WORKSPACE.RECEIPT_PARTNERS]: { + [SCREENS.WORKSPACE.CONNECTIONS_RECEIPT_PARTNERS]: { policyID: string; }; [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_SETUP]: { @@ -3180,6 +3180,12 @@ type WorkspaceSplitNavigatorParamList = { [SCREENS.WORKSPACE.COMPANY_CARDS]: { policyID: string; }; + [SCREENS.WORKSPACE.MCP]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RECEIPT_PARTNERS]: { + policyID: string; + }; [SCREENS.WORKSPACE.CONNECTIONS]: { policyID: string; newConnectionName?: ConnectionName; @@ -3285,6 +3291,12 @@ type WorkspaceSplitNavigatorParamList = { [SCREENS.WORKSPACE.MORE_FEATURES]: { policyID: string; }; + [SCREENS.WORKSPACE.HR]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RECRUITING]: { + policyID: string; + }; [SCREENS.WORKSPACE.TAGS]: { policyID: string; // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md @@ -3307,6 +3319,9 @@ type WorkspaceSplitNavigatorParamList = { [SCREENS.WORKSPACE.TRAVEL]: { policyID: string; }; + [SCREENS.WORKSPACE.ACCOUNTING.ROOT]: { + policyID: string; + }; [SCREENS.WORKSPACE.ACCOUNTING.QUICKBOOKS_ONLINE_ADVANCED]: { policyID: string; }; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 187b8b3d334f..02bd8b554316 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -12683,7 +12683,7 @@ function prepareOnboardingOnyxData({ workspaceMoreFeaturesLink: `${environmentURL}/${ROUTES.WORKSPACE_MORE_FEATURES.getRoute(onboardingPolicyID)}`, workspaceConfirmationLink: `${environmentURL}/${createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_CONFIRMATION.path, ROUTES.WORKSPACES_LIST.route)}`, testDriveURL: `${environmentURL}/${testDriveURL}`, - workspaceAccountingLink: `${environmentURL}/${ROUTES.WORKSPACE_CONNECTIONS.getRoute(onboardingPolicyID)}`, + workspaceAccountingLink: `${environmentURL}/${ROUTES.POLICY_ACCOUNTING.getRoute(onboardingPolicyID)}`, corporateCardLink: `${environmentURL}/${ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(onboardingPolicyID)}`, }; diff --git a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts index 71dd0de01bcf..145463f91914 100644 --- a/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts +++ b/src/pages/home/GettingStartedSection/hooks/useGettingStartedItems.ts @@ -296,7 +296,7 @@ function useGettingStartedItems(): UseGettingStartedItemsResult { label: integrationName ? translate('homePage.gettingStartedSection.connectAccounting', {integrationName}) : translate('homePage.gettingStartedSection.connectAccountingDefault'), subText: translate('homePage.gettingStartedSection.connectAccountingSubText'), isComplete: !!getValidConnectedIntegration(policy) || Object.values(policy?.connections ?? {}).some((conn) => !!conn?.lastSync?.successfulDate), - route: ROUTES.WORKSPACE_CONNECTIONS.getRoute(activePolicyID), + route: ROUTES.WORKSPACE_ACCOUNTING.getRoute(activePolicyID), }); } else if (policy.areCategoriesEnabled) { items.push({ diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 35da241ab5e3..5b7500cfed86 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -48,6 +48,7 @@ import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {LayoutChangeEvent} from 'react-native'; +import type {TupleToUnion} from 'type-fest'; import {findFocusedRoute, useFocusEffect, useIsFocused, useNavigationState} from '@react-navigation/native'; import {createHasExpenseDefaultRuleErrorsSelector} from '@selectors/Rule'; @@ -113,7 +114,9 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac 'Gear', 'Hashtag', 'InvoiceGeneric', + 'Receipt', 'Briefcase', + 'Sync', 'Connect', 'Tag', 'Users', @@ -121,6 +124,8 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac 'LuggageWithLines', 'Clock', 'Bolt', + 'Bot', + 'UserPlus', ]); const policyName = policy?.name ?? ''; @@ -195,6 +200,8 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac shouldShowEnterCredentialsError, shouldShowRBR, isVendorMatchingBetaEnabled: isBetaEnabled(CONST.BETAS.VENDOR_MATCHING), + isRecruitingBetaEnabled: isBetaEnabled(CONST.BETAS.MERGE_ATS), + isUnifiedConnectionsBetaEnabled: isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS), convertToDisplayString, }).map((item) => ({ ...item, @@ -229,7 +236,9 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac ); // Close RHP if we land on a route that no longer exists in the menu - const canAccessRoute = activeRoute && (workspaceMenuItems.some((item) => item.screenName === activeRoute) || activeRoute === SCREENS.WORKSPACE.INITIAL); + const getItemScreenNames = (item: TupleToUnion) => [item.screenName, ...(item.aliasScreenNames ?? [])]; + const canAccessRoute = + activeRoute && (workspaceMenuItems.some((item) => getItemScreenNames(item).some((screenName) => screenName === activeRoute)) || activeRoute === SCREENS.WORKSPACE.INITIAL); useEffect(() => { if (!shouldShowNotFoundPage && canAccessRoute) { return; @@ -315,7 +324,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac return ( activeRoute?.startsWith(screenName)))} interactive={!hasPolicyCreationError} title={translate(item.translationKey)} icon={item.icon} @@ -323,7 +332,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac brickRoadIndicator={item.brickRoadIndicator} wrapperStyle={styles.sectionMenuItem(shouldUseNarrowLayout)} highlighted={!!item?.highlighted} - focused={!!(item.screenName && activeRoute?.startsWith(item.screenName))} + focused={getItemScreenNames(item).some((screenName) => activeRoute?.startsWith(screenName))} role={CONST.ROLE.TAB} badgeText={item.badgeText} shouldIconUseAutoWidthStyle diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx index abfe6b761001..b71ad865132d 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx @@ -8,6 +8,7 @@ import useCardFeeds from '@hooks/useCardFeeds'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDefaultFundID from '@hooks/useDefaultFundID'; +import useIsPolicyConnectedToUberReceiptPartner from '@hooks/useIsPolicyConnectedToUberReceiptPartner'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -22,6 +23,10 @@ import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; import {enablePolicyTravel} from '@libs/actions/Policy/Travel'; import {filterInactiveCards, getAllCardsForWorkspace, getCardSettings, getCompanyFeeds, isSmartLimitEnabled as isSmartLimitEnabledUtil} from '@libs/CardUtils'; +import {getLatestErrorField} from '@libs/ErrorUtils'; +import {getConnectedHRProvider, isAnyHRConnected, isGustoConnected, isZenefitsConnected} from '@libs/merge/HRUtils'; +import {isMergeConnected} from '@libs/merge/MergeUtils'; +import {getConnectedATSProvider, isAnyRecruitingConnected} from '@libs/merge/RecruitingUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; @@ -33,9 +38,12 @@ import { getDistanceRateCustomUnit, getPerDiemCustomUnit, hasAccountingConnections, + hasAccountingFeatureConnection, hasVendorFeature, isControlPolicy, + isMCPEnabled, isPerDiemEnabled, + isPolicyFeatureEnabled, isPolicyTaxEnabled, isTimeTrackingEnabled, tryNavigateToSubmitWorkspaceUpgrade, @@ -50,9 +58,15 @@ import {enablePolicyCategories} from '@userActions/Policy/Category'; import {enablePolicyDistanceRates} from '@userActions/Policy/DistanceRate'; import {enablePerDiem} from '@userActions/Policy/PerDiem'; import { + clearPolicyErrorField, enableCompanyCards, enableExpensifyCard, + enablePolicyConnections, + enablePolicyHR, enablePolicyInvoicing, + enablePolicyRecruiting, + enablePolicyMCP, + enablePolicyReceiptPartners, enablePolicyRules, enablePolicyTaxes, enablePolicyTimeTracking, @@ -81,13 +95,17 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {translate} = useLocalize(); - const {isBetaEnabledOrUnknown} = usePermissions(); + const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); // Undefined until the betas load. The action calls below need that distinction, the UI gating just treats it as off const isVendorMatchingBetaEnabled = isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const {showConfirmModal} = useConfirmModal(); + const isRecruitingBetaEnabled = isBetaEnabled(CONST.BETAS.MERGE_ATS); + // Connections replaces this section's pages, and turns each feature on as its first integration connects + const isUnifiedConnectionsBetaEnabled = isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS); const illustrations = useMemoizedLazyIllustrations([ 'FolderOpen', + 'Accounting', 'CompanyCard', 'Workflows', 'InvoiceBlue', @@ -100,15 +118,21 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro 'Luggage', 'Car', 'Gears', + 'ReceiptPartners', 'Clock', + 'Members', + 'AiAutomation', + 'NewUser', ]); const policyID = policy?.id; const workspaceAccountID = policy?.policyAccountID ?? CONST.DEFAULT_NUMBER_ID; const hasAccountingConnection = hasAccountingConnections(policy); + const isAccountingEnabled = !!policy?.areConnectionsEnabled || hasAccountingFeatureConnection(policy); const perDiemCustomUnit = getPerDiemCustomUnit(policy); const distanceRateCustomUnit = getDistanceRateCustomUnit(policy); + const isUberConnected = useIsPolicyConnectedToUberReceiptPartner({policyID}); const [cardFeeds] = useCardFeeds(policyID); const policyData = usePolicyData(policyID); const {categories: policyCategories} = policyData; @@ -197,6 +221,66 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro }); }; + const warnDisconnectAccountingFirst = async () => { + if (!hasAccountingConnection || !policyID) { + return; + } + const {action} = await showConfirmModal({ + title: translate('workspace.moreFeatures.connectionsWarningModal.featureEnabledTitle'), + prompt: translate('workspace.moreFeatures.connectionsWarningModal.disconnectText'), + confirmText: translate('workspace.moreFeatures.connectionsWarningModal.manageSettings'), + cancelText: translate('common.cancel'), + }); + if (action !== ModalActions.CONFIRM) { + return; + } + Navigation.navigate(ROUTES.POLICY_ACCOUNTING.getRoute(policyID)); + }; + + const warnReceiptPartnersStillConnected = async () => { + if (!isUberConnected) { + return; + } + await showConfirmModal({ + title: translate('workspace.moreFeatures.receiptPartnersWarningModal.featureEnabledTitle'), + prompt: translate('workspace.moreFeatures.receiptPartnersWarningModal.disconnectText'), + confirmText: translate('workspace.moreFeatures.receiptPartnersWarningModal.confirmText'), + shouldShowCancelButton: false, + }); + }; + + const warnDisconnectHRFirst = async () => { + if (!isAnyHRConnected(policy)) { + return; + } + let integration = ''; + if (isZenefitsConnected(policy)) { + integration = translate('workspace.hr.zenefits.title'); + } else if (isGustoConnected(policy)) { + integration = translate('workspace.hr.gusto.title'); + } else if (isMergeConnected(policy, CONST.POLICY.CONNECTIONS.NAME.MERGE_HR)) { + integration = getConnectedHRProvider(policy)?.displayName ?? ''; + } + await showConfirmModal({ + title: translate('workspace.distanceRates.oopsNotSoFast'), + prompt: translate('workspace.moreFeatures.hrWarningModal.disconnectText', {integration}), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + }; + + const warnDisconnectRecruitingFirst = async () => { + if (!isAnyRecruitingConnected(policy)) { + return; + } + await showConfirmModal({ + title: translate('workspace.distanceRates.oopsNotSoFast'), + prompt: translate('workspace.moreFeatures.recruitingWarningModal.disconnectText', {integration: getConnectedATSProvider(policy)?.displayName ?? ''}), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + }; + const promptDisableExpensifyCardViaConcierge = async () => { const {action} = await showConfirmModal({ title: translate('workspace.moreFeatures.expensifyCard.disableCardTitle'), @@ -281,6 +365,156 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro {translate('workspace.moreFeatures.subtitle')} + {!isUnifiedConnectionsBetaEnabled && ( + + { + if (!policyID) { + return; + } + if (tryNavigateToSubmitWorkspaceUpgrade(policy, isEnabled, CONST.UPGRADE_FEATURE_INTRO_MAPPING.accounting.alias)) { + return; + } + enablePolicyConnections(policyID, isEnabled); + }} + errors={getLatestErrorField(policy ?? {}, CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED)} + onCloseError={() => { + if (!policyID) { + return; + } + clearPolicyErrorField(policyID, CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED); + }} + onPress={() => { + if (!policyID) { + return; + } + Navigation.navigate(ROUTES.POLICY_ACCOUNTING.getRoute(policyID)); + }} + /> + { + if (!policyID) { + return; + } + if (isEnabled && !isControlPolicy(policy)) { + Navigation.navigate( + ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.hr.alias, ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)), + ); + return; + } + enablePolicyHR(policyID, isEnabled); + }} + onPress={() => { + if (!policyID) { + return; + } + Navigation.navigate(ROUTES.WORKSPACE_HR.getRoute(policyID)); + }} + /> + { + if (!policyID) { + return; + } + enablePolicyReceiptPartners(policyID, isEnabled); + }} + errors={getLatestErrorField(policy ?? {}, CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED)} + onCloseError={() => { + if (!policyID) { + return; + } + clearPolicyErrorField(policyID, CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED); + }} + onPress={() => { + if (!policyID) { + return; + } + Navigation.navigate(ROUTES.WORKSPACE_RECEIPT_PARTNERS.getRoute(policyID)); + }} + /> + {isRecruitingBetaEnabled && ( + { + if (!policyID) { + return; + } + if (isEnabled && !isControlPolicy(policy)) { + Navigation.navigate( + ROUTES.WORKSPACE_UPGRADE.getRoute( + policyID, + CONST.UPGRADE_FEATURE_INTRO_MAPPING.recruiting.alias, + ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID), + ), + ); + return; + } + enablePolicyRecruiting(policyID, isEnabled); + }} + onPress={() => { + if (!policyID) { + return; + } + Navigation.navigate(ROUTES.WORKSPACE_RECRUITING.getRoute(policyID)); + }} + /> + )} + { + if (!policyID) { + return; + } + enablePolicyMCP(policyID, isEnabled); + }} + onPress={() => { + if (!policyID) { + return; + } + Navigation.navigate(ROUTES.WORKSPACE_MCP.getRoute(policyID)); + }} + /> + + )} + (); const {translate} = useLocalize(); const {showConfirmModal, closeModalByID} = useConfirmModal(); + const {isBetaEnabled} = usePermissions(); + const isUnifiedConnectionsBetaEnabled = isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS); const policyID = policy?.id; // `removePolicyConnection` only runs once the user confirms, which can be a while after the flow started, so the @@ -120,7 +123,14 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider return; } - if (tryNavigateToSubmitWorkspaceUpgrade(policy, true, CONST.UPGRADE_FEATURE_INTRO_MAPPING.accounting.alias, ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))) { + if ( + tryNavigateToSubmitWorkspaceUpgrade( + policy, + true, + CONST.UPGRADE_FEATURE_INTRO_MAPPING.accounting.alias, + isUnifiedConnectionsBetaEnabled ? ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID) : undefined, + ) + ) { return; } @@ -145,6 +155,7 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider cardFeeds, cardLists, newActiveIntegration.isIntuitEnterpriseSuite, + isUnifiedConnectionsBetaEnabled, ); const workspaceUpgradeNavigationDetails = accountingIntegrationData?.workspaceUpgradeNavigationDetails; @@ -154,8 +165,8 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider ); return; } - // Enabled only once the plan allows the integration, so backing out of the upgrade leaves the feature untouched - if (!policy?.areConnectionsEnabled) { + // Connections has no feature toggle, so the feature is turned on once the plan allows the integration + if (isUnifiedConnectionsBetaEnabled && !policy?.areConnectionsEnabled) { enablePolicyConnections(policyID, true, false); } setActiveIntegration({ @@ -170,18 +181,33 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider // Mirrors `shouldShowConfirmationModal` below, which keeps `renderActiveIntegration()` from mounting the // setup flow until the user has decided what to do with the connection that has to be disconnected first. + const connectionName = newActiveIntegration.isIntuitEnterpriseSuite + ? translate('workspace.accounting.intuitEnterpriseSuite') + : (CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[newActiveIntegration.name] ?? newActiveIntegration.name); + const modalCopy = isUnifiedConnectionsBetaEnabled + ? { + title: translate('workspace.connections.replaceConnectionTitle'), + prompt: translate('workspace.connections.replaceConnectionPrompt', getAccountingIntegrationDisplayName(policy, integrationToDisconnect, translate)), + confirmText: translate('common.replace'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + } + : { + title: translate('workspace.accounting.connectTitle', connectionName), + prompt: translate('workspace.accounting.connectPrompt', connectionName), + confirmText: translate('workspace.accounting.setup'), + buttonVariant: CONST.BUTTON_VARIANT.SUCCESS, + }; + isDisconnectConfirmationPendingRef.current = true; showConfirmModal({ // `startIntegrationFlow` can run more than once for the same flow (the `useFocusEffect` in - // `WorkspaceConnectionsPage` re-fires whenever `startIntegrationFlow` is re-created). A stable id keeps the - // repeat call updating this prompt in place instead of stacking a second copy behind it. + // `PolicyAccountingPage` and `WorkspaceConnectionsPage` re-fires whenever `startIntegrationFlow` is + // re-created). A stable id keeps the repeat call updating this prompt in place instead of stacking a + // second copy behind it. id: ACCOUNTING_CONNECTION_CONFIRMATION_MODAL_ID, - title: translate('workspace.connections.replaceConnectionTitle'), - prompt: translate('workspace.connections.replaceConnectionPrompt', getAccountingIntegrationDisplayName(policy, integrationToDisconnect, translate)), - confirmText: translate('common.replace'), + ...modalCopy, cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, }).then((result) => { // A repeat call for the same id is handed back the promise the first call got, so every call's handler // runs on a single user answer. Only the first one may act, or the disconnect would be requested twice. @@ -223,6 +249,7 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider accountingIcons, cardFeeds, cardLists, + isUnifiedConnectionsBetaEnabled, ], ); @@ -267,6 +294,7 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider cardFeeds, cardLists, activeIntegration.isIntuitEnterpriseSuite, + isUnifiedConnectionsBetaEnabled, )?.setupConnectionFlow; }; diff --git a/src/pages/workspace/accounting/ClaimOfferPage.tsx b/src/pages/workspace/accounting/ClaimOfferPage.tsx index 1d74bcdbb3d2..5a90ec2024c2 100644 --- a/src/pages/workspace/accounting/ClaimOfferPage.tsx +++ b/src/pages/workspace/accounting/ClaimOfferPage.tsx @@ -12,6 +12,7 @@ import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIn import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import {openExternalLink} from '@libs/actions/Link'; @@ -30,6 +31,7 @@ import {enablePolicyReceiptPartners} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; +import type {PolicyFeatureName} from '@src/types/onyx/Policy'; import React, {useEffect, useMemo} from 'react'; import {View} from 'react-native'; @@ -45,6 +47,7 @@ type IntegrationConfig = { claimOfferLink?: string; connectButtonText: string; connectionName: string; + featureName: PolicyFeatureName; onConnect: () => void; }; @@ -58,6 +61,8 @@ function ClaimOfferPage({route, policy}: ClaimOfferPageProps) { const expensifyIcons = useMemoizedLazyExpensifyIcons(['TreasureChestGreenWithSparkle']); const integrations = policy?.receiptPartners; const {isUberConnected} = useGetReceiptPartnersIntegrationData(policyID); + const {isBetaEnabled} = usePermissions(); + const isUnifiedConnectionsBetaEnabled = isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS); const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); const accountingIntegrations = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES; @@ -82,6 +87,7 @@ function ClaimOfferPage({route, policy}: ClaimOfferPageProps) { claimOfferLink: CONST.XERO_PARTNER_LINK, connectButtonText: translate('workspace.accounting.claimOffer.xero.connectButton'), connectionName: CONST.POLICY.CONNECTIONS.NAME.XERO, + featureName: CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED, onConnect: () => { startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.XERO}); }, @@ -92,9 +98,10 @@ function ClaimOfferPage({route, policy}: ClaimOfferPageProps) { descriptionHtml: translate('workspace.accounting.claimOffer.uber.description'), connectButtonText: translate('workspace.accounting.claimOffer.uber.connectButton'), connectionName: CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER, + featureName: CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED, onConnect: () => { - // Offers open from Connections, where Receipt partners only turns on once a partner is connected - if (!policy?.receiptPartners?.enabled) { + // Connections offers Uber before Receipt partners is on, so the feature is turned on as Uber connects + if (isUnifiedConnectionsBetaEnabled && !policy?.receiptPartners?.enabled) { enablePolicyReceiptPartners(policyID, true, false); } openExternalLink(`${CONST.UBER_CONNECT_URL}?${integrations?.uber?.connectFormData}`); @@ -146,7 +153,9 @@ function ClaimOfferPage({route, policy}: ClaimOfferPageProps) { diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index 23a454ec6069..9318cdbe84c4 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -1,13 +1,21 @@ import ActivityIndicator from '@components/ActivityIndicator'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; +import CollapsibleSection from '@components/CollapsibleSection'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Icon from '@components/Icon'; import MenuItem from '@components/MenuItem'; +import MenuItemList from '@components/MenuItemList'; +import type {MenuItemWithLink} from '@components/MenuItemList'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import Section from '@components/Section'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; +import ThreeDotsMenu from '@components/ThreeDotsMenu'; import type ThreeDotsMenuProps from '@components/ThreeDotsMenu/types'; import useCardFeeds from '@hooks/useCardFeeds'; @@ -22,6 +30,8 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; @@ -32,6 +42,7 @@ import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/acti import {isExpensifyCardFullySetUp} from '@libs/CardUtils'; import DateUtils from '@libs/DateUtils'; import {getOldDotURLFromEnvironment} from '@libs/Environment/Environment'; +import getPlatform from '@libs/getPlatform'; import { areSettingsInErrorFields, findCurrentXeroOrganization, @@ -42,6 +53,7 @@ import { getXeroTenants, hasAccountingConnections, hasSupportedOnlyOnOldDotIntegration, + isControlPolicy, settingsPendingAction, shouldShowSyncError, } from '@libs/PolicyUtils'; @@ -49,7 +61,7 @@ import { import Navigation from '@navigation/Navigation'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import useRedirectUnconnectedPanelToConnections from '@pages/workspace/connections/useRedirectUnconnectedPanelToConnections'; +import withUnifiedConnectionsBeta from '@pages/workspace/connections/withUnifiedConnectionsBeta'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import {openOldDotLink} from '@userActions/Link'; @@ -58,9 +70,11 @@ import {openPolicyExpensifyCardsPage} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {ConnectionName} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import React, {useEffect, useMemo} from 'react'; +import {useFocusEffect, useRoute} from '@react-navigation/native'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {MenuItemData, PolicyAccountingPageProps} from './types'; @@ -69,7 +83,13 @@ import {AccountingContextProvider, useAccountingActions, useAccountingState} fro import {getCertiniaSelectedCompanyID, isCertiniaFFAConnection} from './certinia/utils'; import {getAccountingIntegrationData, getAccountingIntegrationDisplayName, getSynchronizationErrorMessage, isIntuitEnterpriseSuiteConnection} from './utils'; -/** Settings for the workspace's connected accounting integration, opened from the Connections page. */ +type RouteParams = { + newConnectionName?: ConnectionName; + integrationToDisconnect?: ConnectionName; + shouldDisconnectIntegrationBeforeConnecting?: boolean; + isIntuitEnterpriseSuite?: string; +}; + function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { useWorkspaceDocumentTitle(policy?.name, 'workspace.common.accounting'); const hasReusablePoliciesConnectedToSageIntacct = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, policy?.id); @@ -79,20 +99,32 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { const hasReusablePoliciesConnectedToDualEntry = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, policy?.id); const hasReusablePoliciesConnectedToCampfire = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE, policy?.id); const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy?.id}`); + const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); + const theme = useTheme(); const styles = useThemeStyles(); const {translate, datetimeToRelative: getDatetimeToRelative, getLocalDateFromDatetime, dateFnsLocale} = useLocalize(); const {environment} = useEnvironment(); const oldDotEnvironmentURL = getOldDotURLFromEnvironment(environment); const {isOffline} = useNetwork(); const {isBetaEnabled} = usePermissions(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const {showConfirmModal} = useConfirmModal(); - const {activeIntegration} = useAccountingState(); + const [datetimeToRelative, setDateTimeToRelative] = useState(''); + const {activeIntegration, popoverAnchorRefs} = useAccountingState(); const {startIntegrationFlow} = useAccountingActions(); + const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const {isLargeScreenWidth} = useResponsiveLayout(); + const route = useRoute(); + const params = route.params as RouteParams | undefined; + const newConnectionName = params?.newConnectionName; + const integrationToDisconnect = params?.integrationToDisconnect; + const shouldDisconnectIntegrationBeforeConnecting = params?.shouldDisconnectIntegrationBeforeConnecting; + const shouldConnectToIntuitEnterpriseSuite = params?.isIntuitEnterpriseSuite === 'true'; const policyID = policy?.id; const workspaceAccountID = useWorkspaceAccountID(policyID); const allCardSettings = useExpensifyCardFeeds(policyID); const isSyncInProgress = isConnectionInProgress(connectionSyncProgress, policy); - const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'CircularArrowBackwards', 'ExpensifyCard', 'Gear', 'Key', 'NewWindow', 'Pencil', 'Send', 'Sync', 'Trashcan']); + const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'CircularArrowBackwards', 'ExpensifyCard', 'Gear', 'Key', 'NewWindow', 'Pencil', 'QuestionMark', 'Send', 'Sync', 'Trashcan']); const accountingIcons = useMemoizedLazyExpensifyIcons([ 'IntacctSquare', 'IntuitSquare', @@ -125,6 +157,14 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { }), [canUseCampfireIntegration, canUseBusinessCentralIntegration], ); + const accountingIntegrationOptions = useMemo( + () => + accountingIntegrations.flatMap((name) => [ + {name, isIntuitEnterpriseSuite: name === CONST.POLICY.CONNECTIONS.NAME.QBO ? false : undefined}, + ...(name === CONST.POLICY.CONNECTIONS.NAME.QBO ? [{name, isIntuitEnterpriseSuite: true}] : []), + ]), + [accountingIntegrations], + ); const syncingAccountingIntegration = accountingIntegrations.find((integration) => integration === connectionSyncProgress?.connectionName); const connectedIntegration = getConnectedIntegration(policy, accountingIntegrations) ?? syncingAccountingIntegration; const isIntuitEnterpriseSuiteSyncInProgress = isSyncInProgress && activeIntegration?.name === CONST.POLICY.CONNECTIONS.NAME.QBO && activeIntegration.isIntuitEnterpriseSuite === true; @@ -132,7 +172,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBO && (isIntuitEnterpriseSuiteConnection(policy) || isIntuitEnterpriseSuiteSyncInProgress); const connectedIntegrationDisplayName = connectedIntegration ? getAccountingIntegrationDisplayName(policy, connectedIntegration, translate) : undefined; const hasAccountingConnection = hasAccountingConnections(policy); - const {canWrite: canWriteAccounting} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); + const {canWrite: canWriteAccounting, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); const synchronizationError = connectedIntegration && getSynchronizationErrorMessage(policy, connectedIntegration, isSyncInProgress, translate, styles); const isSageIntacct = connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT; @@ -150,11 +190,9 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { connectedIntegration ? policy?.connections?.[connectedIntegration] : undefined, connectedIntegration === connectionSyncProgress?.connectionName ? connectionSyncProgress : undefined, ); - const datetimeToRelative = successfulDate ? getDatetimeToRelative(successfulDate) : ''; const hasSyncError = shouldShowSyncError(policy, isSyncInProgress, accountingIntegrations); const hasUnsupportedNDIntegration = !isEmptyObject(policy?.connections) && hasSupportedOnlyOnOldDotIntegration(policy); - useRedirectUnconnectedPanelToConnections(policyID, true, !!connectedIntegration || hasUnsupportedNDIntegration); const tenants = useMemo(() => getXeroTenants(policy), [policy]); const currentXeroOrganization = findCurrentXeroOrganization(tenants, policy?.connections?.xero?.config?.tenantID); @@ -169,69 +207,134 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { credentialsMenuTextKey = 'workspace.accounting.updateCredentials'; } - const overflowMenu: ThreeDotsMenuProps['menuItems'] = [ - ...(shouldShowReinstallConnectorMenuItem - ? [ - { - icon: icons.CircularArrowBackwards, - text: translate('workspace.accounting.reinstall'), - onSelected: () => startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.QBD}), - shouldCallAfterModalHide: true, - disabled: isOffline, - iconRight: icons.NewWindow, - }, - ] - : []), - ...(shouldShowEnterCredentials - ? [ - { - icon: icons.Key, - text: translate(credentialsMenuTextKey), - onSelected: () => { - if (isSageIntacct && policyID) { - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTER_CREDENTIALS.getRoute(policyID)); - return; - } - startIntegrationFlow({name: connectedIntegration, isIntuitEnterpriseSuite: isConnectedToIntuitEnterpriseSuite}); + const overflowMenu: ThreeDotsMenuProps['menuItems'] = useMemo( + () => [ + ...(shouldShowReinstallConnectorMenuItem + ? [ + { + icon: icons.CircularArrowBackwards, + text: translate('workspace.accounting.reinstall'), + onSelected: () => startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.QBD}), + shouldCallAfterModalHide: true, + disabled: isOffline, + iconRight: icons.NewWindow, }, - shouldCallAfterModalHide: true, - disabled: isOffline, - iconRight: icons.NewWindow, - }, - ] - : []), - ...(!hasAuthError - ? [ - { - icon: icons.Sync, - text: translate('workspace.accounting.syncNow'), - onSelected: () => syncConnection(policy, connectedIntegration), - disabled: isOffline, - }, - ] - : []), - { - icon: icons.Trashcan, - text: translate('workspace.accounting.disconnect'), - onSelected: () => { - showConfirmModal({ - title: translate('workspace.accounting.disconnectTitle', connectedIntegrationDisplayName), - prompt: translate('workspace.accounting.disconnectPrompt', connectedIntegrationDisplayName), - confirmText: translate('workspace.accounting.disconnect'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }).then(({action}) => { - if (action !== ModalActions.CONFIRM || !connectedIntegration || !policyID) { - return; - } - // These settings have nothing to show once the integration is gone - Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)); - removePolicyConnection(policy, connectedIntegration); - }); + ] + : []), + ...(shouldShowEnterCredentials + ? [ + { + icon: icons.Key, + text: translate(credentialsMenuTextKey), + onSelected: () => { + if (isSageIntacct && policyID) { + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTER_CREDENTIALS.getRoute(policyID)); + return; + } + startIntegrationFlow({name: connectedIntegration, isIntuitEnterpriseSuite: isConnectedToIntuitEnterpriseSuite}); + }, + shouldCallAfterModalHide: true, + disabled: isOffline, + iconRight: icons.NewWindow, + }, + ] + : []), + ...(!hasAuthError + ? [ + { + icon: icons.Sync, + text: translate('workspace.accounting.syncNow'), + onSelected: () => syncConnection(policy, connectedIntegration), + disabled: isOffline, + }, + ] + : []), + { + icon: icons.Trashcan, + text: translate('workspace.accounting.disconnect'), + onSelected: () => { + showConfirmModal({ + title: translate('workspace.accounting.disconnectTitle', connectedIntegrationDisplayName), + prompt: translate('workspace.accounting.disconnectPrompt', connectedIntegrationDisplayName), + confirmText: translate('workspace.accounting.disconnect'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }).then(({action}) => { + if (action !== ModalActions.CONFIRM || !connectedIntegration || !policyID) { + return; + } + removePolicyConnection(policy, connectedIntegration); + }); + }, + shouldCallAfterModalHide: true, }, - shouldCallAfterModalHide: true, - }, - ]; + ], + [ + icons.NewWindow, + icons.CircularArrowBackwards, + icons.Key, + icons.Sync, + icons.Trashcan, + shouldShowEnterCredentials, + shouldShowReinstallConnectorMenuItem, + translate, + isOffline, + policy, + connectedIntegration, + connectedIntegrationDisplayName, + isConnectedToIntuitEnterpriseSuite, + startIntegrationFlow, + isSageIntacct, + hasAuthError, + credentialsMenuTextKey, + policyID, + showConfirmModal, + ], + ); + + // `startIntegrationFlow` changes identity whenever `policy` does, which re-runs this effect. The + // Navigation.setParams below clears newConnectionName through a navigation state update that lands in a later + // render, so that re-run can still see the param set. Key the guard on the value to start the flow only once. + const startedIntegrationFlowForRef = useRef(undefined); + + useFocusEffect( + useCallback(() => { + if (!newConnectionName || !isControlPolicy(policy) || !canWriteAccounting) { + // Re-arm the guard once the param is gone, so a later round-trip that asks for the same integration + // again is not mistaken for the re-run this guard exists to swallow. + if (!newConnectionName) { + startedIntegrationFlowForRef.current = undefined; + } + return; + } + + if (startedIntegrationFlowForRef.current === newConnectionName) { + return; + } + startedIntegrationFlowForRef.current = newConnectionName; + + startIntegrationFlow({ + name: newConnectionName, + isIntuitEnterpriseSuite: shouldConnectToIntuitEnterpriseSuite, + integrationToDisconnect, + shouldDisconnectIntegrationBeforeConnecting, + }); + Navigation.setParams({ + newConnectionName: undefined, + isIntuitEnterpriseSuite: undefined, + integrationToDisconnect: undefined, + shouldDisconnectIntegrationBeforeConnecting: undefined, + }); + }, [newConnectionName, shouldConnectToIntuitEnterpriseSuite, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting, policy, startIntegrationFlow, canWriteAccounting]), + ); + + useEffect(() => { + if (successfulDate) { + setDateTimeToRelative(getDatetimeToRelative(successfulDate)); + return; + } + setDateTimeToRelative(''); + }, [getDatetimeToRelative, successfulDate]); useEffect(() => { if (!policyID || !policy?.areExpensifyCardsEnabled || !workspaceAccountID) { @@ -240,7 +343,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { openPolicyExpensifyCardsPage(policyID, workspaceAccountID); }, [policyID, policy?.areExpensifyCardsEnabled, workspaceAccountID]); - const getIntegrationSpecificMenuItems = () => { + const integrationSpecificMenuItems = useMemo(() => { const sageIntacctEntityList = policy?.connections?.intacct?.data?.entities ?? []; const netSuiteSubsidiaryList = policy?.connections?.netsuite?.options?.data?.subsidiaryList ?? []; const rilletSubsidiaryList = policy?.connections?.rillet?.data?.subsidiaries; @@ -260,6 +363,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { description: translate('workspace.xero.organization'), iconRight: icons.ArrowRight, title: getCurrentXeroOrganizationName(policy), + wrapperStyle: [styles.sectionMenuItemTopDescription], titleStyle: styles.fontWeightNormal, shouldShowRightIcon: canWriteAccounting && tenants.length > 1, shouldShowDescriptionOnTop: true, @@ -282,6 +386,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { description: translate('workspace.netsuite.subsidiary'), iconRight: icons.ArrowRight, title: policy?.connections?.netsuite?.options?.config?.subsidiary ?? '', + wrapperStyle: [styles.sectionMenuItemTopDescription], titleStyle: styles.fontWeightNormal, shouldShowRightIcon: canWriteAccounting && netSuiteSubsidiaryList?.length > 1, shouldShowDescriptionOnTop: true, @@ -302,6 +407,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { description: translate('workspace.intacct.entity'), iconRight: icons.ArrowRight, title: getCurrentSageIntacctEntityName(policy, translate('workspace.common.topLevel')), + wrapperStyle: [styles.sectionMenuItemTopDescription], titleStyle: styles.fontWeightNormal, shouldShowRightIcon: canWriteAccounting, shouldShowDescriptionOnTop: true, @@ -317,6 +423,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { description: translate(isConnectedToIntuitEnterpriseSuite ? 'workspace.qbo.entity' : 'workspace.qbo.connectedTo'), iconRight: isConnectedToIntuitEnterpriseSuite ? icons.ArrowRight : undefined, title: policy?.connections?.quickbooksOnline?.config?.companyName, + wrapperStyle: [styles.sectionMenuItemTopDescription], titleStyle: styles.fontWeightNormal, shouldShowRightIcon: isConnectedToIntuitEnterpriseSuite && canWriteAccounting, shouldShowDescriptionOnTop: true, @@ -335,6 +442,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { description: translate('workspace.certinia.company'), iconRight: icons.ArrowRight, title: selectedCertiniaCompany?.name ?? certiniaCompanyID ?? translate('common.none'), + wrapperStyle: [styles.sectionMenuItemTopDescription], titleStyle: styles.fontWeightNormal, shouldShowRightIcon: canWriteAccounting, shouldShowDescriptionOnTop: true, @@ -350,6 +458,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { description: translate('workspace.rillet.subsidiary'), iconRight: icons.ArrowRight, title: rilletSubsidiaryList?.find((subsidiary) => subsidiary.id === policy?.connections?.rillet?.config?.subsidiaryID)?.tradeName ?? '', + wrapperStyle: [styles.sectionMenuItemTopDescription], titleStyle: styles.fontWeightNormal, shouldShowRightIcon: canWriteAccounting && rilletSubsidiaryList && rilletSubsidiaryList.length > 1, shouldShowDescriptionOnTop: true, @@ -368,6 +477,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { description: translate('workspace.dualEntry.subsidiary'), iconRight: icons.ArrowRight, title: dualEntryCompanyList?.find((company) => company.id === policy?.connections?.dualEntry?.config?.subsidiaryID)?.name ?? '', + wrapperStyle: [styles.sectionMenuItemTopDescription], titleStyle: styles.fontWeightNormal, shouldShowRightIcon: canWriteAccounting && dualEntryCompanyList && dualEntryCompanyList.length > 1, shouldShowDescriptionOnTop: true, @@ -386,6 +496,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { description: translate('workspace.campfire.subsidiary'), iconRight: icons.ArrowRight, title: campfireSubsidiaryList?.find((subsidiary) => subsidiary.id === policy?.connections?.campfire?.config?.subsidiaryID)?.name ?? '', + wrapperStyle: [styles.sectionMenuItemTopDescription], titleStyle: styles.fontWeightNormal, shouldShowRightIcon: canWriteAccounting && campfireSubsidiaryList && campfireSubsidiaryList.length > 1, shouldShowDescriptionOnTop: true, @@ -404,6 +515,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { description: translate('workspace.businessCentral.subsidiary'), iconRight: icons.ArrowRight, title: businessCentralCompanyList.find((company) => company.id === policy?.connections?.businessCentral?.config?.companyID)?.displayName ?? '', + wrapperStyle: [styles.sectionMenuItemTopDescription], titleStyle: styles.fontWeightNormal, shouldShowRightIcon: canWriteAccounting && businessCentralCompanyList.length > 1, shouldShowDescriptionOnTop: true, @@ -419,12 +531,110 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { default: return undefined; } - }; - const integrationSpecificMenuItems = getIntegrationSpecificMenuItems(); + }, [ + canWriteAccounting, + connectedIntegration, + currentXeroOrganization?.id, + isConnectedToIntuitEnterpriseSuite, + policy, + policyID, + styles.fontWeightNormal, + styles.sectionMenuItemTopDescription, + tenants.length, + translate, + icons.ArrowRight, + ]); + + const connectionsMenuItems: MenuItemData[] = useMemo(() => { + if (!hasAccountingConnection && !isSyncInProgress && policyID) { + return accountingIntegrationOptions + .map(({name: integration, isIntuitEnterpriseSuite}) => { + const integrationData = getAccountingIntegrationData( + integration, + policyID, + translate, + { + sageIntacct: hasReusablePoliciesConnectedToSageIntacct, + qbd: hasReusablePoliciesConnectedToQBD, + certinia: hasReusablePoliciesConnectedToCertinia, + rillet: hasReusablePoliciesConnectedToRillet, + dualEntry: hasReusablePoliciesConnectedToDualEntry, + campfire: hasReusablePoliciesConnectedToCampfire, + }, + undefined, + undefined, + undefined, + undefined, + undefined, + accountingIcons, + cardFeeds, + cardLists, + isIntuitEnterpriseSuite, + ); + if (!integrationData) { + return undefined; + } + + const isXero = integration === CONST.POLICY.CONNECTIONS.NAME.XERO; + const iconProps = integrationData?.icon + ? { + icon: integrationData.icon, + iconType: CONST.ICON_TYPE_AVATAR, + } + : {}; + + return { + ...iconProps, + interactive: false, + // On native iOS, `accessible={true}` collapses the row and all its descendants into a single accessibility element, + // so VoiceOver focuses the whole row instead of the nested Connect button. Disabling it only on native iOS lets + // VoiceOver focus/activate the button on its own. Other platforms (Android/TalkBack, web, iOS mWeb→WEB) keep grouping. + shouldBeAccessible: getPlatform() !== CONST.PLATFORM.IOS, + wrapperStyle: [styles.sectionMenuItemTopDescription], + shouldShowRightComponent: true, + title: integrationData?.title, + badgeText: isXero ? translate('workspace.accounting.claimOffer.badgeText') : undefined, + onBadgePress: + isXero && canWriteAccounting + ? () => { + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CLAIM_OFFER.getRoute(policyID, CONST.POLICY.CONNECTIONS.NAME.XERO)); + } + : undefined, + badgeStyle: styles.mr3, + isBadgeSuccess: isXero, + shouldShowBadgeBelow: shouldUseNarrowLayout, + rightComponent: ( + { + if (!canWriteAccounting) { + showReadOnlyModal(); + return; + } + startIntegrationFlow({name: integration, isIntuitEnterpriseSuite}); + }} + style={styles.justifyContentCenter} + innerStyles={!canWriteAccounting ? [styles.buttonOpacityDisabled, styles.buttonDisabled] : undefined} + hoverStyles={!canWriteAccounting ? [styles.buttonOpacityDisabled, styles.buttonDisabled] : undefined} + size={CONST.BUTTON_SIZE.SMALL} + ref={(ref) => { + if (!popoverAnchorRefs?.current) { + return; + } + const integrationKey = isIntuitEnterpriseSuite ? CONST.POLICY.CONNECTIONS.ACCOUNTING_INTEGRATION_ALIASES.INTUIT_ENTERPRISE_SUITE : integration; + popoverAnchorRefs.current[integrationKey].current = ref; + }} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.ACCOUNTING.SETUP_BUTTON} + > + {translate('workspace.accounting.setup')} + + ), + }; + }) + .filter(Boolean) as MenuItemData[]; + } - const getConnectionDetails = () => { if (!connectedIntegration || !policyID) { - return undefined; + return []; } const isConnectionVerified = !isConnectionUnverified(policy, connectedIntegration); const integrationData = getAccountingIntegrationData( @@ -449,6 +659,8 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { cardLists, isConnectedToIntuitEnterpriseSuite, ); + const iconProps = integrationData?.icon ? {icon: integrationData.icon, iconType: CONST.ICON_TYPE_AVATAR} : {}; + let connectionMessage; if (isSyncInProgress && connectionSyncStage) { connectionMessage = translate('workspace.accounting.connections.syncStageName', connectionSyncStage, integrationData?.title); @@ -465,6 +677,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { iconRight: icons.ArrowRight, shouldShowRightIcon: true, title: translate('workspace.accounting.import'), + wrapperStyle: [styles.sectionMenuItemTopDescription], onPress: integrationData?.onImportPagePress, brickRoadIndicator: areSettingsInErrorFields(integrationData?.subscribedImportSettings, integrationData?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR @@ -476,6 +689,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { iconRight: icons.ArrowRight, shouldShowRightIcon: true, title: translate('workspace.accounting.export'), + wrapperStyle: [styles.sectionMenuItemTopDescription], onPress: integrationData?.onExportPagePress, brickRoadIndicator: areSettingsInErrorFields(integrationData?.subscribedExportSettings, integrationData?.errorFields) || @@ -493,6 +707,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { iconRight: icons.ArrowRight, shouldShowRightIcon: true, title: translate('workspace.accounting.cardReconciliation'), + wrapperStyle: [styles.sectionMenuItemTopDescription], onPress: integrationData?.onCardReconciliationPagePress, }, ] @@ -502,6 +717,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { iconRight: icons.ArrowRight, shouldShowRightIcon: true, title: translate('workspace.accounting.advanced'), + wrapperStyle: [styles.sectionMenuItemTopDescription], onPress: integrationData?.onAdvancedPagePress, brickRoadIndicator: areSettingsInErrorFields(integrationData?.subscribedAdvancedSettings, integrationData?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR @@ -511,32 +727,227 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { ] : []; - const menuItems: MenuItemData[] = [ + let rightComponent; + if (isSyncInProgress) { + rightComponent = ; + } else if (canWriteAccounting) { + rightComponent = ( + + ); + } + + let qboTokenExpiryHint; + if (qboTokenExpiryDate && canWriteAccounting) { + const formattedExpiryDate = DateUtils.formatWithUTCTimeZone(qboTokenExpiryDate.toISOString(), CONST.DATE.MONTH_DAY_YEAR_FORMAT, dateFnsLocale); + qboTokenExpiryHint = ( + <> + {translate( + qboTokenExpiryStatus === CONST.POLICY.CONNECTIONS.QBO_REFRESH_TOKEN_EXPIRY_STATUS.EXPIRED + ? 'workspace.accounting.qboConnectionExpired' + : 'workspace.accounting.qboConnectionExpiring', + {date: formattedExpiryDate}, + )}{' '} + startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.QBO, isIntuitEnterpriseSuite: isConnectedToIntuitEnterpriseSuite})}> + {translate('workspace.accounting.reconnect')} + + + ); + } + + return [ + { + ...iconProps, + interactive: false, + wrapperStyle: [styles.sectionMenuItemTopDescription, shouldShowSynchronizationError && styles.pb0], + shouldShowRightComponent: true, + title: integrationData?.title, + errorText: synchronizationError, + errorTextStyle: [styles.mt5], + shouldShowRedDotIndicator: true, + description: connectionMessage, + hintText: qboTokenExpiryHint, + rightComponent, + }, ...(isEmptyObject(integrationSpecificMenuItems) || shouldShowSynchronizationError || !hasAccountingConnection ? [] : [integrationSpecificMenuItems]), ...(!hasAccountingConnection || !isConnectionVerified ? [] : configurationOptions), ]; - return {icon: integrationData?.icon, title: integrationData?.title, connectionMessage, menuItems}; - }; - const connectionDetails = getConnectionDetails(); - const connectionsMenuItems = connectionDetails?.menuItems ?? []; - - let qboTokenExpiryHint; - if (qboTokenExpiryDate && canWriteAccounting) { - const formattedExpiryDate = DateUtils.formatWithUTCTimeZone(qboTokenExpiryDate.toISOString(), CONST.DATE.MONTH_DAY_YEAR_FORMAT, dateFnsLocale); - qboTokenExpiryHint = ( - <> - {translate( - qboTokenExpiryStatus === CONST.POLICY.CONNECTIONS.QBO_REFRESH_TOKEN_EXPIRY_STATUS.EXPIRED - ? 'workspace.accounting.qboConnectionExpired' - : 'workspace.accounting.qboConnectionExpiring', - {date: formattedExpiryDate}, - )}{' '} - startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.QBO, isIntuitEnterpriseSuite: isConnectedToIntuitEnterpriseSuite})}> - {translate('workspace.accounting.reconnect')} - - + }, [ + policy, + hasAccountingConnection, + isSyncInProgress, + policyID, + connectedIntegration, + translate, + isBetaEnabled, + accountingIcons, + cardFeeds, + cardLists, + connectionSyncStage, + icons.Pencil, + icons.ArrowRight, + icons.ExpensifyCard, + icons.Gear, + icons.Send, + styles.sectionMenuItemTopDescription, + styles.pb0, + styles.mt5, + styles.popoverMenuIcon, + styles.mr3, + styles.justifyContentCenter, + styles.buttonOpacityDisabled, + styles.buttonDisabled, + shouldShowCardReconciliationOption, + shouldShowSynchronizationError, + synchronizationError, + overflowMenu, + integrationSpecificMenuItems, + accountingIntegrationOptions, + isConnectedToIntuitEnterpriseSuite, + shouldUseNarrowLayout, + startIntegrationFlow, + popoverAnchorRefs, + datetimeToRelative, + qboTokenExpiryDate, + qboTokenExpiryStatus, + dateFnsLocale, + hasReusablePoliciesConnectedToSageIntacct, + hasReusablePoliciesConnectedToCertinia, + hasReusablePoliciesConnectedToRillet, + hasReusablePoliciesConnectedToDualEntry, + hasReusablePoliciesConnectedToCampfire, + hasReusablePoliciesConnectedToQBD, + canWriteAccounting, + showReadOnlyModal, + ]); + + const otherIntegrationsItems = useMemo(() => { + if ((!hasAccountingConnection && !isSyncInProgress) || !policyID) { + return; + } + const otherIntegrations = accountingIntegrationOptions.filter( + ({name, isIntuitEnterpriseSuite}) => name !== connectedIntegration || !!isIntuitEnterpriseSuite !== isConnectedToIntuitEnterpriseSuite, ); - } + return otherIntegrations + .map(({name: integration, isIntuitEnterpriseSuite}) => { + const integrationData = getAccountingIntegrationData( + integration, + policyID, + translate, + { + sageIntacct: hasReusablePoliciesConnectedToSageIntacct, + qbd: hasReusablePoliciesConnectedToQBD, + certinia: hasReusablePoliciesConnectedToCertinia, + rillet: hasReusablePoliciesConnectedToRillet, + dualEntry: hasReusablePoliciesConnectedToDualEntry, + campfire: hasReusablePoliciesConnectedToCampfire, + }, + undefined, + undefined, + undefined, + undefined, + undefined, + accountingIcons, + cardFeeds, + cardLists, + isIntuitEnterpriseSuite, + ); + if (!integrationData) { + return undefined; + } + + const iconProps = integrationData?.icon ? {icon: integrationData.icon, iconType: CONST.ICON_TYPE_AVATAR} : {}; + + return { + ...iconProps, + title: integrationData?.title, + rightComponent: ( + { + if (!canWriteAccounting) { + showReadOnlyModal(); + return; + } + startIntegrationFlow({ + name: integration, + isIntuitEnterpriseSuite, + integrationToDisconnect: connectedIntegration, + shouldDisconnectIntegrationBeforeConnecting: true, + }); + }} + style={styles.justifyContentCenter} + innerStyles={!canWriteAccounting ? [styles.buttonOpacityDisabled, styles.buttonDisabled] : undefined} + hoverStyles={!canWriteAccounting ? [styles.buttonOpacityDisabled, styles.buttonDisabled] : undefined} + size={CONST.BUTTON_SIZE.SMALL} + ref={(r) => { + if (!popoverAnchorRefs?.current) { + return; + } + const integrationKey = isIntuitEnterpriseSuite ? CONST.POLICY.CONNECTIONS.ACCOUNTING_INTEGRATION_ALIASES.INTUIT_ENTERPRISE_SUITE : integration; + popoverAnchorRefs.current[integrationKey].current = r; + }} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.ACCOUNTING.SETUP_BUTTON} + > + {translate('workspace.accounting.setup')} + + ), + interactive: false, + // On native iOS, `accessible={true}` collapses the row and all its descendants into a single accessibility element, + // so VoiceOver focuses the whole row instead of the nested Connect button. Disabling it only on native iOS lets + // VoiceOver focus/activate the button on its own. Other platforms (Android/TalkBack, web, iOS mWeb→WEB) keep grouping. + shouldBeAccessible: getPlatform() !== CONST.PLATFORM.IOS, + shouldShowRightComponent: true, + wrapperStyle: styles.sectionMenuItemTopDescription, + }; + }) + .filter(Boolean) as MenuItemWithLink[]; + }, [ + hasAccountingConnection, + isSyncInProgress, + accountingIntegrationOptions, + connectedIntegration, + isConnectedToIntuitEnterpriseSuite, + policyID, + translate, + hasReusablePoliciesConnectedToSageIntacct, + hasReusablePoliciesConnectedToCertinia, + hasReusablePoliciesConnectedToRillet, + hasReusablePoliciesConnectedToDualEntry, + hasReusablePoliciesConnectedToCampfire, + hasReusablePoliciesConnectedToQBD, + styles.justifyContentCenter, + styles.buttonOpacityDisabled, + styles.buttonDisabled, + styles.sectionMenuItemTopDescription, + startIntegrationFlow, + popoverAnchorRefs, + accountingIcons, + cardFeeds, + cardLists, + canWriteAccounting, + showReadOnlyModal, + ]); + + const [chatTextLink, chatReportID] = useMemo(() => { + // If they have an onboarding specialist assigned display the following and link to the #admins room with the account executive. + if (policy?.chatReportIDAdmins) { + return [translate('workspace.accounting.talkYourOnboardingSpecialist'), policy?.chatReportIDAdmins?.toString()]; + } + + // If not, if they have an account manager assigned display the following and link to the DM with their account manager. + if (account?.accountManagerAccountID) { + return [translate('workspace.accounting.talkYourAccountManager'), account?.accountManagerReportID]; + } + // Else, display the following and link to their Concierge DM. + return [translate('workspace.accounting.talkToConcierge'), conciergeReportID]; + }, [account?.accountManagerAccountID, account?.accountManagerReportID, conciergeReportID, policy?.chatReportIDAdmins, translate]); const oldDotPolicyConnectionsURL = policyID ? `${oldDotEnvironmentURL}/${CONST.OLDDOT_URLS.POLICY_CONNECTIONS_URL_ENCODED(policyID)}` : ''; @@ -546,86 +957,96 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { policyID={policyID} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} policyFeature={CONST.POLICY.POLICY_FEATURE.ACCOUNTING} - shouldBeBlocked={!connectedIntegration && !hasUnsupportedNDIntegration} > Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} - > - {!!connectionDetails && isSyncInProgress && } - + title={translate('workspace.common.accounting')} + shouldShowBackButton={shouldUseNarrowLayout} + shouldUseHeadlineHeader + shouldDisplayHelpButton + onBackButtonPress={Navigation.goBack} + /> - - {shouldShowSynchronizationError && ( - - )} - {!!qboTokenExpiryHint && ( - - )} - {!hasUnsupportedNDIntegration && - connectionsMenuItems.map((menuItem) => ( - - +
+ {!hasUnsupportedNDIntegration && + connectionsMenuItems.map((menuItem) => ( + - - ))} - {hasUnsupportedNDIntegration && hasSyncError && !!policyID && ( - - )} - {hasUnsupportedNDIntegration && !hasSyncError && !!policyID && ( - - - { - // Go to Expensify Classic. - openOldDotLink(CONST.OLDDOT_URLS.POLICY_CONNECTIONS_URL(policyID)); - }} + shouldDisableStrikeThrough > - {translate('workspace.accounting.goToODToSettings')} - - - - )} + + + ))} + {hasUnsupportedNDIntegration && hasSyncError && !!policyID && ( + + )} + {hasUnsupportedNDIntegration && !hasSyncError && !!policyID && ( + + + { + // Go to Expensify Classic. + openOldDotLink(CONST.OLDDOT_URLS.POLICY_CONNECTIONS_URL(policyID)); + }} + > + {translate('workspace.accounting.goToODToSettings')} + + + + )} + {!!otherIntegrationsItems && ( + + + + )} + {!!account?.guideDetails?.email && !hasAccountingConnections(policy) && canWriteAccounting && ( + + + + {translate('workspace.accounting.needAnotherAccounting')} + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(String(chatReportID)))}>{chatTextLink} + + + )} +
@@ -641,4 +1062,4 @@ function PolicyAccountingPageWrapper(props: PolicyAccountingPageProps) { ); } -export default withPolicyConnections(PolicyAccountingPageWrapper); +export default withUnifiedConnectionsBeta(withPolicyConnections(PolicyAccountingPageWrapper)); diff --git a/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.native.tsx b/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.native.tsx index 64c9f7ce052f..f987325181cc 100644 --- a/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.native.tsx +++ b/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.native.tsx @@ -1,10 +1,13 @@ +import usePermissions from '@hooks/usePermissions'; + import {getNetSuiteSetupLink} from '@libs/actions/connections/NetSuiteCommands'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import AccountingSetupWebViewPage from '@pages/workspace/accounting/AccountingSetupWebViewPage'; +import {getAccountingConnectionsRoute} from '@pages/workspace/connections/utils'; -import ROUTES from '@src/ROUTES'; +import CONST from '@src/CONST'; import type SCREENS from '@src/SCREENS'; import React from 'react'; @@ -14,6 +17,7 @@ type NetSuiteSetupPageProps = PlatformStackScreenProps ); } diff --git a/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.tsx b/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.tsx index d67c4ba8d595..2b30f9db5b6d 100644 --- a/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.tsx +++ b/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.tsx @@ -18,7 +18,7 @@ function NetSuiteSetupPage({route}: NetSuiteSetupPageProps) { // only reached via a deep link, so just return to the workspace accounting page. We wait for the RHP open // transition to finish first, otherwise goBack fires mid-transition and is dropped. const handle = TransitionTracker.runAfterTransitions({ - callback: () => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)), + callback: () => Navigation.goBack(ROUTES.POLICY_ACCOUNTING.getRoute(policyID)), waitForUpcomingTransition: true, }); return () => handle.cancel(); diff --git a/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteExistingConnectionsPage.tsx b/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteExistingConnectionsPage.tsx index 1c7f5fac6392..0b522fa9c2a6 100644 --- a/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteExistingConnectionsPage.tsx +++ b/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteExistingConnectionsPage.tsx @@ -2,6 +2,7 @@ import ConnectionLayout from '@components/ConnectionLayout'; import MenuItemList from '@components/MenuItemList'; import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; import useReusablePoliciesConnectedTo from '@hooks/useReusablePoliciesConnectedTo'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -10,8 +11,9 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {getAccountingConnectionsRoute} from '@pages/workspace/connections/utils'; + import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import React from 'react'; @@ -23,6 +25,8 @@ function NetSuiteExistingConnectionsPage({route}: ExistingConnectionsPageProps) const {translate, datetimeToRelative} = useLocalize(); const styles = useThemeStyles(); const policyID: string = route.params.policyID; + const {isBetaEnabled} = usePermissions(); + const accountingConnectionsRoute = getAccountingConnectionsRoute(isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS), policyID); const {reusablePoliciesConnectedTo: reusablePoliciesConnectedToNetSuite} = useReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.NETSUITE, policyID); const menuItems = reusablePoliciesConnectedToNetSuite.map((policy) => { @@ -37,7 +41,7 @@ function NetSuiteExistingConnectionsPage({route}: ExistingConnectionsPageProps) description: date ? translate('workspace.common.lastSyncDate', CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.netsuite, date) : translate('workspace.accounting.netsuite'), onPress: () => { copyExistingPolicyConnection(policy.id, policyID, CONST.POLICY.CONNECTIONS.NAME.NETSUITE); - Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)); + Navigation.goBack(accountingConnectionsRoute); }, }; }); @@ -53,7 +57,7 @@ function NetSuiteExistingConnectionsPage({route}: ExistingConnectionsPageProps) titleStyle={styles.ph5} shouldLoadForEmptyConnection connectionName={CONST.POLICY.CONNECTIONS.NAME.NETSUITE} - onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} + onBackButtonPress={() => Navigation.goBack(accountingConnectionsRoute)} > { if (!policyID) { @@ -29,7 +32,7 @@ function QuickBooksDesktopSetupFlowSyncPage({route}: QuickBooksDesktopSetupFlowS syncConnection(policy, CONST.POLICY.CONNECTIONS.NAME.QBD, true); } - Navigation.navigate(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)); + Navigation.navigate(getAccountingConnectionsRoute(isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS), policyID)); // disabling this rule, as we want this to run only on the first render // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/pages/workspace/accounting/qbo/QuickbooksOnlineSetupPage/index.tsx b/src/pages/workspace/accounting/qbo/QuickbooksOnlineSetupPage/index.tsx index aed1e3cc5723..70b6f67d251f 100644 --- a/src/pages/workspace/accounting/qbo/QuickbooksOnlineSetupPage/index.tsx +++ b/src/pages/workspace/accounting/qbo/QuickbooksOnlineSetupPage/index.tsx @@ -18,7 +18,7 @@ function QuickbooksOnlineSetupPage({route}: QuickbooksOnlineSetupPageProps) { // only reached via a deep link, so just return to the workspace accounting page. We wait for the RHP open // transition to finish first, otherwise goBack fires mid-transition and is dropped. const handle = TransitionTracker.runAfterTransitions({ - callback: () => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)), + callback: () => Navigation.goBack(ROUTES.POLICY_ACCOUNTING.getRoute(policyID)), waitForUpcomingTransition: true, }); return () => handle.cancel(); diff --git a/src/pages/workspace/accounting/utils.tsx b/src/pages/workspace/accounting/utils.tsx index c3814ec30b25..5c8ba031e6cc 100644 --- a/src/pages/workspace/accounting/utils.tsx +++ b/src/pages/workspace/accounting/utils.tsx @@ -21,6 +21,8 @@ import {getCurrentConnectionName} from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; +import {getAccountingConnectionsRoute} from '@pages/workspace/connections/utils'; + import type {ThemeStyles} from '@styles/index'; import {getTrackingCategories} from '@userActions/connections/Xero'; @@ -91,6 +93,7 @@ function getAccountingIntegrationData( cardFeeds?: CombinedCardFeeds, cardList?: Record, isIntuitEnterpriseSuiteOverride?: boolean, + isUnifiedConnectionsBetaEnabled = false, ): AccountingIntegration | undefined { const basePath = ROUTES.POLICY_ACCOUNTING.getRoute(policyID); const qboConfig = policy?.connections?.quickbooksOnline?.config; @@ -101,16 +104,16 @@ function getAccountingIntegrationData( const netsuiteSelectedSubsidiary = (policy?.connections?.netsuite?.options?.data?.subsidiaryList ?? []).find((subsidiary) => subsidiary.internalID === netsuiteConfig?.subsidiaryID); const getBackToAfterWorkspaceUpgradeRouteForIntacct = () => { if (integrationToDisconnect) { - return ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); + return getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); } if (existingConnections.sageIntacct) { return ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXISTING_CONNECTIONS.getRoute(policyID); } - return createDynamicRoute(DYNAMIC_ROUTES.SAGE_INTACCT_PREREQUISITES.path, ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)); + return createDynamicRoute(DYNAMIC_ROUTES.SAGE_INTACCT_PREREQUISITES.path, getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID)); }; const getBackToAfterWorkspaceUpgradeRouteForQBD = () => { if (integrationToDisconnect) { - return ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); + return getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); } if (existingConnections.qbd) { return ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXISTING_CONNECTIONS.getRoute(policyID); @@ -119,7 +122,7 @@ function getAccountingIntegrationData( }; const getBackToAfterWorkspaceUpgradeRouteForCertinia = () => { if (integrationToDisconnect) { - return ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); + return getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); } if (existingConnections.certinia) { return ROUTES.POLICY_ACCOUNTING_CERTINIA_EXISTING_CONNECTIONS.getRoute(policyID); @@ -128,7 +131,7 @@ function getAccountingIntegrationData( }; const getBackToAfterWorkspaceUpgradeRouteForRillet = () => { if (integrationToDisconnect) { - return ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); + return getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); } if (existingConnections.rillet) { return ROUTES.POLICY_ACCOUNTING_RILLET_EXISTING_CONNECTIONS.getRoute(policyID); @@ -137,7 +140,7 @@ function getAccountingIntegrationData( }; const getBackToAfterWorkspaceUpgradeRouteForDualEntry = () => { if (integrationToDisconnect) { - return ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); + return getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); } if (existingConnections.dualEntry) { return ROUTES.POLICY_ACCOUNTING_DUALENTRY_EXISTING_CONNECTIONS.getRoute(policyID); @@ -146,7 +149,7 @@ function getAccountingIntegrationData( }; const getBackToAfterWorkspaceUpgradeRouteForCampfire = () => { if (integrationToDisconnect) { - return ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); + return getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting); } if (existingConnections.campfire) { return ROUTES.POLICY_ACCOUNTING_CAMPFIRE_EXISTING_CONNECTIONS.getRoute(policyID); @@ -211,7 +214,8 @@ function getAccountingIntegrationData( ? { workspaceUpgradeNavigationDetails: { integrationAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.intuitEnterpriseSuite.alias, - backToAfterWorkspaceUpgradeRoute: ROUTES.WORKSPACE_CONNECTIONS.getRoute( + backToAfterWorkspaceUpgradeRoute: getAccountingConnectionsRoute( + isUnifiedConnectionsBetaEnabled, policyID, connectionName, integrationToDisconnect, @@ -328,7 +332,7 @@ function getAccountingIntegrationData( workspaceUpgradeNavigationDetails: { integrationAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.netsuite.alias, backToAfterWorkspaceUpgradeRoute: integrationToDisconnect - ? ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting) + ? getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting) : ROUTES.POLICY_ACCOUNTING_NETSUITE_TOKEN_INPUT.getRoute(policyID, getInitialSubPageForNetsuiteTokenInput(policy)), }, pendingFields: {...netsuiteConfig?.pendingFields, ...policy?.connections?.netsuite?.config?.pendingFields, ...policy?.connections?.netsuite?.options?.config?.pendingFields}, @@ -691,7 +695,7 @@ function getAccountingIntegrationData( workspaceUpgradeNavigationDetails: { integrationAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.businessCentral.alias, backToAfterWorkspaceUpgradeRoute: integrationToDisconnect - ? ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting) + ? getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID, connectionName, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting) : ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_PREREQUISITES.getRoute(policyID), }, pendingFields: policy?.connections?.businessCentral?.config?.pendingFields, diff --git a/src/pages/workspace/accounting/xero/XeroSetupPage/index.tsx b/src/pages/workspace/accounting/xero/XeroSetupPage/index.tsx index 49917e324b83..9dc2a960fefd 100644 --- a/src/pages/workspace/accounting/xero/XeroSetupPage/index.tsx +++ b/src/pages/workspace/accounting/xero/XeroSetupPage/index.tsx @@ -18,7 +18,7 @@ function XeroSetupPage({route}: XeroSetupPageProps) { // only reached via a deep link, so just return to the workspace accounting page. We wait for the RHP open // transition to finish first, otherwise goBack fires mid-transition and is dropped. const handle = TransitionTracker.runAfterTransitions({ - callback: () => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)), + callback: () => Navigation.goBack(ROUTES.POLICY_ACCOUNTING.getRoute(policyID)), waitForUpcomingTransition: true, }); return () => handle.cancel(); diff --git a/src/pages/workspace/connections/ConnectionsAccountingPage.tsx b/src/pages/workspace/connections/ConnectionsAccountingPage.tsx new file mode 100644 index 000000000000..495204e0e5c4 --- /dev/null +++ b/src/pages/workspace/connections/ConnectionsAccountingPage.tsx @@ -0,0 +1,648 @@ +import ActivityIndicator from '@components/ActivityIndicator'; +import UserAvatar from '@components/Avatar/UserAvatar'; +import FormHelpMessage from '@components/FormHelpMessage'; +import Header from '@components/Header'; +import MenuItem from '@components/MenuItem'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; +import TextLink from '@components/TextLink'; +import type ThreeDotsMenuProps from '@components/ThreeDotsMenu/types'; + +import useCardFeeds from '@hooks/useCardFeeds'; +import useCardsLists from '@hooks/useCardsLists'; +import useConfirmModal from '@hooks/useConfirmModal'; +import useEnvironment from '@hooks/useEnvironment'; +import useExpensifyCardFeeds from '@hooks/useExpensifyCardFeeds'; +import useHasReusablePoliciesConnectedTo from '@hooks/useHasReusablePoliciesConnectedTo'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; +import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; + +import {getQBORefreshTokenExpiryDate, getQBORefreshTokenExpiryStatus} from '@libs/AccountingUtils'; +import {isAuthenticationError, isConnectionInProgress, isConnectionUnverified, removePolicyConnection, syncConnection} from '@libs/actions/connections'; +import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; +import {isExpensifyCardFullySetUp} from '@libs/CardUtils'; +import DateUtils from '@libs/DateUtils'; +import {getOldDotURLFromEnvironment} from '@libs/Environment/Environment'; +import { + areSettingsInErrorFields, + findCurrentXeroOrganization, + getConnectedIntegration, + getCurrentSageIntacctEntityName, + getCurrentXeroOrganizationName, + getIntegrationLastSuccessfulDate, + getXeroTenants, + hasAccountingConnections, + hasSupportedOnlyOnOldDotIntegration, + settingsPendingAction, + shouldShowSyncError, +} from '@libs/PolicyUtils'; + +import Navigation from '@navigation/Navigation'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import {AccountingContextProvider, useAccountingActions, useAccountingState} from '@pages/workspace/accounting/AccountingContext'; +import {getCertiniaSelectedCompanyID, isCertiniaFFAConnection} from '@pages/workspace/accounting/certinia/utils'; +import type {MenuItemData, PolicyAccountingPageProps} from '@pages/workspace/accounting/types'; +import {getAccountingIntegrationData, getAccountingIntegrationDisplayName, getSynchronizationErrorMessage, isIntuitEnterpriseSuiteConnection} from '@pages/workspace/accounting/utils'; +import withPolicyConnections from '@pages/workspace/withPolicyConnections'; + +import {openOldDotLink} from '@userActions/Link'; +import {openPolicyExpensifyCardsPage} from '@userActions/Policy/Policy'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import React, {useEffect} from 'react'; +import {View} from 'react-native'; + +import useRedirectUnconnectedPanelToConnections from './useRedirectUnconnectedPanelToConnections'; + +/** Settings for the workspace's connected accounting integration, opened from the Connections page. */ +function ConnectionsAccountingPage({policy}: PolicyAccountingPageProps) { + useWorkspaceDocumentTitle(policy?.name, 'workspace.common.accounting'); + const hasReusablePoliciesConnectedToSageIntacct = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, policy?.id); + const hasReusablePoliciesConnectedToQBD = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.QBD, policy?.id); + const hasReusablePoliciesConnectedToCertinia = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CERTINIA, policy?.id); + const hasReusablePoliciesConnectedToRillet = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.RILLET, policy?.id); + const hasReusablePoliciesConnectedToDualEntry = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, policy?.id); + const hasReusablePoliciesConnectedToCampfire = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE, policy?.id); + const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy?.id}`); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate, datetimeToRelative: getDatetimeToRelative, getLocalDateFromDatetime, dateFnsLocale} = useLocalize(); + const {environment} = useEnvironment(); + const oldDotEnvironmentURL = getOldDotURLFromEnvironment(environment); + const {isOffline} = useNetwork(); + const {isBetaEnabled} = usePermissions(); + const {showConfirmModal} = useConfirmModal(); + const {activeIntegration} = useAccountingState(); + const {startIntegrationFlow} = useAccountingActions(); + const policyID = policy?.id; + const workspaceAccountID = useWorkspaceAccountID(policyID); + const allCardSettings = useExpensifyCardFeeds(policyID); + const isSyncInProgress = isConnectionInProgress(connectionSyncProgress, policy); + const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'CircularArrowBackwards', 'ExpensifyCard', 'Gear', 'Key', 'NewWindow', 'Pencil', 'Send', 'Sync', 'Trashcan']); + const accountingIcons = useMemoizedLazyExpensifyIcons([ + 'IntacctSquare', + 'IntuitSquare', + 'QBOSquare', + 'XeroSquare', + 'NetSuiteSquare', + 'QBDSquare', + 'CertiniaSquare', + 'RilletSquare', + 'DualEntrySquare', + 'CampfireSquare', + 'BusinessCentralSquare', + ]); + const [cardFeeds] = useCardFeeds(policyID); + const [cardLists] = useCardsLists(); + const connectionSyncStage = connectionSyncProgress?.stageInProgress; + + const canUseCampfireIntegration = isBetaEnabled(CONST.BETAS.CAMPFIRE) || !!policy?.connections?.campfire; + const canUseBusinessCentralIntegration = isBetaEnabled(CONST.BETAS.BUSINESS_CENTRAL) || !!policy?.connections?.businessCentral; + const accountingIntegrations = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES.filter((name) => { + if (name === CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE) { + return canUseCampfireIntegration; + } + if (name === CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL) { + return canUseBusinessCentralIntegration; + } + return true; + }); + const syncingAccountingIntegration = accountingIntegrations.find((integration) => integration === connectionSyncProgress?.connectionName); + const connectedIntegration = getConnectedIntegration(policy, accountingIntegrations) ?? syncingAccountingIntegration; + const isIntuitEnterpriseSuiteSyncInProgress = isSyncInProgress && activeIntegration?.name === CONST.POLICY.CONNECTIONS.NAME.QBO && activeIntegration.isIntuitEnterpriseSuite === true; + const isConnectedToIntuitEnterpriseSuite = + connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBO && (isIntuitEnterpriseSuiteConnection(policy) || isIntuitEnterpriseSuiteSyncInProgress); + const connectedIntegrationDisplayName = connectedIntegration ? getAccountingIntegrationDisplayName(policy, connectedIntegration, translate) : undefined; + const hasAccountingConnection = hasAccountingConnections(policy); + const {canWrite: canWriteAccounting} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); + const synchronizationError = connectedIntegration && getSynchronizationErrorMessage(policy, connectedIntegration, isSyncInProgress, translate, styles); + + const isSageIntacct = connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT; + const hasAuthError = !!connectedIntegration && !!synchronizationError && isAuthenticationError(policy, connectedIntegration); + // A QBO refresh token that is about to expire (or already has, without a sync failing yet) is warned about while the connection still looks healthy + const qboTokenExpiryStatus = + connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBO && !synchronizationError && !isSyncInProgress ? getQBORefreshTokenExpiryStatus(policy) : undefined; + const isQBOTokenExpiringSoon = !!qboTokenExpiryStatus; + const qboTokenExpiryDate = isQBOTokenExpiringSoon ? getQBORefreshTokenExpiryDate(policy) : undefined; + const shouldShowEnterCredentials = !!connectedIntegration && (hasAuthError || isSageIntacct || isQBOTokenExpiringSoon); + + // Get the last successful date of the integration. Then, if `connectionSyncProgress` is the same integration displayed and the state is 'jobDone', get the more recent update time of the two. + const successfulDate = getIntegrationLastSuccessfulDate( + getLocalDateFromDatetime, + connectedIntegration ? policy?.connections?.[connectedIntegration] : undefined, + connectedIntegration === connectionSyncProgress?.connectionName ? connectionSyncProgress : undefined, + ); + const datetimeToRelative = successfulDate ? getDatetimeToRelative(successfulDate) : ''; + + const hasSyncError = shouldShowSyncError(policy, isSyncInProgress, accountingIntegrations); + const hasUnsupportedNDIntegration = !isEmptyObject(policy?.connections) && hasSupportedOnlyOnOldDotIntegration(policy); + useRedirectUnconnectedPanelToConnections(policyID, true, !!connectedIntegration || hasUnsupportedNDIntegration); + + const tenants = getXeroTenants(policy); + const currentXeroOrganization = findCurrentXeroOrganization(tenants, policy?.connections?.xero?.config?.tenantID); + const shouldShowSynchronizationError = !!synchronizationError; + const shouldShowReinstallConnectorMenuItem = shouldShowSynchronizationError && connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBD; + const shouldShowCardReconciliationOption = Object.values(allCardSettings ?? {})?.some((cardSetting) => isExpensifyCardFullySetUp(policy, cardSetting)); + const shouldShowReconnect = hasAuthError && connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.CERTINIA; + let credentialsMenuTextKey: Parameters[0] = 'workspace.accounting.enterCredentials'; + if (shouldShowReconnect || isQBOTokenExpiringSoon) { + credentialsMenuTextKey = 'workspace.accounting.reconnect'; + } else if (isSageIntacct && !hasAuthError) { + credentialsMenuTextKey = 'workspace.accounting.updateCredentials'; + } + + const overflowMenu: ThreeDotsMenuProps['menuItems'] = [ + ...(shouldShowReinstallConnectorMenuItem + ? [ + { + icon: icons.CircularArrowBackwards, + text: translate('workspace.accounting.reinstall'), + onSelected: () => startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.QBD}), + shouldCallAfterModalHide: true, + disabled: isOffline, + iconRight: icons.NewWindow, + }, + ] + : []), + ...(shouldShowEnterCredentials + ? [ + { + icon: icons.Key, + text: translate(credentialsMenuTextKey), + onSelected: () => { + if (isSageIntacct && policyID) { + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTER_CREDENTIALS.getRoute(policyID)); + return; + } + startIntegrationFlow({name: connectedIntegration, isIntuitEnterpriseSuite: isConnectedToIntuitEnterpriseSuite}); + }, + shouldCallAfterModalHide: true, + disabled: isOffline, + iconRight: icons.NewWindow, + }, + ] + : []), + ...(!hasAuthError + ? [ + { + icon: icons.Sync, + text: translate('workspace.accounting.syncNow'), + onSelected: () => syncConnection(policy, connectedIntegration), + disabled: isOffline, + }, + ] + : []), + { + icon: icons.Trashcan, + text: translate('workspace.accounting.disconnect'), + onSelected: () => { + showConfirmModal({ + title: translate('workspace.accounting.disconnectTitle', connectedIntegrationDisplayName), + prompt: translate('workspace.accounting.disconnectPrompt', connectedIntegrationDisplayName), + confirmText: translate('workspace.accounting.disconnect'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }).then(({action}) => { + if (action !== ModalActions.CONFIRM || !connectedIntegration || !policyID) { + return; + } + // These settings have nothing to show once the integration is gone + Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)); + removePolicyConnection(policy, connectedIntegration); + }); + }, + shouldCallAfterModalHide: true, + }, + ]; + + useEffect(() => { + if (!policyID || !policy?.areExpensifyCardsEnabled || !workspaceAccountID) { + return; + } + openPolicyExpensifyCardsPage(policyID, workspaceAccountID); + }, [policyID, policy?.areExpensifyCardsEnabled, workspaceAccountID]); + + const getIntegrationSpecificMenuItems = () => { + const sageIntacctEntityList = policy?.connections?.intacct?.data?.entities ?? []; + const netSuiteSubsidiaryList = policy?.connections?.netsuite?.options?.data?.subsidiaryList ?? []; + const rilletSubsidiaryList = policy?.connections?.rillet?.data?.subsidiaries; + const dualEntryCompanyList = policy?.connections?.dualEntry?.data?.companies; + const campfireSubsidiaryList = policy?.connections?.campfire?.data?.subsidiaries; + const businessCentralCompanyList = policy?.connections?.businessCentral?.data?.companies; + const certiniaConfig = policy?.connections?.financialforce?.config; + const certiniaCompanies = policy?.connections?.financialforce?.data?.companies ?? []; + const certiniaCompanyID = getCertiniaSelectedCompanyID(certiniaConfig); + const certiniaCompanyField = certiniaConfig?.hasPSA ? CONST.CERTINIA_CONFIG.COMPANY_ID : CONST.CERTINIA_CONFIG.COMPANY; + const selectedCertiniaCompany = certiniaCompanies.find((company) => company.id === certiniaCompanyID); + switch (connectedIntegration) { + case CONST.POLICY.CONNECTIONS.NAME.XERO: + return !policy?.connections?.xero?.data?.tenants + ? {} + : { + description: translate('workspace.xero.organization'), + iconRight: icons.ArrowRight, + title: getCurrentXeroOrganizationName(policy), + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting && tenants.length > 1, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + onPress: + canWriteAccounting && tenants.length > 1 + ? () => { + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_XERO_ORGANIZATION.getRoute(policyID, currentXeroOrganization?.id)); + } + : undefined, + pendingAction: settingsPendingAction([CONST.XERO_CONFIG.TENANT_ID], policy?.connections?.xero?.config?.pendingFields), + brickRoadIndicator: areSettingsInErrorFields([CONST.XERO_CONFIG.TENANT_ID], policy?.connections?.xero?.config?.errorFields) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.NETSUITE: + return !policy?.connections?.netsuite?.options?.config?.subsidiary + ? {} + : { + description: translate('workspace.netsuite.subsidiary'), + iconRight: icons.ArrowRight, + title: policy?.connections?.netsuite?.options?.config?.subsidiary ?? '', + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting && netSuiteSubsidiaryList?.length > 1, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: policy?.connections?.netsuite?.options?.config?.pendingFields?.subsidiary, + brickRoadIndicator: policy?.connections?.netsuite?.options?.config?.errorFields?.subsidiary ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: + canWriteAccounting && netSuiteSubsidiaryList?.length > 1 + ? () => { + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR.getRoute(policyID)); + } + : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT: + return !sageIntacctEntityList.length + ? {} + : { + description: translate('workspace.intacct.entity'), + iconRight: icons.ArrowRight, + title: getCurrentSageIntacctEntityName(policy, translate('workspace.common.topLevel')), + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: policy?.connections?.intacct?.config?.pendingFields?.entity, + brickRoadIndicator: policy?.connections?.intacct?.config?.errorFields?.entity ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: canWriteAccounting ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTITY.getRoute(policyID)) : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.QBO: + return !policy?.connections?.quickbooksOnline?.config?.companyName + ? {} + : { + description: translate(isConnectedToIntuitEnterpriseSuite ? 'workspace.qbo.entity' : 'workspace.qbo.connectedTo'), + iconRight: isConnectedToIntuitEnterpriseSuite ? icons.ArrowRight : undefined, + title: policy?.connections?.quickbooksOnline?.config?.companyName, + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: isConnectedToIntuitEnterpriseSuite && canWriteAccounting, + shouldShowDescriptionOnTop: true, + interactive: isConnectedToIntuitEnterpriseSuite && canWriteAccounting, + pendingAction: policy?.connections?.quickbooksOnline?.config?.pendingFields?.realmId, + brickRoadIndicator: policy?.connections?.quickbooksOnline?.config?.errorFields?.realmId ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: + isConnectedToIntuitEnterpriseSuite && canWriteAccounting && policyID + ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_INTUIT_ENTERPRISE_SUITE_ENTITY_SELECTOR.getRoute(policyID)) + : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.CERTINIA: + return !isCertiniaFFAConnection(certiniaConfig) + ? {} + : { + description: translate('workspace.certinia.company'), + iconRight: icons.ArrowRight, + title: selectedCertiniaCompany?.name ?? certiniaCompanyID ?? translate('common.none'), + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: settingsPendingAction([certiniaCompanyField], certiniaConfig?.pendingFields), + brickRoadIndicator: areSettingsInErrorFields([certiniaCompanyField], certiniaConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: canWriteAccounting ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CERTINIA_COMPANY_SELECTOR.getRoute(policyID)) : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.RILLET: + return !rilletSubsidiaryList?.length + ? {} + : { + description: translate('workspace.rillet.subsidiary'), + iconRight: icons.ArrowRight, + title: rilletSubsidiaryList?.find((subsidiary) => subsidiary.id === policy?.connections?.rillet?.config?.subsidiaryID)?.tradeName ?? '', + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting && rilletSubsidiaryList && rilletSubsidiaryList.length > 1, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: policy?.connections?.rillet?.config.pendingFields?.subsidiaryID, + brickRoadIndicator: policy?.connections?.rillet?.config.errorFields?.subsidiaryID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: + policyID && canWriteAccounting && rilletSubsidiaryList && rilletSubsidiaryList.length > 1 + ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_SUBSIDIARY_SELECTOR.getRoute(policyID)) + : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: + return !dualEntryCompanyList?.length + ? {} + : { + description: translate('workspace.dualEntry.subsidiary'), + iconRight: icons.ArrowRight, + title: dualEntryCompanyList?.find((company) => company.id === policy?.connections?.dualEntry?.config?.subsidiaryID)?.name ?? '', + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting && dualEntryCompanyList && dualEntryCompanyList.length > 1, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: policy?.connections?.dualEntry?.config.pendingFields?.subsidiaryID, + brickRoadIndicator: policy?.connections?.dualEntry?.config.errorFields?.subsidiaryID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: + policyID && canWriteAccounting && dualEntryCompanyList && dualEntryCompanyList.length > 1 + ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_DUALENTRY_SUBSIDIARY_SELECTOR.getRoute(policyID)) + : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: + return !campfireSubsidiaryList?.length + ? {} + : { + description: translate('workspace.campfire.subsidiary'), + iconRight: icons.ArrowRight, + title: campfireSubsidiaryList?.find((subsidiary) => subsidiary.id === policy?.connections?.campfire?.config?.subsidiaryID)?.name ?? '', + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting && campfireSubsidiaryList && campfireSubsidiaryList.length > 1, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: policy?.connections?.campfire?.config.pendingFields?.subsidiaryID, + brickRoadIndicator: policy?.connections?.campfire?.config.errorFields?.subsidiaryID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: + policyID && canWriteAccounting && campfireSubsidiaryList && campfireSubsidiaryList.length > 1 + ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CAMPFIRE_SUBSIDIARY_SELECTOR.getRoute(policyID)) + : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL: + return !businessCentralCompanyList?.length + ? {} + : { + description: translate('workspace.businessCentral.subsidiary'), + iconRight: icons.ArrowRight, + title: businessCentralCompanyList.find((company) => company.id === policy?.connections?.businessCentral?.config?.companyID)?.displayName ?? '', + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting && businessCentralCompanyList.length > 1, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: policy?.connections?.businessCentral?.config.pendingFields?.companyID, + brickRoadIndicator: policy?.connections?.businessCentral?.config.errorFields?.companyID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: + policyID && canWriteAccounting && businessCentralCompanyList.length > 1 + ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_COMPANY_SELECTOR.getRoute(policyID)) + : undefined, + }; + + default: + return undefined; + } + }; + const integrationSpecificMenuItems = getIntegrationSpecificMenuItems(); + + const getConnectionDetails = () => { + if (!connectedIntegration || !policyID) { + return undefined; + } + const isConnectionVerified = !isConnectionUnverified(policy, connectedIntegration); + const integrationData = getAccountingIntegrationData( + connectedIntegration, + policyID, + translate, + { + sageIntacct: hasReusablePoliciesConnectedToSageIntacct, + qbd: hasReusablePoliciesConnectedToQBD, + certinia: hasReusablePoliciesConnectedToCertinia, + rillet: hasReusablePoliciesConnectedToRillet, + dualEntry: hasReusablePoliciesConnectedToDualEntry, + campfire: hasReusablePoliciesConnectedToCampfire, + }, + policy, + undefined, + undefined, + undefined, + isBetaEnabled(CONST.BETAS.NETSUITE_USA_TAX), + accountingIcons, + cardFeeds, + cardLists, + isConnectedToIntuitEnterpriseSuite, + isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS), + ); + let connectionMessage; + if (isSyncInProgress && connectionSyncStage) { + connectionMessage = translate('workspace.accounting.connections.syncStageName', connectionSyncStage, integrationData?.title); + } else if (!isConnectionVerified) { + connectionMessage = translate('workspace.accounting.notSync'); + } else { + connectionMessage = translate('workspace.accounting.lastSync', datetimeToRelative); + } + + const configurationOptions = canWriteAccounting + ? [ + { + icon: icons.Pencil, + iconRight: icons.ArrowRight, + shouldShowRightIcon: true, + title: translate('workspace.accounting.import'), + onPress: integrationData?.onImportPagePress, + brickRoadIndicator: areSettingsInErrorFields(integrationData?.subscribedImportSettings, integrationData?.errorFields) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : undefined, + pendingAction: settingsPendingAction(integrationData?.subscribedImportSettings, integrationData?.pendingFields), + }, + { + icon: icons.Send, + iconRight: icons.ArrowRight, + shouldShowRightIcon: true, + title: translate('workspace.accounting.export'), + onPress: integrationData?.onExportPagePress, + brickRoadIndicator: + areSettingsInErrorFields(integrationData?.subscribedExportSettings, integrationData?.errorFields) || + shouldShowQBOReimbursableExportDestinationAccountError(policy) || + integrationData?.externalSubscribedExportSettingsHasErrorFields + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : undefined, + pendingAction: + settingsPendingAction(integrationData?.subscribedExportSettings, integrationData?.pendingFields) ?? integrationData?.externalSubscribedExportSettingsPendingAction, + }, + ...(shouldShowCardReconciliationOption && integrationData?.onCardReconciliationPagePress + ? [ + { + icon: icons.ExpensifyCard, + iconRight: icons.ArrowRight, + shouldShowRightIcon: true, + title: translate('workspace.accounting.cardReconciliation'), + onPress: integrationData?.onCardReconciliationPagePress, + }, + ] + : []), + { + icon: icons.Gear, + iconRight: icons.ArrowRight, + shouldShowRightIcon: true, + title: translate('workspace.accounting.advanced'), + onPress: integrationData?.onAdvancedPagePress, + brickRoadIndicator: areSettingsInErrorFields(integrationData?.subscribedAdvancedSettings, integrationData?.errorFields) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : undefined, + pendingAction: settingsPendingAction(integrationData?.subscribedAdvancedSettings, integrationData?.pendingFields), + }, + ] + : []; + + const menuItems: MenuItemData[] = [ + ...(isEmptyObject(integrationSpecificMenuItems) || shouldShowSynchronizationError || !hasAccountingConnection ? [] : [integrationSpecificMenuItems]), + ...(!hasAccountingConnection || !isConnectionVerified ? [] : configurationOptions), + ]; + return {icon: integrationData?.icon, title: integrationData?.title, connectionMessage, menuItems}; + }; + const connectionDetails = getConnectionDetails(); + const connectionsMenuItems = connectionDetails?.menuItems ?? []; + + let qboTokenExpiryHint; + if (qboTokenExpiryDate && canWriteAccounting) { + const formattedExpiryDate = DateUtils.formatWithUTCTimeZone(qboTokenExpiryDate.toISOString(), CONST.DATE.MONTH_DAY_YEAR_FORMAT, dateFnsLocale); + qboTokenExpiryHint = ( + <> + {translate( + qboTokenExpiryStatus === CONST.POLICY.CONNECTIONS.QBO_REFRESH_TOKEN_EXPIRY_STATUS.EXPIRED + ? 'workspace.accounting.qboConnectionExpired' + : 'workspace.accounting.qboConnectionExpiring', + {date: formattedExpiryDate}, + )}{' '} + startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.QBO, isIntuitEnterpriseSuite: isConnectedToIntuitEnterpriseSuite})}> + {translate('workspace.accounting.reconnect')} + + + ); + } + + const oldDotPolicyConnectionsURL = policyID ? `${oldDotEnvironmentURL}/${CONST.OLDDOT_URLS.POLICY_CONNECTIONS_URL_ENCODED(policyID)}` : ''; + + return ( + + +
+ Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} /> + {!!connectionDetails?.icon && ( + + )} + + + {!!connectionDetails && isSyncInProgress && } + {!!connectionDetails && canWriteAccounting && !isSyncInProgress && } + +
+ + + {shouldShowSynchronizationError && ( + + )} + {!!qboTokenExpiryHint && ( + + )} + {!hasUnsupportedNDIntegration && + connectionsMenuItems.map((menuItem) => ( + + + + ))} + {hasUnsupportedNDIntegration && hasSyncError && !!policyID && ( + + )} + {hasUnsupportedNDIntegration && !hasSyncError && !!policyID && ( + + + { + // Go to Expensify Classic. + openOldDotLink(CONST.OLDDOT_URLS.POLICY_CONNECTIONS_URL(policyID)); + }} + > + {translate('workspace.accounting.goToODToSettings')} + + + + )} + + +
+
+ ); +} + +function ConnectionsAccountingPageWrapper(props: PolicyAccountingPageProps) { + return ( + + + + ); +} + +export default withPolicyConnections(ConnectionsAccountingPageWrapper); diff --git a/src/pages/workspace/connections/ConnectionsHRPage.tsx b/src/pages/workspace/connections/ConnectionsHRPage.tsx new file mode 100644 index 000000000000..af88d3fdee96 --- /dev/null +++ b/src/pages/workspace/connections/ConnectionsHRPage.tsx @@ -0,0 +1,54 @@ +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; +import usePolicy from '@hooks/usePolicy'; + +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import {getHRCards} from '@pages/workspace/hr/utils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; + +import ConnectionsMergePageBase from './ConnectionsMergePageBase'; + +type ConnectionsHRPageProps = PlatformStackScreenProps; + +function ConnectionsHRPage({ + route: { + params: {policyID}, + }, +}: ConnectionsHRPageProps) { + const {translate, getLocalDateFromDatetime, formatPhoneNumber} = useLocalize(); + const policy = usePolicy(policyID); + const policyEmployeePersonalDetails = usePersonalDetailsByLogins([...Object.keys(policy?.employeeList ?? {})]); + const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); + const icons = useMemoizedLazyExpensifyIcons(['GustoSquare', 'TriNetSquare']); + + const cards = getHRCards({ + policy, + policyEmployeePersonalDetails, + connectionSyncProgress, + getLocalDateFromDatetime, + translate, + formatPhoneNumber, + policyID, + gustoIcon: icons.GustoSquare, + trinetIcon: icons.TriNetSquare, + }); + + return ( + + ); +} + +export default ConnectionsHRPage; diff --git a/src/pages/workspace/connections/ConnectionsMergePageBase.tsx b/src/pages/workspace/connections/ConnectionsMergePageBase.tsx new file mode 100644 index 000000000000..bcfb006c031b --- /dev/null +++ b/src/pages/workspace/connections/ConnectionsMergePageBase.tsx @@ -0,0 +1,161 @@ +import ConnectToMergeFlow from '@components/ConnectToMergeFlow'; +import ScreenWrapper from '@components/ScreenWrapper'; + +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import usePolicy from '@hooks/usePolicy'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; + +import {openPolicyHRPage, openPolicyRecruitingPage} from '@libs/actions/PolicyConnections'; +import {isMergeConnectionName} from '@libs/merge/MergeUtils'; +import Navigation from '@libs/Navigation/Navigation'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import MergeInitialSyncingModalListener from '@pages/workspace/merge/MergeInitialSyncingModalListener'; +import MergeSyncResultsListener from '@pages/workspace/merge/MergeSyncResultsListener'; +import type {MergeProviderCardCategory, MergeProviderCardDescriptor} from '@pages/workspace/merge/types'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; + +import React, {useEffect, useState} from 'react'; + +import ConnectionsMergeProviderCard from './ConnectionsMergeProviderCard'; +import useRedirectUnconnectedPanelToConnections from './useRedirectUnconnectedPanelToConnections'; + +/** The handful of things that differ between the HR page and the Recruiting page. Everything else is shared. */ +const PAGE_CONFIG = { + [CONST.POLICY.CONNECTIONS.CATEGORY.HR]: { + featureName: CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED, + openPage: openPolicyHRPage, + testID: 'WorkspaceHRPage', + }, + [CONST.POLICY.CONNECTIONS.CATEGORY.RECRUITING]: { + featureName: CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED, + openPage: openPolicyRecruitingPage, + testID: 'WorkspaceRecruitingPage', + }, +} as const; + +type ConnectionsMergePageBaseContentProps = { + /** The workspace whose connections are listed. */ + policyID: string; + + /** Which page this is. Picks the category-specific copy, feature flag, and data-fetching command. */ + category: MergeProviderCardCategory; + + /** Provider cards built by the category's `utils`. Only the connected one is shown. */ + cards: MergeProviderCardDescriptor[]; +}; + +type ConnectionsMergePageBaseProps = ConnectionsMergePageBaseContentProps & { + /** Whether to block access to the page, e.g. when the category is still behind a beta. */ + shouldBeBlocked?: boolean; +}; + +function ConnectionsMergePageBaseContent({policyID, category, cards}: ConnectionsMergePageBaseContentProps) { + const {translate} = useLocalize(); + const policy = usePolicy(policyID); + const [activeSetupFlow, setActiveSetupFlow] = useState<{setupLink: string; key: number} | undefined>(); + + const {testID} = PAGE_CONFIG[category]; + + // At most one provider of a category can be connected to a workspace at a time. + const connectedCard = cards.find((card) => card.isConnected); + + const {canWrite: canWriteMoreFeatures, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); + + const handleReconnect = (card: MergeProviderCardDescriptor) => { + if (!card.setupLink) { + return; + } + + if (!canWriteMoreFeatures) { + showReadOnlyModal(); + return; + } + + // A random key forces a remount on every press, even for the same provider + setActiveSetupFlow({setupLink: card.setupLink, key: Math.random()}); + }; + + return ( + + {!!connectedCard && ( + + )} + {!!connectedCard && isMergeConnectionName(connectedCard.connectionName) && ( + + )} + {!!activeSetupFlow && ( + setActiveSetupFlow(undefined)} + /> + )} + {!!connectedCard && ( + handleReconnect(connectedCard)} + onDisconnect={() => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} + onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} + canWriteMoreFeatures={canWriteMoreFeatures} + showReadOnlyModal={showReadOnlyModal} + /> + )} + + ); +} + +function ConnectionsMergePageBase({policyID, category, cards, shouldBeBlocked}: ConnectionsMergePageBaseProps) { + const {featureName, openPage} = PAGE_CONFIG[category]; + const {isBetaEnabled} = usePermissions(); + const [hasConnectionsDataBeenFetched] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED}${policyID}`); + useRedirectUnconnectedPanelToConnections( + policyID, + !!hasConnectionsDataBeenFetched, + cards.some((card) => card.isConnected), + ); + + useWorkspaceDocumentTitle(undefined, `workspace.common.${category}`); + + useNetwork({onReconnect: () => openPage(policyID)}); + + useEffect(() => { + openPage(policyID); + }, [openPage, policyID]); + + return ( + card.isConnected)} + > + + + ); +} + +export default ConnectionsMergePageBase; diff --git a/src/pages/workspace/connections/ConnectionsMergeProviderCard.tsx b/src/pages/workspace/connections/ConnectionsMergeProviderCard.tsx new file mode 100644 index 000000000000..3e9b280936bc --- /dev/null +++ b/src/pages/workspace/connections/ConnectionsMergeProviderCard.tsx @@ -0,0 +1,233 @@ +import ActivityIndicator from '@components/ActivityIndicator'; +import UserAvatar from '@components/Avatar/UserAvatar'; +import FormHelpMessage from '@components/FormHelpMessage'; +import Header from '@components/Header'; +import MenuItem from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import RenderHTML from '@components/RenderHTML'; +import ScrollView from '@components/ScrollView'; +import TextLink from '@components/TextLink'; +import type ThreeDotsMenuProps from '@components/ThreeDotsMenu/types'; + +import useConfirmModal from '@hooks/useConfirmModal'; +import useEnvironment from '@hooks/useEnvironment'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {removePolicyConnection, syncConnection} from '@libs/actions/connections'; +import {clearMergeConnectionErrorField} from '@libs/actions/connections/merge'; +import {showMergeManualSyncLimitModalIfReached} from '@libs/merge/MergeUtils'; +import Navigation from '@libs/Navigation/Navigation'; + +import type {MergeProviderCardDescriptor} from '@pages/workspace/merge/types'; + +import CONST from '@src/CONST'; +import type Policy from '@src/types/onyx/Policy'; + +import type {ReactNode} from 'react'; + +import React from 'react'; +import {View} from 'react-native'; + +type ConnectionsMergeProviderCardProps = { + /** Descriptor object containing the Merge provider's display info, connection state, and sync status. */ + card: MergeProviderCardDescriptor; + + /** The workspace policy that owns this Merge integration. */ + policy: Policy | undefined; + + /** Callback invoked when the user taps the "Connect" or "Reconnect" button. */ + handleConnect: () => void; + + /** Called once the user confirms disconnecting, right before the connection is removed */ + onDisconnect?: () => void; + + /** Called when the header's back button is pressed. */ + onBackButtonPress: () => void; + + /** Whether the current user can edit this Merge connection. */ + canWriteMoreFeatures: boolean; + + /** Shows the read-only action modal. */ + showReadOnlyModal: () => void; +}; + +function ConnectionsMergeProviderCard({card, policy, handleConnect, onDisconnect, onBackButtonPress, canWriteMoreFeatures, showReadOnlyModal}: ConnectionsMergeProviderCardProps) { + const {translate, datetimeToRelative} = useLocalize(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {environmentURL} = useEnvironment(); + const {isOffline} = useNetwork(); + const icons = useMemoizedLazyExpensifyIcons(['Sync', 'Trashcan', 'Building', 'CheckCircle']); + const {showConfirmModal} = useConfirmModal(); + + const fallbackIcon = icons.Building; + const cardIcon = card.icon || fallbackIcon; + + let connectionDescription: string; + if (card.isSyncInProgress) { + connectionDescription = card.syncStageInProgress ? translate('workspace.hr.syncStageName', card.syncStageInProgress) : translate(`workspace.${card.category}.syncing`); + } else if (!card.successfulDate) { + connectionDescription = translate('workspace.merge.notSync'); + } else { + connectionDescription = translate('workspace.merge.lastSync', datetimeToRelative(card.successfulDate)); + } + + let lastSyncErrorMessage: ReactNode | undefined; + if (card.needsReconnect) { + lastSyncErrorMessage = ( + <> + {`${translate('workspace.merge.authenticationError', card.displayName)} `} + {!isOffline && ( + + {translate('workspace.merge.reconnectLink')} + + )} + + ); + } else if (card.hasError) { + const genericError = translate('workspace.merge.syncError', card.displayName); + lastSyncErrorMessage = card.lastSyncErrorMessage ? `${genericError} ("${card.lastSyncErrorMessage}")` : genericError; + } + + const getPrimaryMenuItem = (): ThreeDotsMenuProps['menuItems'][number] => { + if (card.needsReconnect) { + return { + icon: icons.Sync, + text: translate('workspace.merge.reconnect'), + onSelected: handleConnect, + disabled: isOffline, + }; + } + if (card.completeSetupRoute) { + return { + icon: icons.CheckCircle, + text: translate('workspace.merge.completeSetup'), + onSelected: () => { + if (!canWriteMoreFeatures) { + showReadOnlyModal(); + return; + } + if (card.completeSetupRoute) { + Navigation.navigate(card.completeSetupRoute); + } + }, + disabled: isOffline, + }; + } + return { + icon: icons.Sync, + text: translate('workspace.merge.syncNow'), + onSelected: () => { + if (showMergeManualSyncLimitModalIfReached(policy, card.connectionName, translate, showConfirmModal)) { + return; + } + syncConnection(policy, card.connectionName); + }, + disabled: isOffline, + shouldCallAfterModalHide: true, + }; + }; + + const overflowMenu: ThreeDotsMenuProps['menuItems'] = [ + getPrimaryMenuItem(), + { + icon: icons.Trashcan, + text: translate('workspace.merge.disconnect'), + onSelected: () => { + showConfirmModal({ + title: translate('workspace.merge.disconnectTitle', card.displayName), + prompt: translate('workspace.merge.disconnectPrompt', card.displayName), + confirmText: translate('workspace.merge.disconnect'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }).then((result) => { + if (result?.action !== ModalActions.CONFIRM || !policy) { + return; + } + onDisconnect?.(); + removePolicyConnection(policy, card.connectionName); + }); + }, + shouldCallAfterModalHide: true, + }, + ]; + + // While the setup is incomplete only the rows that failed to save are shown, so the admin is steered to the setup flow first. + const visibleConfigRows = card.isConnected && !card.isInitialSyncInProgress ? (card.configRows ?? []).filter((row) => !card.completeSetupRoute || !!row.errors) : []; + + return ( + <> +
+ + + + + {card.isSyncInProgress && } + {card.isConnected && !card.isSyncInProgress && } + +
+ + {!!card.completeSetupRoute && ( + + + + )} + {!!lastSyncErrorMessage && ( + + )} + {visibleConfigRows.map((row) => { + const RowMenuItem = row.shouldRenderAsMenuItem ? MenuItem : MenuItemWithTopDescription; + + return ( + clearMergeConnectionErrorField(policy?.id, card.connectionName, row.field)} + > + Navigation.navigate(row.route)} + interactive={canWriteMoreFeatures} + /> + + ); + })} + + + ); +} + +export default ConnectionsMergeProviderCard; diff --git a/src/pages/workspace/connections/ConnectionsReceiptPartnersPage.tsx b/src/pages/workspace/connections/ConnectionsReceiptPartnersPage.tsx new file mode 100644 index 000000000000..1d21b560f29d --- /dev/null +++ b/src/pages/workspace/connections/ConnectionsReceiptPartnersPage.tsx @@ -0,0 +1,269 @@ +import UserAvatar from '@components/Avatar/UserAvatar'; +import FormHelpMessage from '@components/FormHelpMessage'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import Header from '@components/Header'; +import MenuItem from '@components/MenuItem'; +import MenuItemField from '@components/MenuItem/presets/MenuItemField'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; + +import useConfirmModal from '@hooks/useConfirmModal'; +import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import usePermissions from '@hooks/usePermissions'; +import usePolicy from '@hooks/usePolicy'; +import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import useScreenBoundDynamicRoute from '@hooks/useScreenBoundDynamicRoute'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; + +import Navigation from '@navigation/Navigation'; +import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@navigation/types'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import getSynchronizationErrorMessage from '@pages/workspace/receiptPartners/utils'; +import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; + +import {openExternalLink} from '@userActions/Link'; +import {openPolicyReceiptPartnersPage, removePolicyReceiptPartnersConnection, togglePolicyUberAutoInvite, togglePolicyUberAutoRemove} from '@userActions/Policy/Policy'; + +import CONST from '@src/CONST'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import type {ValueOf} from 'type-fest'; + +import React, {useEffect} from 'react'; + +import useRedirectUnconnectedPanelToConnections from './useRedirectUnconnectedPanelToConnections'; + +type ConnectionsReceiptPartnersPageProps = PlatformStackScreenProps; + +function ConnectionsReceiptPartnersPage({route}: ConnectionsReceiptPartnersPageProps) { + const policyID = route.params.policyID; + const icons = useMemoizedLazyExpensifyIcons(['Key', 'Mail', 'NewWindow', 'Trashcan']); + const {translate} = useLocalize(); + const buildDynamicRoute = useScreenBoundDynamicRoute(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {showConfirmModal} = useConfirmModal(); + const policy = usePolicy(policyID); + const {isBetaEnabled} = usePermissions(); + useWorkspaceDocumentTitle(policy?.name, 'workspace.common.receiptPartners'); + const {getReceiptPartnersIntegrationData, shouldShowEnterCredentialsError, isUberConnected} = useGetReceiptPartnersIntegrationData(policyID); + const isLoading = policy?.isLoading; + useRedirectUnconnectedPanelToConnections(policyID, !!policy && !isLoading, isUberConnected || shouldShowEnterCredentialsError); + const integrations = policy?.receiptPartners; + const isAutoRemove = !!integrations?.uber?.autoRemove; + const isAutoInvite = !!integrations?.uber?.autoInvite; + const centralBillingAccountEmail = !!integrations?.uber?.centralBillingAccountEmail; + const {canWrite: canWriteMoreFeatures, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); + + const startIntegrationFlow = ({name}: {name: string}) => { + switch (name) { + case CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER: { + openExternalLink(`${CONST.UBER_CONNECT_URL}?${integrations?.uber?.connectFormData}`); + break; + } + default: { + break; + } + } + }; + + const fetchReceiptPartners = () => { + openPolicyReceiptPartnersPage(policyID); + }; + + const {isOffline} = useNetwork({onReconnect: fetchReceiptPartners}); + + useEffect(() => { + fetchReceiptPartners(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const toggleWorkspaceUberAutoInvite = () => { + togglePolicyUberAutoInvite(policyID, !isAutoInvite); + }; + + const toggleWorkspaceUberAutoRemove = () => { + togglePolicyUberAutoRemove(policyID, !isAutoRemove); + }; + + const disconnectPartner = (partner: ValueOf) => { + if (!policyID) { + return; + } + removePolicyReceiptPartnersConnection(policyID, partner, integrations?.[partner]); + fetchReceiptPartners(); + }; + + const getOverflowMenu = (integration: string) => { + switch (integration) { + case CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER: + if (shouldShowEnterCredentialsError) { + return [ + { + icon: icons.Key, + text: translate('workspace.accounting.enterCredentials'), + onSelected: () => + startIntegrationFlow({ + name: CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER, + }), + shouldCallAfterModalHide: true, + disabled: isOffline, + iconRight: icons.NewWindow, + }, + ]; + } + + return [ + { + icon: icons.Trashcan, + text: translate('workspace.accounting.disconnect'), + onSelected: () => { + showConfirmModal({ + title: translate('workspace.moreFeatures.receiptPartnersWarningModal.featureEnabledTitle'), + prompt: translate('workspace.moreFeatures.receiptPartnersWarningModal.description'), + confirmText: translate('workspace.accounting.disconnect'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }).then(({action}) => { + if (action !== ModalActions.CONFIRM) { + return; + } + // These settings have nothing to show once Uber is disconnected + Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)); + disconnectPartner(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER); + }); + }, + shouldCallAfterModalHide: true, + }, + ]; + default: + return []; + } + }; + + const uberData = policyID ? getReceiptPartnersIntegrationData(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER) : undefined; + + return ( + + {isLoading ? ( + + ) : ( + +
+ Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} /> + {!!uberData?.icon && ( + + )} + + {!!uberData && canWriteMoreFeatures && ( + + + + )} +
+ + {!!uberData && shouldShowEnterCredentialsError && ( + + )} + {isUberConnected && ( + <> + + + + + + + {centralBillingAccountEmail && ( + + + Navigation.navigate( + ROUTES.WORKSPACE_RECEIPT_PARTNERS_CHANGE_BILLING_ACCOUNT.getRoute(policyID, CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER), + ) + : undefined + } + /> + + )} + {canWriteMoreFeatures && ( + + Navigation.navigate(buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_RECEIPT_PARTNERS_INVITE_EDIT.getRoute(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER))) + } + /> + )} + + )} + +
+ )} +
+ ); +} + +export default ConnectionsReceiptPartnersPage; diff --git a/src/pages/workspace/connections/ConnectionsRecruitingPage.tsx b/src/pages/workspace/connections/ConnectionsRecruitingPage.tsx new file mode 100644 index 000000000000..d7123e4c65cc --- /dev/null +++ b/src/pages/workspace/connections/ConnectionsRecruitingPage.tsx @@ -0,0 +1,44 @@ +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; +import {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; +import usePolicy from '@hooks/usePolicy'; + +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import {getRecruitingCards} from '@pages/workspace/recruiting/utils'; + +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; + +import ConnectionsMergePageBase from './ConnectionsMergePageBase'; + +type ConnectionsRecruitingPageProps = PlatformStackScreenProps; + +function ConnectionsRecruitingPage({ + route: { + params: {policyID}, + }, +}: ConnectionsRecruitingPageProps) { + const {translate, formatPhoneNumber} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Download']); + const policy = usePolicy(policyID); + const policyEmployeePersonalDetails = usePersonalDetailsByLogins([...Object.keys(policy?.employeeList ?? {})]); + const {isBetaEnabled} = usePermissions(); + + const cards = getRecruitingCards({policy, policyEmployeePersonalDetails, policyID, icons, translate, formatPhoneNumber}); + + return ( + + ); +} + +export default ConnectionsRecruitingPage; diff --git a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx index 495c2c0acf93..fe0f46f27c65 100644 --- a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx +++ b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx @@ -368,11 +368,14 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { } function WorkspaceConnectionsPageWrapper(props: WithPolicyConnectionsProps) { + const {isBetaEnabled} = usePermissions(); + return ( diff --git a/src/pages/workspace/connections/useAccountingConnectionListings.ts b/src/pages/workspace/connections/useAccountingConnectionListings.ts index a46e6ab98364..e1d75fb5ae11 100644 --- a/src/pages/workspace/connections/useAccountingConnectionListings.ts +++ b/src/pages/workspace/connections/useAccountingConnectionListings.ts @@ -141,6 +141,7 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL undefined, undefined, isIntuitEnterpriseSuite, + isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS), ); if (!integrationData) { return []; @@ -161,7 +162,7 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL ? {onPress: canWrite ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CLAIM_OFFER.getRoute(policyID, CONST.POLICY.CONNECTIONS.NAME.XERO)) : undefined} : undefined, onConnect: () => connect(name, isIntuitEnterpriseSuite), - onConfigure: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING.getRoute(policyID)), + onConfigure: () => Navigation.navigate(ROUTES.WORKSPACE_CONNECTIONS_ACCOUNTING.getRoute(policyID)), registerConnectButton: (button) => { const anchorRef = popoverAnchorRefs?.current?.[key]; if (!anchorRef) { diff --git a/src/pages/workspace/connections/useMergeConnectionListings.ts b/src/pages/workspace/connections/useMergeConnectionListings.ts index c506c9cf7a85..b6213d36f095 100644 --- a/src/pages/workspace/connections/useMergeConnectionListings.ts +++ b/src/pages/workspace/connections/useMergeConnectionListings.ts @@ -37,13 +37,13 @@ const CATEGORY_CONFIG = { featureName: CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED, upgradeAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.hr.alias, enableFeature: enablePolicyHR, - getConfigureRoute: ROUTES.WORKSPACE_HR.getRoute, + getConfigureRoute: ROUTES.WORKSPACE_CONNECTIONS_HR.getRoute, }, [CONST.POLICY.CONNECTIONS.CATEGORY.RECRUITING]: { featureName: CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED, upgradeAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.recruiting.alias, enableFeature: enablePolicyRecruiting, - getConfigureRoute: ROUTES.WORKSPACE_RECRUITING.getRoute, + getConfigureRoute: ROUTES.WORKSPACE_CONNECTIONS_RECRUITING.getRoute, }, } as const; diff --git a/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts index c86f4c141a0a..98c97d23cf53 100644 --- a/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts +++ b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts @@ -37,7 +37,10 @@ function useReceiptPartnerConnectionListings(policy: OnyxEntry): Connect return; } Navigation.navigate( - createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_RECEIPT_PARTNERS_INVITE.getRoute(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER), ROUTES.WORKSPACE_RECEIPT_PARTNERS.getRoute(policyID)), + createDynamicRoute( + DYNAMIC_ROUTES.WORKSPACE_RECEIPT_PARTNERS_INVITE.getRoute(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER), + ROUTES.WORKSPACE_CONNECTIONS_RECEIPT_PARTNERS.getRoute(policyID), + ), ); }, [prevIsUberConnected, isUberConnected, policyID, canWrite]); @@ -81,7 +84,7 @@ function useReceiptPartnerConnectionListings(policy: OnyxEntry): Connect : {onPress: canWrite ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CLAIM_OFFER.getRoute(policyID, CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER)) : undefined}, onConnect: connectUber, isLoading: !policy?.receiptPartners?.uber && !isOffline && !!policy?.isLoadingReceiptPartners, - onConfigure: () => Navigation.navigate(ROUTES.WORKSPACE_RECEIPT_PARTNERS.getRoute(policyID)), + onConfigure: () => Navigation.navigate(ROUTES.WORKSPACE_CONNECTIONS_RECEIPT_PARTNERS.getRoute(policyID)), }, ]; } diff --git a/src/pages/workspace/connections/useRedirectUnconnectedPanelToConnections.ts b/src/pages/workspace/connections/useRedirectUnconnectedPanelToConnections.ts index 82ecde77510d..7e6716d3a398 100644 --- a/src/pages/workspace/connections/useRedirectUnconnectedPanelToConnections.ts +++ b/src/pages/workspace/connections/useRedirectUnconnectedPanelToConnections.ts @@ -5,9 +5,9 @@ import ROUTES from '@src/ROUTES'; import {useEffect, useRef} from 'react'; /** - * Sends a visit to a connection's settings panel back to the Connections page when that connection isn't set up, so old - * links to the Accounting, HR, and Receipt partners pages keep working. Only the first loaded state is checked, so - * disconnecting from inside the panel keeps its own navigation. + * Sends a visit to a connection's settings panel back to the Connections page when that connection isn't set up, so a + * stale link to a panel still lands somewhere useful. Only the first loaded state is checked, so disconnecting from + * inside the panel keeps its own navigation. */ function useRedirectUnconnectedPanelToConnections(policyID: string | undefined, isDataReady: boolean, isConnected: boolean) { const hasCheckedRef = useRef(false); diff --git a/src/pages/workspace/connections/utils.ts b/src/pages/workspace/connections/utils.ts index 80f7287470c5..9921f3fe901e 100644 --- a/src/pages/workspace/connections/utils.ts +++ b/src/pages/workspace/connections/utils.ts @@ -4,6 +4,7 @@ import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; import type {ConnectionListing, ConnectionsTab} from './types'; @@ -42,4 +43,12 @@ function getListingsForTab(listings: ConnectionListing[], tab: ConnectionsTab): return listings.filter((listing) => listing.category === tab); } -export {MCP_CONNECTOR, getSyncStatusMessage, getListingsForTab}; +/** The page listing the workspace's accounting integrations, which is Connections while the unified Connections beta is on */ +function getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled: boolean, ...params: Parameters) { + if (isUnifiedConnectionsBetaEnabled) { + return ROUTES.WORKSPACE_CONNECTIONS.getRoute(...params); + } + return ROUTES.POLICY_ACCOUNTING.getRoute(...params); +} + +export {MCP_CONNECTOR, getSyncStatusMessage, getListingsForTab, getAccountingConnectionsRoute}; diff --git a/src/pages/workspace/connections/withUnifiedConnectionsBeta.tsx b/src/pages/workspace/connections/withUnifiedConnectionsBeta.tsx new file mode 100644 index 000000000000..9071e8bcdf3a --- /dev/null +++ b/src/pages/workspace/connections/withUnifiedConnectionsBeta.tsx @@ -0,0 +1,30 @@ +import usePermissions from '@hooks/usePermissions'; + +import CONST from '@src/CONST'; + +import type {ComponentProps, ComponentType} from 'react'; + +import React from 'react'; + +import WorkspaceConnectionsPage from './WorkspaceConnectionsPage'; + +type WorkspaceConnectionsPageProps = ComponentProps; + +/** + * Renders the Connections page in place of a page it replaces while the unified Connections beta is on. Old links and + * deep links into those pages' settings still resolve to their routes, so they need to land on Connections for beta users. + */ +export default function withUnifiedConnectionsBeta>(WrappedComponent: ComponentType): ComponentType { + function WithUnifiedConnectionsBeta(props: TProps) { + const {isBetaEnabled} = usePermissions(); + + if (isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- these are the screen props of a page Connections replaced, and it only reads the policy ID and the connection to start from the route + return ; + } + + return ; + } + + return WithUnifiedConnectionsBeta; +} diff --git a/src/pages/workspace/getWorkspaceMenuItems.ts b/src/pages/workspace/getWorkspaceMenuItems.ts index b001dac4875e..46a9adb1f482 100644 --- a/src/pages/workspace/getWorkspaceMenuItems.ts +++ b/src/pages/workspace/getWorkspaceMenuItems.ts @@ -4,17 +4,20 @@ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; -import {isMergeHRCompleteSetupNeeded, shouldShowHRConnectionError} from '@libs/merge/HRUtils'; +import {isAnyHRConnected, isMergeHRCompleteSetupNeeded, shouldShowHRConnectionError} from '@libs/merge/HRUtils'; +import {isAnyRecruitingConnected} from '@libs/merge/RecruitingUtils'; import {getObjectKeys} from '@libs/ObjectUtils'; import { arePolicyRulesEnabled, canMemberRead, canPolicyAccessFeature, + hasAccountingFeatureConnection, hasPolicyCategoriesError, hasPolicyRulesError, hasVendorFeature, isGroupPolicy, isMatchingVendorListLoaded, + isMCPEnabled, isPerDiemEnabled, isPolicyAdmin, isQBORefreshTokenExpiringSoonSelector, @@ -49,7 +52,9 @@ type WorkspaceMenuIconMap = Record< | 'Users' | 'Hashtag' | 'Document' + | 'Sync' | 'Connect' + | 'Receipt' | 'Briefcase' | 'Folder' | 'Tag' @@ -63,7 +68,9 @@ type WorkspaceMenuIconMap = Record< | 'Clock' | 'InvoiceGeneric' | 'Gear' - | 'Bolt', + | 'Bolt' + | 'Bot' + | 'UserPlus', IconAsset >; @@ -82,6 +89,9 @@ type WorkspaceMenuItem = WithSentryLabel & { /** Other names the Search router also matches this item by */ searchAliasKeys?: TranslationPaths[]; + + /** Other screens that render this item's page, so the menu treats them as this item */ + aliasScreenNames?: WorkspaceTopLevelScreens[]; }; /** Inputs used to build the Workspace menu while preserving its visibility and indicator rules. */ @@ -108,6 +118,10 @@ type GetWorkspaceMenuItemsParams = { shouldShowRBR?: boolean; /** Whether the vendor matching beta is enabled. */ isVendorMatchingBetaEnabled?: boolean; + /** Whether the Merge ATS beta gating the Recruiting feature is enabled. */ + isRecruitingBetaEnabled?: boolean; + /** Whether the unified Connections beta replaces the Accounting, HR, Recruiting, Receipt partners, and MCP items. */ + isUnifiedConnectionsBetaEnabled?: boolean; /** Formats the invoice account balance for its menu badge. */ convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString']; }; @@ -124,6 +138,8 @@ function getWorkspaceMenuItems({ shouldShowEnterCredentialsError = false, shouldShowRBR = false, isVendorMatchingBetaEnabled = false, + isRecruitingBetaEnabled = false, + isUnifiedConnectionsBetaEnabled = false, convertToDisplayString, }: GetWorkspaceMenuItemsParams): WorkspaceMenuItem[] { const canReadPolicyFeature = (policyFeature: PolicyFeature) => canMemberRead(policy, currentUserLogin ?? '', policyFeature); @@ -146,6 +162,14 @@ function getWorkspaceMenuItems({ const accountingConnectionNames = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES; const hasSyncError = shouldShowSyncError(policy, isConnectionInProgress, accountingConnectionNames); const hasHRError = shouldShowHRConnectionError(policy, isConnectionInProgress, isPolicyAdmin(policy)); + const getHRBrickRoadIndicator = () => { + if (hasHRError) { + return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; + } + if (isMergeHRCompleteSetupNeeded(policy)) { + return CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; + } + }; const getConnectionsBrickRoadIndicator = () => { if (hasSyncError || shouldShowQBOReimbursableExportDestinationAccountError(policy) || hasHRError || shouldShowEnterCredentialsError) { return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; @@ -169,11 +193,19 @@ function getWorkspaceMenuItems({ [CONST.POLICY.MORE_FEATURES.ARE_TAGS_ENABLED]: policy?.areTagsEnabled, [CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED]: policy?.tax?.trackingEnabled, [CONST.POLICY.MORE_FEATURES.ARE_COMPANY_CARDS_ENABLED]: policy?.areCompanyCardsEnabled, + [CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED]: !!policy?.areConnectionsEnabled || hasAccountingFeatureConnection(policy), + [CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED]: (policy?.isHREnabled === true || isAnyHRConnected(policy)) && canPolicyAccessFeature(policy, CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED), + [CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED]: + isRecruitingBetaEnabled && + (policy?.isRecruitingEnabled === true || isAnyRecruitingConnected(policy)) && + canPolicyAccessFeature(policy, CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED), [CONST.POLICY.MORE_FEATURES.ARE_EXPENSIFY_CARDS_ENABLED]: policy?.areExpensifyCardsEnabled, [CONST.POLICY.MORE_FEATURES.ARE_REPORT_FIELDS_ENABLED]: policy?.areReportFieldsEnabled, [CONST.POLICY.MORE_FEATURES.ARE_RULES_ENABLED]: arePolicyRulesEnabled(policy, policyCategories), [CONST.POLICY.MORE_FEATURES.ARE_INVOICES_ENABLED]: policy?.areInvoicesEnabled, [CONST.POLICY.MORE_FEATURES.ARE_PER_DIEM_RATES_ENABLED]: isPerDiemEnabled(policy) && canPolicyAccessFeature(policy, CONST.POLICY.MORE_FEATURES.ARE_PER_DIEM_RATES_ENABLED), + [CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED]: policy?.receiptPartners?.enabled ?? false, + [CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED]: isMCPEnabled(policy), [CONST.POLICY.MORE_FEATURES.IS_TRAVEL_ENABLED]: policy?.isTravelEnabled, [CONST.POLICY.MORE_FEATURES.IS_TIME_TRACKING_ENABLED]: isTimeTrackingEnabled(policy), }; @@ -216,10 +248,11 @@ function getWorkspaceMenuItems({ }); } - if (canReadMoreFeatures) { + if (isUnifiedConnectionsBetaEnabled && canReadMoreFeatures) { items.push({ translationKey: 'workspace.common.connections', searchAliasKeys: ['workspace.common.accounting', 'workspace.common.hr', 'workspace.common.recruiting', 'workspace.common.receiptPartners', 'workspace.common.mcp'], + aliasScreenNames: [SCREENS.WORKSPACE.ACCOUNTING.ROOT, SCREENS.WORKSPACE.HR, SCREENS.WORKSPACE.RECRUITING, SCREENS.WORKSPACE.RECEIPT_PARTNERS, SCREENS.WORKSPACE.MCP], icon: icons.Connect, getRoute: () => ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID), brickRoadIndicator: getConnectionsBrickRoadIndicator(), @@ -228,6 +261,70 @@ function getWorkspaceMenuItems({ }); } + if (!isUnifiedConnectionsBetaEnabled && policyFeatureStates[CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED] && canReadPolicyFeature(CONST.POLICY.POLICY_FEATURE.ACCOUNTING)) { + let accountingBrickRoadIndicator; + if (hasSyncError || shouldShowQBOReimbursableExportDestinationAccountError(policy)) { + accountingBrickRoadIndicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; + } else if (isQBORefreshTokenExpiringSoonSelector(policy)) { + accountingBrickRoadIndicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO; + } + items.push({ + translationKey: 'workspace.common.accounting', + icon: icons.Sync, + getRoute: () => ROUTES.POLICY_ACCOUNTING.getRoute(policyID), + brickRoadIndicator: accountingBrickRoadIndicator, + screenName: SCREENS.WORKSPACE.ACCOUNTING.ROOT, + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.ACCOUNTING, + highlighted: highlightedPolicyFeature === CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED, + }); + } + + if (!isUnifiedConnectionsBetaEnabled && policyFeatureStates[CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED] && canReadMoreFeatures) { + items.push({ + translationKey: 'workspace.common.hr', + icon: icons.Users, + getRoute: () => ROUTES.WORKSPACE_HR.getRoute(policyID), + brickRoadIndicator: getHRBrickRoadIndicator(), + screenName: SCREENS.WORKSPACE.HR, + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.HR, + highlighted: highlightedPolicyFeature === CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED, + }); + } + + if (!isUnifiedConnectionsBetaEnabled && policyFeatureStates[CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED] && canReadMoreFeatures) { + items.push({ + translationKey: 'workspace.common.recruiting', + icon: icons.UserPlus, + getRoute: () => ROUTES.WORKSPACE_RECRUITING.getRoute(policyID), + screenName: SCREENS.WORKSPACE.RECRUITING, + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.RECRUITING, + highlighted: highlightedPolicyFeature === CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED, + }); + } + + if (!isUnifiedConnectionsBetaEnabled && policyFeatureStates[CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED] && canReadMoreFeatures) { + items.push({ + translationKey: 'workspace.common.receiptPartners', + icon: icons.Receipt, + getRoute: () => ROUTES.WORKSPACE_RECEIPT_PARTNERS.getRoute(policyID), + brickRoadIndicator: shouldShowEnterCredentialsError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + screenName: SCREENS.WORKSPACE.RECEIPT_PARTNERS, + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.RECEIPT_PARTNERS, + highlighted: highlightedPolicyFeature === CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED, + }); + } + + if (!isUnifiedConnectionsBetaEnabled && policyFeatureStates[CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED] && canReadMoreFeatures) { + items.push({ + translationKey: 'workspace.common.mcp', + icon: icons.Bot, + getRoute: () => ROUTES.WORKSPACE_MCP.getRoute(policyID), + screenName: SCREENS.WORKSPACE.MCP, + sentryLabel: CONST.SENTRY_LABEL.WORKSPACE.INITIAL.MCP, + highlighted: highlightedPolicyFeature === CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED, + }); + } + if (policyFeatureStates[CONST.POLICY.MORE_FEATURES.ARE_CATEGORIES_ENABLED] && canReadPolicyFeature(CONST.POLICY.POLICY_FEATURE.CATEGORIES)) { items.push({ translationKey: 'workspace.common.categories', diff --git a/src/pages/workspace/hr/WorkspaceHRPage.tsx b/src/pages/workspace/hr/WorkspaceHRPage.tsx index a158c9516ef7..cd8cb4bc30bc 100644 --- a/src/pages/workspace/hr/WorkspaceHRPage.tsx +++ b/src/pages/workspace/hr/WorkspaceHRPage.tsx @@ -5,8 +5,9 @@ import {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; import usePolicy from '@hooks/usePolicy'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; +import withUnifiedConnectionsBeta from '@pages/workspace/connections/withUnifiedConnectionsBeta'; import MergeConnectionsPageBase from '@pages/workspace/merge/MergeConnectionsPageBase'; import CONST from '@src/CONST'; @@ -17,7 +18,7 @@ import React from 'react'; import {getHRCards} from './utils'; -type WorkspaceHRPageProps = PlatformStackScreenProps; +type WorkspaceHRPageProps = PlatformStackScreenProps; function WorkspaceHRPage({ route: { @@ -51,4 +52,4 @@ function WorkspaceHRPage({ ); } -export default WorkspaceHRPage; +export default withUnifiedConnectionsBeta(WorkspaceHRPage); diff --git a/src/pages/workspace/mcp/WorkspaceMCPPage.tsx b/src/pages/workspace/mcp/WorkspaceMCPPage.tsx new file mode 100644 index 000000000000..00b74532d621 --- /dev/null +++ b/src/pages/workspace/mcp/WorkspaceMCPPage.tsx @@ -0,0 +1,129 @@ +import Button from '@components/Button'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Icon from '@components/Icon'; +import MenuItem from '@components/MenuItem'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Section from '@components/Section'; +import Text from '@components/Text'; +import TextLink from '@components/TextLink'; + +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import usePolicy from '@hooks/usePolicy'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; + +import Navigation from '@navigation/Navigation'; +import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; +import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import withUnifiedConnectionsBeta from '@pages/workspace/connections/withUnifiedConnectionsBeta'; + +import variables from '@styles/variables'; + +import {openExternalLink} from '@userActions/Link'; + +import CONST from '@src/CONST'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; +import {View} from 'react-native'; + +type WorkspaceMCPPageProps = PlatformStackScreenProps; + +function WorkspaceMCPPage({route}: WorkspaceMCPPageProps) { + const policyID = route.params.policyID; + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const theme = useTheme(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const icons = useMemoizedLazyExpensifyIcons(['ChatGPTSquare', 'ClaudeSquare', 'CursorSquare', 'QuestionMark']); + const policy = usePolicy(policyID); + + useWorkspaceDocumentTitle(policy?.name, 'workspace.common.mcp'); + + const connectors = [ + {icon: icons.ChatGPTSquare, title: translate('workspace.mcp.chatgpt.title'), description: translate('workspace.mcp.chatgpt.subtitle'), url: CONST.CHATGPT_CONNECT_URL}, + {icon: icons.ClaudeSquare, title: translate('workspace.mcp.claude.title'), description: translate('workspace.mcp.claude.subtitle'), url: CONST.CLAUDE_CONNECT_URL}, + {icon: icons.CursorSquare, title: translate('workspace.mcp.cursor.title'), description: translate('workspace.mcp.cursor.subtitle'), url: CONST.CURSOR_MCP_HELP_URL}, + ]; + + return ( + + + Navigation.goBack()} + /> + + +
+ {connectors.map((connector) => ( + openExternalLink(connector.url)} + style={styles.justifyContentCenter} + size={CONST.BUTTON_SIZE.SMALL} + > + {translate('workspace.mcp.connect')} + + } + /> + ))} + + + + {translate('workspace.mcp.helpPrompt')} openExternalLink(CONST.MCP_HELP_URL)}>{translate('workspace.mcp.helpLink')} + + +
+
+
+
+
+ ); +} + +export default withUnifiedConnectionsBeta(WorkspaceMCPPage); diff --git a/src/pages/workspace/merge/MergeConnectionsPageBase.tsx b/src/pages/workspace/merge/MergeConnectionsPageBase.tsx index 2736f27d1cdb..5bd34ee2eac4 100644 --- a/src/pages/workspace/merge/MergeConnectionsPageBase.tsx +++ b/src/pages/workspace/merge/MergeConnectionsPageBase.tsx @@ -1,25 +1,35 @@ +import CollapsibleSection from '@components/CollapsibleSection'; import ConnectToMergeFlow from '@components/ConnectToMergeFlow'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import CompactSearchBar from '@components/SearchBar/CompactSearchBar'; +import Section from '@components/Section'; +import useConfirmModal from '@hooks/useConfirmModal'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useSearchResults from '@hooks/useSearchResults'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; import {openPolicyHRPage, openPolicyRecruitingPage} from '@libs/actions/PolicyConnections'; import {isMergeConnectionName} from '@libs/merge/MergeUtils'; import Navigation from '@libs/Navigation/Navigation'; +import tokenizedSearch from '@libs/tokenizedSearch'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import useRedirectUnconnectedPanelToConnections from '@pages/workspace/connections/useRedirectUnconnectedPanelToConnections'; + +import variables from '@styles/variables'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import React, {useEffect, useState} from 'react'; +import {View} from 'react-native'; import type {MergeProviderCardCategory, MergeProviderCardDescriptor} from './types'; @@ -48,8 +58,11 @@ type MergeConnectionsPageBaseContentProps = { /** Which page this is. Picks the category-specific copy, feature flag, and data-fetching command. */ category: MergeProviderCardCategory; - /** Provider cards built by the category's `utils`. Only the connected one is shown. */ + /** Provider cards to list, already built by the category's `utils`. */ cards: MergeProviderCardDescriptor[]; + + /** Category-specific content rendered under the provider list while nothing is connected, e.g. what to do when the provider isn't listed. */ + footer?: React.ReactNode; }; type MergeConnectionsPageBaseProps = MergeConnectionsPageBaseContentProps & { @@ -57,19 +70,36 @@ type MergeConnectionsPageBaseProps = MergeConnectionsPageBaseContentProps & { shouldBeBlocked?: boolean; }; -function MergeConnectionsPageBaseContent({policyID, category, cards}: MergeConnectionsPageBaseContentProps) { - const {translate} = useLocalize(); +function MergeConnectionsPageBaseContent({policyID, category, cards, footer}: MergeConnectionsPageBaseContentProps) { + const {translate, localeCompare} = useLocalize(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const policy = usePolicy(policyID); const [activeSetupFlow, setActiveSetupFlow] = useState<{setupLink: string; key: number} | undefined>(); + const {showConfirmModal} = useConfirmModal(); const {testID} = PAGE_CONFIG[category]; + const connectedCards: MergeProviderCardDescriptor[] = []; + const disconnectedCards: MergeProviderCardDescriptor[] = []; + for (const card of cards) { + (card.isConnected ? connectedCards : disconnectedCards).push(card); + } // At most one provider of a category can be connected to a workspace at a time. - const connectedCard = cards.find((card) => card.isConnected); + const connectedConnectionName = connectedCards.at(0)?.connectionName; + const byName = (a: MergeProviderCardDescriptor, b: MergeProviderCardDescriptor) => localeCompare(a.displayName, b.displayName); + connectedCards.sort(byName); + disconnectedCards.sort(byName); + + const filterCard = (card: MergeProviderCardDescriptor, searchInput: string) => { + return tokenizedSearch([card], searchInput, (c) => [c.displayName]).length > 0; + }; + const [inputValue, setInputValue, filteredDisconnectedCards] = useSearchResults(disconnectedCards, filterCard); const {canWrite: canWriteMoreFeatures, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); - const handleReconnect = (card: MergeProviderCardDescriptor) => { + const handleConnect = (card: MergeProviderCardDescriptor) => { if (!card.setupLink) { return; } @@ -79,25 +109,59 @@ function MergeConnectionsPageBaseContent({policyID, category, cards}: MergeConne return; } - // A random key forces a remount on every press, even for the same provider + if (!card.isConnected && connectedCards.length > 0) { + showConfirmModal({ + title: translate(`workspace.${category}.alreadyConnectedTitle`), + prompt: translate(`workspace.${category}.alreadyConnectedPrompt`), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + innerContainerStyle: shouldUseNarrowLayout ? undefined : StyleUtils.getWidthStyle(variables.wideConfirmModalWidth), + }); + return; + } + + // eslint-disable-next-line react-hooks/purity -- random key forces remount on every press, even for the same provider setActiveSetupFlow({setupLink: card.setupLink, key: Math.random()}); }; + const maybeSearchBar = disconnectedCards.length >= CONST.STANDARD_LIST_ITEM_LIMIT && ( + + ); + const disconnectedProviderCards = filteredDisconnectedCards.map((card) => ( + handleConnect(card)} + canWriteMoreFeatures={canWriteMoreFeatures} + showReadOnlyModal={showReadOnlyModal} + /> + )); + return ( - {!!connectedCard && ( + {!!connectedConnectionName && ( )} - {!!connectedCard && isMergeConnectionName(connectedCard.connectionName) && ( + {!!connectedConnectionName && isMergeConnectionName(connectedConnectionName) && ( )} {!!activeSetupFlow && ( @@ -108,29 +172,65 @@ function MergeConnectionsPageBaseContent({policyID, category, cards}: MergeConne onDone={() => setActiveSetupFlow(undefined)} /> )} - {!!connectedCard && ( - handleReconnect(connectedCard)} - onDisconnect={() => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} - onBackButtonPress={() => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} - canWriteMoreFeatures={canWriteMoreFeatures} - showReadOnlyModal={showReadOnlyModal} - /> - )} + Navigation.goBack()} + /> + + +
+ {connectedCards.map((card) => ( + handleConnect(card)} + canWriteMoreFeatures={canWriteMoreFeatures} + showReadOnlyModal={showReadOnlyModal} + /> + ))} + {connectedCards.length === 0 && ( + <> + {maybeSearchBar} + {disconnectedProviderCards} + {footer} + + )} + + {connectedCards.length > 0 && disconnectedCards.length > 0 && !connectedCards.some((c) => c.isInitialSyncInProgress) && ( + + {maybeSearchBar} + {disconnectedProviderCards} + + )} +
+
+
); } -function MergeConnectionsPageBase({policyID, category, cards, shouldBeBlocked}: MergeConnectionsPageBaseProps) { +function MergeConnectionsPageBase({policyID, category, cards, footer, shouldBeBlocked}: MergeConnectionsPageBaseProps) { const {featureName, openPage} = PAGE_CONFIG[category]; - const [hasConnectionsDataBeenFetched] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED}${policyID}`); - useRedirectUnconnectedPanelToConnections( - policyID, - !!hasConnectionsDataBeenFetched, - cards.some((card) => card.isConnected), - ); useWorkspaceDocumentTitle(undefined, `workspace.common.${category}`); @@ -146,12 +246,13 @@ function MergeConnectionsPageBase({policyID, category, cards, shouldBeBlocked}: policyID={policyID} featureName={featureName} policyFeature={CONST.POLICY.POLICY_FEATURE.MORE_FEATURES} - shouldBeBlocked={(shouldBeBlocked ?? false) || !cards.some((card) => card.isConnected)} + shouldBeBlocked={shouldBeBlocked} >
); diff --git a/src/pages/workspace/merge/MergeProviderCard.tsx b/src/pages/workspace/merge/MergeProviderCard.tsx index 2e159b50d135..8e6e93b918f3 100644 --- a/src/pages/workspace/merge/MergeProviderCard.tsx +++ b/src/pages/workspace/merge/MergeProviderCard.tsx @@ -1,14 +1,13 @@ import ActivityIndicator from '@components/ActivityIndicator'; -import UserAvatar from '@components/Avatar/UserAvatar'; -import FormHelpMessage from '@components/FormHelpMessage'; -import Header from '@components/Header'; +import Button from '@components/Button'; +import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import RenderHTML from '@components/RenderHTML'; -import ScrollView from '@components/ScrollView'; import TextLink from '@components/TextLink'; +import ThreeDotsMenu from '@components/ThreeDotsMenu'; import type ThreeDotsMenuProps from '@components/ThreeDotsMenu/types'; import useConfirmModal from '@hooks/useConfirmModal'; @@ -16,7 +15,6 @@ import useEnvironment from '@hooks/useEnvironment'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {removePolicyConnection, syncConnection} from '@libs/actions/connections'; @@ -44,12 +42,6 @@ type MergeProviderCardProps = { /** Callback invoked when the user taps the "Connect" or "Reconnect" button. */ handleConnect: () => void; - /** Called once the user confirms disconnecting, right before the connection is removed */ - onDisconnect?: () => void; - - /** Called when the header's back button is pressed. */ - onBackButtonPress: () => void; - /** Whether the current user can edit this Merge connection. */ canWriteMoreFeatures: boolean; @@ -57,10 +49,9 @@ type MergeProviderCardProps = { showReadOnlyModal: () => void; }; -function MergeProviderCard({card, policy, handleConnect, onDisconnect, onBackButtonPress, canWriteMoreFeatures, showReadOnlyModal}: MergeProviderCardProps) { +function MergeProviderCard({card, policy, handleConnect, canWriteMoreFeatures, showReadOnlyModal}: MergeProviderCardProps) { const {translate, datetimeToRelative} = useLocalize(); const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); const {environmentURL} = useEnvironment(); const {isOffline} = useNetwork(); const icons = useMemoizedLazyExpensifyIcons(['Sync', 'Trashcan', 'Building', 'CheckCircle']); @@ -153,7 +144,6 @@ function MergeProviderCard({card, policy, handleConnect, onDisconnect, onBackBut if (result?.action !== ModalActions.CONFIRM || !policy) { return; } - onDisconnect?.(); removePolicyConnection(policy, card.connectionName); }); }, @@ -161,71 +151,88 @@ function MergeProviderCard({card, policy, handleConnect, onDisconnect, onBackBut }, ]; + let rightInset: React.ReactNode; + if (!card.isConnected) { + rightInset = ( + + {translate('workspace.merge.connect')} + + ); + } else if (card.isSyncInProgress) { + rightInset = ; + } else { + rightInset = ( + + ); + } + + const rightComponent = {rightInset}; + // While the setup is incomplete only the rows that failed to save are shown, so the admin is steered to the setup flow first. const visibleConfigRows = card.isConnected && !card.isInitialSyncInProgress ? (card.configRows ?? []).filter((row) => !card.completeSetupRoute || !!row.errors) : []; return ( <> -
- - - - - {card.isSyncInProgress && } - {card.isConnected && !card.isSyncInProgress && } - -
- - {!!card.completeSetupRoute && ( - + - - )} - {!!lastSyncErrorMessage && ( - - )} - {visibleConfigRows.map((row) => { - const RowMenuItem = row.shouldRenderAsMenuItem ? MenuItem : MenuItemWithTopDescription; - - return ( - clearMergeConnectionErrorField(policy?.id, card.connectionName, row.field)} - > - Navigation.navigate(row.route)} - interactive={canWriteMoreFeatures} - /> - - ); - })} - + ) : undefined + } + errorText={lastSyncErrorMessage} + errorTextStyle={styles.mt5} + shouldShowRedDotIndicator + shouldShowRightComponent={!!rightInset} + brickRoadIndicator={card.completeSetupRoute ? CONST.BRICK_ROAD_INDICATOR_STATUS.INFO : undefined} + rightComponent={rightComponent} + fallbackIcon={fallbackIcon} + /> + {visibleConfigRows.length > 0 && ( + + {visibleConfigRows.map((row) => { + const RowMenuItem = row.shouldRenderAsMenuItem ? MenuItem : MenuItemWithTopDescription; + + return ( + clearMergeConnectionErrorField(policy?.id, card.connectionName, row.field)} + > + Navigation.navigate(row.route)} + interactive={canWriteMoreFeatures} + /> + + ); + })} + + )} ); } diff --git a/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx b/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx index d78d2579e52a..88a0b1529adf 100644 --- a/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx +++ b/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx @@ -1,12 +1,15 @@ -import FormHelpMessage from '@components/FormHelpMessage'; +import Button from '@components/Button'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; +import MenuItemSectionRoot from '@components/MenuItem/presets/MenuItemSectionRoot'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import Section from '@components/Section'; +import ThreeDotsMenu from '@components/ThreeDotsMenu'; import useConfirmModal from '@hooks/useConfirmModal'; import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; @@ -15,16 +18,19 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import usePolicy from '@hooks/usePolicy'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; +import usePrevious from '@hooks/usePrevious'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScreenBoundDynamicRoute from '@hooks/useScreenBoundDynamicRoute'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; import Navigation from '@navigation/Navigation'; import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; -import type {SettingsNavigatorParamList} from '@navigation/types'; +import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import useRedirectUnconnectedPanelToConnections from '@pages/workspace/connections/useRedirectUnconnectedPanelToConnections'; +import type {MenuItemData} from '@pages/workspace/accounting/types'; +import withUnifiedConnectionsBeta from '@pages/workspace/connections/withUnifiedConnectionsBeta'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; import {openExternalLink} from '@userActions/Link'; @@ -33,14 +39,16 @@ import {openPolicyReceiptPartnersPage, removePolicyReceiptPartnersConnection, to import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import type {AnchorPosition} from '@src/styles'; -import type {ValueOf} from 'type-fest'; +import type {ComponentRef} from 'react'; -import React, {useCallback, useEffect} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import {View} from 'react-native'; import getSynchronizationErrorMessage from './utils'; -type WorkspaceReceiptPartnersPageProps = PlatformStackScreenProps; +type WorkspaceReceiptPartnersPageProps = PlatformStackScreenProps; function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps) { const policyID = route.params.policyID; @@ -48,17 +56,22 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps const {translate} = useLocalize(); const buildDynamicRoute = useScreenBoundDynamicRoute(); const styles = useThemeStyles(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const {showConfirmModal} = useConfirmModal(); + const receiptPartnerNames = CONST.POLICY.RECEIPT_PARTNERS.NAME; + const receiptPartnerIntegrations = Object.values(receiptPartnerNames); + const threeDotsMenuContainerRef = useRef>(null); const policy = usePolicy(policyID); useWorkspaceDocumentTitle(policy?.name, 'workspace.common.receiptPartners'); const {getReceiptPartnersIntegrationData, shouldShowEnterCredentialsError, isUberConnected} = useGetReceiptPartnersIntegrationData(policyID); const isLoading = policy?.isLoading; - useRedirectUnconnectedPanelToConnections(policyID, !!policy && !isLoading, isUberConnected || shouldShowEnterCredentialsError); const integrations = policy?.receiptPartners; const isAutoRemove = !!integrations?.uber?.autoRemove; const isAutoInvite = !!integrations?.uber?.autoInvite; const centralBillingAccountEmail = !!integrations?.uber?.centralBillingAccountEmail; - const {canWrite: canWriteMoreFeatures, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); + // Track focus and connection change to route to the invite flow once after successful connection + const prevIsUberConnected = usePrevious(isUberConnected); + const {canWrite: canWriteMoreFeatures, showReadOnlyModal, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); const startIntegrationFlow = useCallback( ({name}: {name: string}) => { @@ -86,6 +99,28 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // When Uber connection status flips from false -> true, navigate to the invite flow once + useEffect(() => { + if (!isUberConnected || prevIsUberConnected || !canWriteMoreFeatures) { + return; + } + Navigation.navigate(buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_RECEIPT_PARTNERS_INVITE.getRoute(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER))); + }, [prevIsUberConnected, isUberConnected, policyID, canWriteMoreFeatures, buildDynamicRoute]); + + const calculateAndSetThreeDotsMenuPosition = useCallback(() => { + if (shouldUseNarrowLayout) { + return Promise.resolve({horizontal: 0, vertical: 0}); + } + return new Promise((resolve) => { + threeDotsMenuContainerRef.current?.measureInWindow((x, y, width, height) => { + resolve({ + horizontal: x + width, + vertical: y + height, + }); + }); + }); + }, [shouldUseNarrowLayout]); + const toggleWorkspaceUberAutoInvite = useCallback(() => { togglePolicyUberAutoInvite(policyID, !isAutoInvite); }, [isAutoInvite, policyID]); @@ -95,7 +130,7 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps }, [isAutoRemove, policyID]); const disconnectPartner = useCallback( - (partner: ValueOf) => { + (partner: (typeof receiptPartnerNames)[keyof typeof receiptPartnerNames]) => { if (!policyID) { return; } @@ -140,8 +175,6 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps if (action !== ModalActions.CONFIRM) { return; } - // These settings have nothing to show once Uber is disconnected - Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)); disconnectPartner(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER); }); }, @@ -152,10 +185,110 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps return []; } }, - [icons.Key, icons.NewWindow, icons.Trashcan, shouldShowEnterCredentialsError, translate, isOffline, startIntegrationFlow, showConfirmModal, disconnectPartner, policyID], + [icons.Key, icons.NewWindow, icons.Trashcan, shouldShowEnterCredentialsError, translate, isOffline, startIntegrationFlow, showConfirmModal, disconnectPartner], ); - const uberData = policyID ? getReceiptPartnersIntegrationData(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER) : undefined; + const connectionsMenuItems: MenuItemData[] = useMemo(() => { + if (policyID) { + return receiptPartnerIntegrations + .map((integration) => { + const integrationData = getReceiptPartnersIntegrationData(integration); + if (!integrationData) { + return undefined; + } + const overflowMenu = canWriteMoreFeatures ? getOverflowMenu(integration) : []; + + const iconProps = integrationData?.icon + ? { + icon: integrationData.icon, + iconType: CONST.ICON_TYPE_AVATAR, + } + : {}; + + const isUber = integration === CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER; + let rightComponent: React.ReactNode; + if (canWriteMoreFeatures && (isUberConnected || shouldShowEnterCredentialsError)) { + rightComponent = ( + + + + ); + } else { + rightComponent = ( + + ); + } + + return { + ...iconProps, + ...integrationData, + interactive: false, + errorText: shouldShowEnterCredentialsError ? getSynchronizationErrorMessage(integrationData.title, translate, styles) : undefined, + wrapperStyle: [styles.sectionMenuItemTopDescription], + shouldShowRightComponent: !!rightComponent, + title: integrationData?.title, + badgeText: isUber ? translate('workspace.accounting.claimOffer.badgeText') : undefined, + onBadgePress: + isUber && canWriteMoreFeatures + ? () => { + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CLAIM_OFFER.getRoute(policyID, CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER)); + } + : undefined, + badgeStyle: styles.mr3, + isBadgeSuccess: isUber, + shouldShowBadgeInSeparateRow: shouldUseNarrowLayout, + numberOfLinesDescription: 5, + titleContainerStyle: [styles.pr2], + description: integrationData?.description, + brickRoadIndicator: !!integrationData?.errorFields || shouldShowEnterCredentialsError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + rightComponent, + }; + }) + .filter(Boolean) as MenuItemData[]; + } + + return []; + }, [ + policyID, + receiptPartnerIntegrations, + getReceiptPartnersIntegrationData, + getOverflowMenu, + canWriteMoreFeatures, + shouldShowEnterCredentialsError, + translate, + styles, + shouldUseNarrowLayout, + isUberConnected, + calculateAndSetThreeDotsMenuPosition, + policy?.receiptPartners?.uber, + policy?.isLoadingReceiptPartners, + isOffline, + startIntegrationFlow, + showReadOnlyModal, + ]); return ( {isLoading ? ( Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))} + title={translate('workspace.common.receiptPartners')} + shouldShowBackButton={shouldUseNarrowLayout} + shouldUseHeadlineHeader + shouldDisplayHelpButton + onBackButtonPress={Navigation.goBack} /> - {!!uberData && shouldShowEnterCredentialsError && ( - - )} - {isUberConnected && ( - <> - - - - - - - {centralBillingAccountEmail && ( - - - Navigation.navigate( - ROUTES.WORKSPACE_RECEIPT_PARTNERS_CHANGE_BILLING_ACCOUNT.getRoute(policyID, CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER), - ) - : undefined - } + +
+ {connectionsMenuItems.map((menuItem) => ( + + + ))} + {isUberConnected && ( + <> + + + + + + + + + + + {centralBillingAccountEmail && ( + + + + Navigation.navigate( + ROUTES.WORKSPACE_RECEIPT_PARTNERS_CHANGE_BILLING_ACCOUNT.getRoute( + policyID, + CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER, + ), + ) + : undefined + } + > + + {canWriteMoreFeatures && } + + + + + )} + {canWriteMoreFeatures && ( + + + Navigation.navigate( + buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_RECEIPT_PARTNERS_INVITE_EDIT.getRoute(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER)), + ) + } + > + + + + + + {translate('workspace.receiptPartners.uber.manageInvites')} + + + + + + + + )} + )} - {canWriteMoreFeatures && ( - - Navigation.navigate(buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_RECEIPT_PARTNERS_INVITE_EDIT.getRoute(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER))) - } - /> - )} - - )} +
+
)} @@ -262,4 +430,4 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps ); } -export default WorkspaceReceiptPartnersPage; +export default withUnifiedConnectionsBeta(WorkspaceReceiptPartnersPage); diff --git a/src/pages/workspace/recruiting/WorkspaceRecruitingPage.tsx b/src/pages/workspace/recruiting/WorkspaceRecruitingPage.tsx index 7383cec20256..33a4f1aeb2ba 100644 --- a/src/pages/workspace/recruiting/WorkspaceRecruitingPage.tsx +++ b/src/pages/workspace/recruiting/WorkspaceRecruitingPage.tsx @@ -1,22 +1,28 @@ +import RenderHTML from '@components/RenderHTML'; + import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useOpenConciergeAnywhere from '@hooks/useOpenConciergeAnywhere'; import usePermissions from '@hooks/usePermissions'; import {usePersonalDetailsByLogins} from '@hooks/usePersonalDetailByLogin'; import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; -import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; +import withUnifiedConnectionsBeta from '@pages/workspace/connections/withUnifiedConnectionsBeta'; import MergeConnectionsPageBase from '@pages/workspace/merge/MergeConnectionsPageBase'; import CONST from '@src/CONST'; import type SCREENS from '@src/SCREENS'; import React from 'react'; +import {View} from 'react-native'; import {getRecruitingCards} from './utils'; -type WorkspaceRecruitingPageProps = PlatformStackScreenProps; +type WorkspaceRecruitingPageProps = PlatformStackScreenProps; function WorkspaceRecruitingPage({ route: { @@ -24,9 +30,11 @@ function WorkspaceRecruitingPage({ }, }: WorkspaceRecruitingPageProps) { const {translate, formatPhoneNumber} = useLocalize(); + const styles = useThemeStyles(); const icons = useMemoizedLazyExpensifyIcons(['Download']); const policy = usePolicy(policyID); const policyEmployeePersonalDetails = usePersonalDetailsByLogins([...Object.keys(policy?.employeeList ?? {})]); + const {openConciergeAnywhere} = useOpenConciergeAnywhere(); const {isBetaEnabled} = usePermissions(); const cards = getRecruitingCards({policy, policyEmployeePersonalDetails, policyID, icons, translate, formatPhoneNumber}); @@ -37,8 +45,16 @@ function WorkspaceRecruitingPage({ category={CONST.POLICY.CONNECTIONS.CATEGORY.RECRUITING} cards={cards} shouldBeBlocked={!isBetaEnabled(CONST.BETAS.MERGE_ATS)} + footer={ + + openConciergeAnywhere()} + /> + + } /> ); } -export default WorkspaceRecruitingPage; +export default withUnifiedConnectionsBeta(WorkspaceRecruitingPage); diff --git a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx index e0c1f532f356..43981648b498 100644 --- a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx +++ b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx @@ -125,8 +125,9 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { const isUpgraded = !!policy?.type && upgradingFromSubmit !== undefined && (isControlPolicy(policy) || !!(upgradingFromSubmit && isPaidGroupPolicy(policy))); const {translate} = useLocalize(); - const {isBetaEnabledOrUnknown} = usePermissions(); + const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); const isVendorMatchingBetaEnabled = isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING); + const isUnifiedConnectionsBetaEnabled = isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS); const {accountID, email = ''} = useCurrentUserPersonalDetails(); const getReviewWorkspaceSettingsTaskCompletion = useReviewWorkspaceSettingsTaskCompletion(); const [priorFirstDayFreeTrial] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL); @@ -190,7 +191,8 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { case CONST.UPGRADE_FEATURE_INTRO_MAPPING.travelSubmit.id: return Navigation.goBack(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)); case CONST.UPGRADE_FEATURE_INTRO_MAPPING.hr.id: - return Navigation.goBack(route.params.backTo ?? ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)); + // Connections also upgrades for HR, and passes itself as the page to return to + return Navigation.goBack((isUnifiedConnectionsBetaEnabled ? route.params.backTo : undefined) ?? ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)); default: return route.params.backTo ? Navigation.goBack(route.params.backTo) : Navigation.goBack(); } @@ -298,10 +300,10 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { enablePerDiem(policyID, true, perDiemCustomUnit?.customUnitID, false); break; case CONST.UPGRADE_FEATURE_INTRO_MAPPING.hr.id: - enablePolicyHR(policyID, true, false); + enablePolicyHR(policyID, true, !isUnifiedConnectionsBetaEnabled); break; case CONST.UPGRADE_FEATURE_INTRO_MAPPING.recruiting.id: - enablePolicyRecruiting(policyID, true, false); + enablePolicyRecruiting(policyID, true, !isUnifiedConnectionsBetaEnabled); break; case CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvals.id: case CONST.UPGRADE_FEATURE_INTRO_MAPPING.approvalSubmit.id: diff --git a/tests/ui/WorkspaceMoreFeaturesPageTest.tsx b/tests/ui/WorkspaceMoreFeaturesPageTest.tsx index 711163e691e5..7e509b3a9624 100644 --- a/tests/ui/WorkspaceMoreFeaturesPageTest.tsx +++ b/tests/ui/WorkspaceMoreFeaturesPageTest.tsx @@ -6,6 +6,7 @@ import {ModalProvider} from '@components/Modal/Global/ModalContext'; import OnyxListItemProvider from '@components/OnyxListItemProvider'; import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; +import useIsPolicyConnectedToUberReceiptPartner from '@hooks/useIsPolicyConnectedToUberReceiptPartner'; import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; @@ -25,6 +26,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import type Beta from '@src/types/onyx/Beta'; import {PortalProvider} from '@gorhom/portal'; import {NavigationContainer} from '@react-navigation/native'; @@ -44,6 +46,8 @@ jest.mock('@components/Modal/ReanimatedModal', () => { return MockReanimatedModal; }); +jest.mock('@hooks/useIsPolicyConnectedToUberReceiptPartner', () => ({__esModule: true, default: jest.fn(() => false)})); + jest.mock('@libs/CardUtils', () => { const actual: typeof CardUtils = jest.requireActual('@libs/CardUtils'); return { @@ -118,6 +122,7 @@ const isSmartLimitEnabledMock = jest.mocked(CardUtils.isSmartLimitEnabled); const getCompanyFeedsMock = jest.mocked(CardUtils.getCompanyFeeds); const hasAccountingConnectionsMock = jest.mocked(PolicyUtils.hasAccountingConnections); const hasAccountingFeatureConnectionMock = jest.mocked(PolicyUtils.hasAccountingFeatureConnection); +const useIsUberConnectedMock = jest.mocked(useIsPolicyConnectedToUberReceiptPartner); const navigateSpy = jest.spyOn(Navigation, 'navigate').mockImplementation(() => undefined); const navigateToConciergeChatSpy = jest.spyOn(ReportActions, 'navigateToConciergeChat').mockImplementation(() => Promise.resolve()); @@ -155,6 +160,7 @@ describe('WorkspaceMoreFeaturesPage', () => { getCompanyFeedsMock.mockReturnValue({}); hasAccountingConnectionsMock.mockReturnValue(false); hasAccountingFeatureConnectionMock.mockReturnValue(false); + useIsUberConnectedMock.mockReturnValue(false); }); afterEach(async () => { @@ -297,6 +303,41 @@ describe('WorkspaceMoreFeaturesPage', () => { }); }); + describe('Accounting toggle (locked when an integration is connected)', () => { + it('locks the Accounting switch when the policy has an active connection', async () => { + await TestHelper.signInWithTestUser(); + hasAccountingConnectionsMock.mockReturnValue(true); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildPolicy({id: POLICY_ID})); + }); + + renderPage({policyID: POLICY_ID}); + await waitForBatchedUpdatesWithAct(); + + await expect(findLockedSwitch('workspace.moreFeatures.connections.subtitle')).resolves.toBeOnTheScreen(); + }); + + it('routes confirm to the accounting page', async () => { + await TestHelper.signInWithTestUser(); + hasAccountingConnectionsMock.mockReturnValue(true); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildPolicy({id: POLICY_ID})); + }); + + renderPage({policyID: POLICY_ID}); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(await findLockedSwitch('workspace.moreFeatures.connections.subtitle')); + + await waitFor(() => { + expect(screen.getByText(TestHelper.translateLocal('workspace.moreFeatures.connectionsWarningModal.disconnectText'))).toBeOnTheScreen(); + }); + fireEvent.press(await screen.findByLabelText(TestHelper.translateLocal('workspace.moreFeatures.connectionsWarningModal.manageSettings'))); + await waitForBatchedUpdatesWithAct(); + + expect(navigateSpy).toHaveBeenCalledWith(ROUTES.POLICY_ACCOUNTING.getRoute(POLICY_ID)); + }); + }); + describe('Concierge-routed disable flows', () => { it('opens Concierge chat when the user confirms the Expensify Card disable warning', async () => { await TestHelper.signInWithTestUser(); @@ -343,6 +384,64 @@ describe('WorkspaceMoreFeaturesPage', () => { }); }); + describe('Receipt partners (informational warning, no confirm action)', () => { + it('opens the disconnect-Uber info modal without a Cancel button when locked', async () => { + await TestHelper.signInWithTestUser(); + useIsUberConnectedMock.mockReturnValue(true); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildPolicy({id: POLICY_ID})); + }); + + renderPage({policyID: POLICY_ID}); + await waitForBatchedUpdatesWithAct(); + fireEvent.press(await findLockedSwitch('workspace.moreFeatures.receiptPartners.subtitle')); + + await waitFor(() => { + expect(screen.getByText(TestHelper.translateLocal('workspace.moreFeatures.receiptPartnersWarningModal.disconnectText'))).toBeOnTheScreen(); + }); + expect(screen.queryByText(TestHelper.translateLocal('common.cancel'))).toBeNull(); + + fireEvent.press(await screen.findByLabelText(TestHelper.translateLocal('workspace.moreFeatures.receiptPartnersWarningModal.confirmText'))); + await waitForBatchedUpdatesWithAct(); + + expect(navigateSpy).not.toHaveBeenCalled(); + expect(navigateToConciergeChatSpy).not.toHaveBeenCalled(); + }); + }); + + describe('Integrate section', () => { + const renderWithBetas = async (betas: Beta[]) => { + await TestHelper.signInWithTestUser(); + await act(async () => { + await Onyx.merge(ONYXKEYS.BETAS, betas); + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildPolicy({id: POLICY_ID})); + }); + renderPage({policyID: POLICY_ID}); + await waitForBatchedUpdatesWithAct(); + }; + + const accountingSwitchQuery = () => + screen.queryByRole(CONST.ROLE.SWITCH, {name: new RegExp(escapeRegExp(TestHelper.translateLocal('workspace.moreFeatures.connections.subtitle')), 'i')}); + + it('shows the Accounting toggle without the unified Connections beta', async () => { + // Given a workspace + // When More Features renders without the beta + await renderWithBetas([]); + + // Then the integration features can still be turned on here, since their pages are only reachable once they are on + expect(accountingSwitchQuery()).toBeOnTheScreen(); + }); + + it('hides the integration toggles with the unified Connections beta', async () => { + // Given a workspace + // When More Features renders with the beta + await renderWithBetas([CONST.BETAS.UNIFIED_CONNECTIONS]); + + // Then the toggles are gone, because Connections turns each feature on as its first integration connects + expect(accountingSwitchQuery()).toBeNull(); + }); + }); + describe('Vendors row (visibility gated on a supported integration)', () => { const renderWithVendorMatching = async (connections: Record, isBetaEnabled = true) => { await TestHelper.signInWithTestUser(); diff --git a/tests/unit/AccountingConnectionConfirmationTest.tsx b/tests/unit/AccountingConnectionConfirmationTest.tsx index b06d783c7f31..e482ee4207f2 100644 --- a/tests/unit/AccountingConnectionConfirmationTest.tsx +++ b/tests/unit/AccountingConnectionConfirmationTest.tsx @@ -87,6 +87,17 @@ jest.mock('@hooks/useHasReusablePoliciesConnectedTo', () => ({ default: () => false, })); +let mockEnabledBetas: string[] = []; +jest.mock('@hooks/usePermissions', () => () => ({isBetaEnabled: (beta: string) => mockEnabledBetas.includes(beta)})); + +const mockEnablePolicyConnections = jest.fn(); +jest.mock('@userActions/Policy/Policy', () => ({ + __esModule: true, + enablePolicyConnections: (policyID: string, enabled: boolean, shouldGoBack: boolean) => { + mockEnablePolicyConnections(policyID, enabled, shouldGoBack); + }, +})); + const policy: Policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE, 'Test workspace'), id: POLICY_ID}; const TestHarness = React.forwardRef((_props, ref) => { @@ -154,11 +165,11 @@ describe('AccountingContextProvider connect-confirmation prompt', () => { // Then the user is asked first, and the setup flow stays unmounted so it cannot start behind the prompt expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); - expect(getShowConfirmModalOption('title')).toBe('workspace.connections.replaceConnectionTitle'); - expect(getShowConfirmModalOption('prompt')).toBe(`workspace.connections.replaceConnectionPrompt:${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.xero}`); - expect(getShowConfirmModalOption('confirmText')).toBe('common.replace'); + expect(getShowConfirmModalOption('title')).toBe(`workspace.accounting.connectTitle:${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.quickbooksOnline}`); + expect(getShowConfirmModalOption('prompt')).toBe(`workspace.accounting.connectPrompt:${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.quickbooksOnline}`); + expect(getShowConfirmModalOption('confirmText')).toBe('workspace.accounting.setup'); expect(getShowConfirmModalOption('cancelText')).toBe('common.cancel'); - expect(getShowConfirmModalOption('buttonVariant')).toBe(CONST.BUTTON_VARIANT.DANGER); + expect(getShowConfirmModalOption('buttonVariant')).toBe(CONST.BUTTON_VARIANT.SUCCESS); expect(screen.queryByTestId(SETUP_FLOW_TEST_ID)).not.toBeOnTheScreen(); }); @@ -189,25 +200,23 @@ describe('AccountingContextProvider connect-confirmation prompt', () => { expect(screen.getByTestId(SETUP_FLOW_TEST_ID)).toBeOnTheScreen(); }); - it('should name the connected Intuit Enterprise Suite when that is the connection being replaced', async () => { - // Given a workspace whose QuickBooks Online connection is the Intuit Enterprise Suite variant - const ref = renderProvider({ - ...policy, - connections: createMock({quickbooksOnline: {config: {credentials: {scope: CONST.POLICY.CONNECTIONS.INTUIT_ENTERPRISE_SUITE_SCOPE}}}}), - }); + it('should use the Intuit Enterprise Suite display name when the integration is one', async () => { + // Given a workspace with an integration connected + const ref = renderProvider(); - // When a different integration is started, which means replacing that connection + // When the flow being started is the Intuit Enterprise Suite variant, which shares QBO's connection name await act(async () => { ref.current?.startIntegrationFlow({ - name: CONST.POLICY.CONNECTIONS.NAME.XERO, + name: CONST.POLICY.CONNECTIONS.NAME.QBO, + isIntuitEnterpriseSuite: true, shouldDisconnectIntegrationBeforeConnecting: true, - integrationToDisconnect: CONST.POLICY.CONNECTIONS.NAME.QBO, + integrationToDisconnect: CONST.POLICY.CONNECTIONS.NAME.XERO, }); await waitForBatchedUpdates(); }); - // Then the prompt names the suite rather than QuickBooks Online, so the user recognises what they would lose - expect(getShowConfirmModalOption('prompt')).toBe('workspace.connections.replaceConnectionPrompt:workspace.accounting.intuitEnterpriseSuite'); + // Then the prompt names the suite rather than QuickBooks Online, so the user recognises what they bought + expect(getShowConfirmModalOption('title')).toBe('workspace.accounting.connectTitle:workspace.accounting.intuitEnterpriseSuite'); }); it('should disconnect the old connection and release the setup flow on confirm', async () => { @@ -312,4 +321,64 @@ describe('AccountingContextProvider connect-confirmation prompt', () => { expect(mockRemovePolicyConnection).not.toHaveBeenCalled(); expect(screen.queryByTestId(SETUP_FLOW_TEST_ID)).not.toBeOnTheScreen(); }); + + describe('with the unified Connections beta', () => { + beforeEach(() => { + mockEnabledBetas = [CONST.BETAS.UNIFIED_CONNECTIONS]; + }); + + afterEach(() => { + mockEnabledBetas = []; + }); + + it('should ask to replace the connected integration, naming the one that would be removed', async () => { + // Given a workspace with an accounting integration connected + const ref = renderProvider(); + + // When a flow that has to disconnect it first is started + await startFlowNeedingDisconnect(ref); + + // Then the prompt is about replacing that connection, because Connections lists every integration side by side + expect(getShowConfirmModalOption('title')).toBe('workspace.connections.replaceConnectionTitle'); + expect(getShowConfirmModalOption('prompt')).toBe(`workspace.connections.replaceConnectionPrompt:${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.xero}`); + expect(getShowConfirmModalOption('confirmText')).toBe('common.replace'); + expect(getShowConfirmModalOption('buttonVariant')).toBe(CONST.BUTTON_VARIANT.DANGER); + expect(screen.queryByTestId(SETUP_FLOW_TEST_ID)).not.toBeOnTheScreen(); + }); + + it('should name the connected Intuit Enterprise Suite when that is the connection being replaced', async () => { + // Given a workspace whose QuickBooks Online connection is the Intuit Enterprise Suite variant + const ref = renderProvider({ + ...policy, + connections: createMock({quickbooksOnline: {config: {credentials: {scope: CONST.POLICY.CONNECTIONS.INTUIT_ENTERPRISE_SUITE_SCOPE}}}}), + }); + + // When a different integration is started, which means replacing that connection + await act(async () => { + ref.current?.startIntegrationFlow({ + name: CONST.POLICY.CONNECTIONS.NAME.XERO, + shouldDisconnectIntegrationBeforeConnecting: true, + integrationToDisconnect: CONST.POLICY.CONNECTIONS.NAME.QBO, + }); + await waitForBatchedUpdates(); + }); + + // Then the prompt names the suite rather than QuickBooks Online, so the user recognizes what they would lose + expect(getShowConfirmModalOption('prompt')).toBe('workspace.connections.replaceConnectionPrompt:workspace.accounting.intuitEnterpriseSuite'); + }); + + it('should turn the Accounting feature on when a flow starts on a workspace that has it off', async () => { + // Given a workspace with the Accounting feature off, which Connections has no toggle for + const ref = renderProvider({...policy, areConnectionsEnabled: false}); + + // When a connect flow is started + await act(async () => { + ref.current?.startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.QBO}); + await waitForBatchedUpdates(); + }); + + // Then the feature is turned on without navigating away, so the integration can be used once it connects + expect(mockEnablePolicyConnections).toHaveBeenCalledWith(POLICY_ID, true, false); + }); + }); }); diff --git a/tests/unit/ConnectionsAccountingPageQBOTokenExpiryTest.tsx b/tests/unit/ConnectionsAccountingPageQBOTokenExpiryTest.tsx new file mode 100644 index 000000000000..717e8c2a0b5c --- /dev/null +++ b/tests/unit/ConnectionsAccountingPageQBOTokenExpiryTest.tsx @@ -0,0 +1,223 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; +import type TextComponent from '@components/Text'; + +import DateUtils from '@libs/DateUtils'; + +import ConnectionsAccountingPage from '@pages/workspace/connections/ConnectionsAccountingPage'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type Policy from '@src/types/onyx/Policy'; + +import type * as ReactNavigation from '@react-navigation/native'; + +import {addDays, getUnixTime, subDays} from 'date-fns'; +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import createRandomPolicy from '../utils/collections/policies'; +import createMock from '../utils/createMock'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const POLICY_ID = 'policy-1'; +const OVERFLOW_MENU_ITEM_PREFIX = 'overflow:'; + +const mockStartIntegrationFlow = jest.fn(); +let mockCanWriteAccounting = true; + +jest.mock('@react-navigation/native', () => { + const actualNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualNavigation, + useFocusEffect: () => {}, + useRoute: () => ({params: {}}), + useNavigation: () => ({}), + useIsFocused: () => true, + }; +}); + +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: { + setParams: jest.fn(), + navigate: jest.fn(), + goBack: jest.fn(), + isNavigationReady: () => Promise.resolve(), + }, +})); + +jest.mock('@pages/workspace/accounting/AccountingContext', () => ({ + __esModule: true, + AccountingContextProvider: ({children}: {children: React.ReactNode}) => children, + useAccountingActions: () => ({startIntegrationFlow: mockStartIntegrationFlow}), + useAccountingState: () => ({activeIntegration: undefined, popoverAnchorRefs: {current: {}}}), +})); + +jest.mock('@pages/workspace/withPolicyConnections', () => ({ + __esModule: true, + default: (Component: React.ComponentType<{policy: Policy}>) => Component, +})); + +jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({ + __esModule: true, + default: ({children}: {children: React.ReactNode}) => children, +})); + +jest.mock('@components/ScreenWrapper', () => ({ + __esModule: true, + default: ({children}: {children: React.ReactNode}) => children, +})); + +jest.mock('@components/ScrollView', () => ({ + __esModule: true, + default: ({children}: {children: React.ReactNode}) => children, +})); + +jest.mock('@components/MenuItemList', () => ({__esModule: true, default: () => null})); +jest.mock('@components/Section', () => ({__esModule: true, default: ({children}: {children: React.ReactNode}) => children})); +jest.mock('@components/CollapsibleSection', () => ({__esModule: true, default: () => null})); +jest.mock('@components/ActivityIndicator', () => ({__esModule: true, default: () => null})); + +// The header's three-dot menu only renders its entries inside a popover once pressed. Rendering their labels inline lets the tests +// read which credentials entry the page offers without driving the popover. +jest.mock('@components/HeaderWithBackButton', () => { + const {default: MockText} = jest.requireActual<{default: typeof TextComponent}>('@components/Text'); + return { + __esModule: true, + default: ({threeDotsMenuItems, shouldShowThreeDotsButton}: {threeDotsMenuItems?: PopoverMenuItem[]; shouldShowThreeDotsButton?: boolean}) => + shouldShowThreeDotsButton ? threeDotsMenuItems?.map((menuItem) => {`${OVERFLOW_MENU_ITEM_PREFIX}${menuItem.text}`}) : null, + }; +}); + +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => ({ + __esModule: true, + default: () => ({canWrite: mockCanWriteAccounting, showReadOnlyModal: () => {}}), +})); + +// The real `withPolicyConnections` HOC reads `policy` from Onyx and strips it from the component's public props. It is +// mocked to an identity wrapper above, so the component under test takes `policy` directly. +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only: the HOC that would inject `policy` is mocked out, so it is passed as a prop here +const ConnectionsAccountingPageUnderTest = ConnectionsAccountingPage as unknown as React.ComponentType<{policy: Policy}>; + +/** + * A healthy QBO connection (synced, no error) whose refresh token expires at the given time. `data` is populated so the + * connection does not read as a first sync still in progress. + */ +function buildQBOPolicy(refreshTokenExpiresAt: Date, isAuthenticationError = false): Policy { + return { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE, 'Test workspace'), + id: POLICY_ID, + connections: createMock({ + [CONST.POLICY.CONNECTIONS.NAME.QBO]: { + config: { + credentials: { + companyID: '12345', + refreshTokenExpiresAt: getUnixTime(refreshTokenExpiresAt), + }, + }, + data: {}, + lastSync: { + isSuccessful: !isAuthenticationError, + isAuthenticationError, + isConnected: true, + successfulDate: subDays(new Date(), 1).toISOString(), + source: 'NEWEXPENSIFY', + }, + }, + }), + }; +} + +function formatExpiryDate(expiryDate: Date): string { + return DateUtils.formatWithUTCTimeZone(expiryDate.toISOString(), CONST.DATE.MONTH_DAY_YEAR_FORMAT, undefined); +} + +async function renderPage(policy: Policy) { + render( + + + , + ); + await waitForBatchedUpdates(); +} + +describe('ConnectionsAccountingPage QBO refresh token expiry warning', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + IntlStore.load(CONST.LOCALES.EN); + return waitForBatchedUpdates(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockCanWriteAccounting = true; + }); + + it('should warn with the expiry date and offer to reconnect when the token expires within the warning window', async () => { + // Given a healthy QBO connection whose refresh token expires in a few days + const expiryDate = addDays(new Date(), 3); + await renderPage(buildQBOPolicy(expiryDate)); + + // Then the connection row explains when the connection will expire + expect(screen.getByText(`Your QuickBooks Online connection expires on ${formatExpiryDate(expiryDate)}.`, {exact: false})).toBeOnTheScreen(); + + // And the overflow menu gains a credentials entry that reads Reconnect rather than Enter your credentials + expect(screen.getByText(`${OVERFLOW_MENU_ITEM_PREFIX}Reconnect`)).toBeOnTheScreen(); + expect(screen.queryByText(`${OVERFLOW_MENU_ITEM_PREFIX}Enter your credentials`)).not.toBeOnTheScreen(); + + // When the user presses the Reconnect link in the warning + fireEvent.press(screen.getByRole('link', {name: 'Reconnect'}), {preventDefault: () => {}}); + + // Then the QBO connect flow starts so a fresh token is issued before the current one lapses + expect(mockStartIntegrationFlow).toHaveBeenCalledTimes(1); + expect(mockStartIntegrationFlow).toHaveBeenCalledWith({name: CONST.POLICY.CONNECTIONS.NAME.QBO, isIntuitEnterpriseSuite: false}); + }); + + it('should say the connection expired when the token is past its expiry but no sync has failed yet', async () => { + // Given a QBO connection whose refresh token lapsed yesterday while the last sync still reads as successful + const expiryDate = subDays(new Date(), 1); + await renderPage(buildQBOPolicy(expiryDate)); + + // Then the row says the connection already expired and still offers to reconnect + expect(screen.getByText(`Your QuickBooks Online connection expired on ${formatExpiryDate(expiryDate)}.`, {exact: false})).toBeOnTheScreen(); + expect(screen.getByRole('link', {name: 'Reconnect'})).toBeOnTheScreen(); + }); + + it('should not warn while the token is still far from expiring', async () => { + // Given a healthy QBO connection whose refresh token is valid well past the warning window + await renderPage(buildQBOPolicy(addDays(new Date(), CONST.POLICY.CONNECTIONS.QBO_REFRESH_TOKEN_EXPIRY_WARNING_DAYS + 30))); + + // Then no expiry warning is shown and the overflow menu offers no credentials entry, as a healthy QBO connection + // has nothing to re-enter + expect(screen.queryByText('Your QuickBooks Online connection', {exact: false})).not.toBeOnTheScreen(); + expect(screen.queryByRole('link', {name: 'Reconnect'})).not.toBeOnTheScreen(); + expect(screen.queryByText(`${OVERFLOW_MENU_ITEM_PREFIX}Reconnect`)).not.toBeOnTheScreen(); + expect(screen.queryByText(`${OVERFLOW_MENU_ITEM_PREFIX}Enter your credentials`)).not.toBeOnTheScreen(); + }); + + it('should leave the authentication error in charge once a sync has already failed on the expired token', async () => { + // Given a QBO connection whose token lapsed and whose last sync already failed to authenticate + await renderPage(buildQBOPolicy(subDays(new Date(), 1), true)); + + // Then the standard authentication error is shown instead of a second, redundant expiry warning + expect(screen.queryByText('Your QuickBooks Online connection', {exact: false})).not.toBeOnTheScreen(); + expect(screen.getByText('due to an authentication error', {exact: false})).toBeOnTheScreen(); + }); + + it('should not warn a member who can only read the accounting settings', async () => { + // Given a QBO connection whose refresh token expires in a few days, viewed by a member without write access to accounting + mockCanWriteAccounting = false; + await renderPage(buildQBOPolicy(addDays(new Date(), 3))); + + // Then no expiry warning or Reconnect link is shown, since reconnecting is a write action this member cannot take + expect(screen.queryByText('Your QuickBooks Online connection', {exact: false})).not.toBeOnTheScreen(); + expect(screen.queryByRole('link', {name: 'Reconnect'})).not.toBeOnTheScreen(); + expect(screen.queryByText(`${OVERFLOW_MENU_ITEM_PREFIX}Reconnect`)).not.toBeOnTheScreen(); + }); +}); diff --git a/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx b/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx new file mode 100644 index 000000000000..7ee37df32f51 --- /dev/null +++ b/tests/unit/PolicyAccountingPageAutoStartFlowTest.tsx @@ -0,0 +1,183 @@ +import {render} from '@testing-library/react-native'; + +import Navigation from '@libs/Navigation/Navigation'; + +import PolicyAccountingPage from '@pages/workspace/accounting/PolicyAccountingPage'; + +import CONST from '@src/CONST'; +import type {ConnectionName} from '@src/types/onyx/Policy'; +import type Policy from '@src/types/onyx/Policy'; + +import type * as ReactNavigation from '@react-navigation/native'; + +import React from 'react'; + +import createRandomPolicy from '../utils/collections/policies'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const POLICY_ID = 'policy-1'; + +type RouteParams = { + newConnectionName?: ConnectionName; + integrationToDisconnect?: ConnectionName; + shouldDisconnectIntegrationBeforeConnecting?: boolean; + isIntuitEnterpriseSuite?: string; +}; + +let mockRouteParams: RouteParams = {}; + +const mockStartIntegrationFlow = jest.fn(); + +// The real `useFocusEffect` re-runs its callback whenever the callback's identity changes while the screen is focused. +// `useEffect(cb, [cb])` reproduces exactly that, which is the behaviour the guard under test has to survive. +jest.mock('@react-navigation/native', () => { + const actualNavigation = jest.requireActual('@react-navigation/native'); + const {useEffect: mockUseEffect} = jest.requireActual('react'); + return { + ...actualNavigation, + useFocusEffect: (callback: () => void) => { + mockUseEffect(callback, [callback]); + }, + useRoute: () => ({params: mockRouteParams}), + useNavigation: () => ({}), + useIsFocused: () => true, + }; +}); + +// `Navigation.setParams` clears `newConnectionName` through react-navigation's state update, which lands in a later +// render than the synchronous call. The mock therefore only records the requested params; each test applies them when +// it wants that state update to land, which is what lets the two orderings below be told apart. +let mockPendingParams: RouteParams | undefined; +jest.mock('@libs/Navigation/Navigation', () => ({ + __esModule: true, + default: { + setParams: jest.fn((params: RouteParams) => { + mockPendingParams = params; + }), + navigate: jest.fn(), + goBack: jest.fn(), + isNavigationReady: () => Promise.resolve(), + }, +})); + +/** Lands the pending `Navigation.setParams` update, as a real navigation state change eventually would. */ +function landPendingParamsUpdate() { + mockRouteParams = {...mockRouteParams, ...mockPendingParams}; + mockPendingParams = undefined; +} + +jest.mock('@pages/workspace/accounting/AccountingContext', () => ({ + __esModule: true, + AccountingContextProvider: ({children}: {children: React.ReactNode}) => children, + useAccountingActions: () => ({startIntegrationFlow: mockStartIntegrationFlow}), + useAccountingState: () => ({activeIntegration: undefined, popoverAnchorRefs: {current: {}}}), +})); + +jest.mock('@pages/workspace/withPolicyConnections', () => ({ + __esModule: true, + default: (Component: React.ComponentType<{policy: Policy}>) => Component, +})); + +jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({ + __esModule: true, + default: ({children}: {children: React.ReactNode}) => children, +})); + +jest.mock('@components/ScreenWrapper', () => ({ + __esModule: true, + default: ({children}: {children: React.ReactNode}) => children, +})); + +jest.mock('@components/ScrollView', () => ({ + __esModule: true, + default: ({children}: {children: React.ReactNode}) => children, +})); + +jest.mock('@components/HeaderWithBackButton', () => ({__esModule: true, default: () => null})); +jest.mock('@components/MenuItemList', () => ({__esModule: true, default: () => null})); +jest.mock('@components/MenuItem', () => ({__esModule: true, default: () => null})); +jest.mock('@components/Section', () => ({__esModule: true, default: ({children}: {children: React.ReactNode}) => children})); +jest.mock('@components/CollapsibleSection', () => ({__esModule: true, default: () => null})); +jest.mock('@components/ThreeDotsMenu', () => ({__esModule: true, default: () => null})); +jest.mock('@components/ActivityIndicator', () => ({__esModule: true, default: () => null})); + +jest.mock('@libs/PolicyUtils', () => { + const actual = jest.requireActual>('@libs/PolicyUtils'); + return { + ...actual, + isControlPolicy: () => true, + }; +}); + +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => ({ + __esModule: true, + default: () => ({canWrite: true, showReadOnlyModal: () => {}}), +})); + +// The real `withPolicyConnections` HOC reads `policy` from Onyx and strips it from the component's public props. It is +// mocked to an identity wrapper above, so the component under test takes `policy` directly. +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only: the HOC that would inject `policy` is mocked out, so it is passed as a prop here +const PolicyAccountingPageUnderTest = PolicyAccountingPage as unknown as React.ComponentType<{policy: Policy}>; + +function buildPolicy(overrides: Partial = {}): Policy { + return {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE, 'Test workspace'), id: POLICY_ID, ...overrides}; +} + +describe('PolicyAccountingPage auto-started connect flow', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockPendingParams = undefined; + mockRouteParams = { + newConnectionName: CONST.POLICY.CONNECTIONS.NAME.QBO, + integrationToDisconnect: CONST.POLICY.CONNECTIONS.NAME.XERO, + shouldDisconnectIntegrationBeforeConnecting: true, + }; + }); + + it('should start the flow once when the effect re-runs before the cleared param has landed', async () => { + // Given the page opened by a route that asks for a connect flow, which starts it and asks for the param to be + // cleared so it cannot be acted on twice + const {rerender} = render(); + await waitForBatchedUpdates(); + + expect(mockStartIntegrationFlow).toHaveBeenCalledTimes(1); + expect(Navigation.setParams).toHaveBeenCalled(); + + // When anything re-creates `startIntegrationFlow` and re-runs the effect before that clear has landed, which a + // policy update does. This is the window the guard exists for: `newConnectionName` is still set here. + rerender(); + await waitForBatchedUpdates(); + + // Then the flow is not started again, because a second start is what used to stack a second confirmation + // prompt the user had to dismiss twice + expect(mockStartIntegrationFlow).toHaveBeenCalledTimes(1); + }); + + it('should start the flow again for a later round-trip that asks for the same integration', async () => { + // Given a connect flow that was started from the route param and then let the clear land, so nothing is + // pending any more + const {rerender} = render(); + await waitForBatchedUpdates(); + + expect(mockStartIntegrationFlow).toHaveBeenCalledTimes(1); + + landPendingParamsUpdate(); + rerender(); + await waitForBatchedUpdates(); + + expect(mockStartIntegrationFlow).toHaveBeenCalledTimes(1); + + // When the user comes back later and asks for the very same integration again + mockRouteParams = { + newConnectionName: CONST.POLICY.CONNECTIONS.NAME.QBO, + integrationToDisconnect: CONST.POLICY.CONNECTIONS.NAME.XERO, + shouldDisconnectIntegrationBeforeConnecting: true, + }; + rerender(); + await waitForBatchedUpdates(); + + // Then it is honoured rather than swallowed as a repeat of the first run, or the connect flow would silently + // do nothing the second time round + expect(mockStartIntegrationFlow).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/unit/PolicyAccountingPageQBOTokenExpiryTest.tsx b/tests/unit/PolicyAccountingPageQBOTokenExpiryTest.tsx index 6058402dc117..94038a342332 100644 --- a/tests/unit/PolicyAccountingPageQBOTokenExpiryTest.tsx +++ b/tests/unit/PolicyAccountingPageQBOTokenExpiryTest.tsx @@ -79,19 +79,19 @@ jest.mock('@components/ScrollView', () => ({ default: ({children}: {children: React.ReactNode}) => children, })); +jest.mock('@components/HeaderWithBackButton', () => ({__esModule: true, default: () => null})); jest.mock('@components/MenuItemList', () => ({__esModule: true, default: () => null})); jest.mock('@components/Section', () => ({__esModule: true, default: ({children}: {children: React.ReactNode}) => children})); jest.mock('@components/CollapsibleSection', () => ({__esModule: true, default: () => null})); jest.mock('@components/ActivityIndicator', () => ({__esModule: true, default: () => null})); -// The header's three-dot menu only renders its entries inside a popover once pressed. Rendering their labels inline lets the tests +// The real menu only renders its entries inside a popover once pressed. Rendering their labels inline lets the tests // read which credentials entry the page offers without driving the popover. -jest.mock('@components/HeaderWithBackButton', () => { +jest.mock('@components/ThreeDotsMenu', () => { const {default: MockText} = jest.requireActual<{default: typeof TextComponent}>('@components/Text'); return { __esModule: true, - default: ({threeDotsMenuItems, shouldShowThreeDotsButton}: {threeDotsMenuItems?: PopoverMenuItem[]; shouldShowThreeDotsButton?: boolean}) => - shouldShowThreeDotsButton ? threeDotsMenuItems?.map((menuItem) => {`${OVERFLOW_MENU_ITEM_PREFIX}${menuItem.text}`}) : null, + default: ({menuItems}: {menuItems: PopoverMenuItem[]}) => menuItems.map((menuItem) => {`${OVERFLOW_MENU_ITEM_PREFIX}${menuItem.text}`}), }; }); diff --git a/tests/unit/SearchRouterNavigationTest.ts b/tests/unit/SearchRouterNavigationTest.ts index c3460d096e41..27f1b64ea78c 100644 --- a/tests/unit/SearchRouterNavigationTest.ts +++ b/tests/unit/SearchRouterNavigationTest.ts @@ -203,7 +203,9 @@ const workspaceIcons = { Users: mockIcon, Hashtag: mockIcon, Document: mockIcon, + Sync: mockIcon, Connect: mockIcon, + Receipt: mockIcon, Briefcase: mockIcon, Folder: mockIcon, Tag: mockIcon, @@ -219,6 +221,8 @@ const workspaceIcons = { InvoiceGeneric: mockIcon, Gear: mockIcon, Bolt: mockIcon, + Bot: mockIcon, + UserPlus: mockIcon, }; function createWorkspacePolicy(id: string, name: string, overrides: Partial = {}): Policy { @@ -701,7 +705,7 @@ describe('Workspace Search Router navigation source', () => { jest.clearAllMocks(); }); - const buildItems = (policies: Policy[], isOffline = false) => + const buildItems = (policies: Policy[], isOffline = false, isUnifiedConnectionsBetaEnabled = false) => buildWorkspaceNavigationItems({ policies: Object.fromEntries(policies.map((policy) => [`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy])), policyCategories: undefined, @@ -709,6 +713,8 @@ describe('Workspace Search Router navigation source', () => { icons: workspaceIcons, isOffline, isVendorMatchingBetaEnabled: false, + isRecruitingBetaEnabled: false, + isUnifiedConnectionsBetaEnabled, shouldUseNarrowLayout: false, convertToDisplayString: () => '$0.00', getItemText: (item) => { @@ -745,9 +751,18 @@ describe('Workspace Search Router navigation source', () => { expect(items.some((item) => item.keyForList === `workspace_1_${SCREENS.WORKSPACE.WORKFLOWS}`)).toBe(false); }); - it('matches the Connections row by the name of an integration category it replaced', () => { - // Given a workspace, since HR no longer has its own row - const items = buildItems([createWorkspacePolicy('1', 'Alpha Workspace')]); + it('supports the short HR query and alphabetizes equal-priority Workspace rows', () => { + const items = buildItems([createWorkspacePolicy('1', 'Beta Workspace', {isHREnabled: true}), createWorkspacePolicy('2', 'Alpha Workspace', {isHREnabled: true})]); + + expect(buildNavigationSuggestions('hr', [items], localeCompare).map((item) => item.keyForList)).toEqual([ + `workspace_2_${SCREENS.WORKSPACE.HR}`, + `workspace_1_${SCREENS.WORKSPACE.HR}`, + ]); + }); + + it('matches the Connections row by the name of a page it replaced when the unified Connections beta is on', () => { + // Given a workspace with the HR feature on, viewed with the beta on so HR has no row of its own + const items = buildItems([createWorkspacePolicy('1', 'Alpha Workspace', {isHREnabled: true})], false, true); // When searching for the old HR page by its short name const suggestions = buildNavigationSuggestions('hr', [items], localeCompare); @@ -756,15 +771,6 @@ describe('Workspace Search Router navigation source', () => { expect(suggestions.map((item) => item.keyForList)).toEqual([`workspace_1_${SCREENS.WORKSPACE.CONNECTIONS}`]); }); - it('alphabetizes equal-priority Workspace rows', () => { - const items = buildItems([createWorkspacePolicy('1', 'Beta Workspace'), createWorkspacePolicy('2', 'Alpha Workspace')]); - - expect(buildNavigationSuggestions('connections', [items], localeCompare).map((item) => item.keyForList)).toEqual([ - `workspace_2_${SCREENS.WORKSPACE.CONNECTIONS}`, - `workspace_1_${SCREENS.WORKSPACE.CONNECTIONS}`, - ]); - }); - it('includes workspace identity and navigates through the Workspace synchronization helper', () => { const policy = createWorkspacePolicy('1', 'Alpha Workspace'); const overviewItem = buildItems([policy]).find((item) => item.keyForList === `workspace_1_${SCREENS.WORKSPACE.PROFILE}`); @@ -839,9 +845,11 @@ describe('Workspace Search Router navigation source', () => { expect.objectContaining({ policy: activePolicy, isVendorMatchingBetaEnabled: true, + isRecruitingBetaEnabled: true, }), ); expect(mockIsBetaEnabled).toHaveBeenCalledWith(CONST.BETAS.VENDOR_MATCHING); + expect(mockIsBetaEnabled).toHaveBeenCalledWith(CONST.BETAS.MERGE_ATS); }); }); diff --git a/tests/unit/components/QuickbooksOnlineSetupPageWebTest.tsx b/tests/unit/components/QuickbooksOnlineSetupPageWebTest.tsx index f066bebec8f5..0ccaa1268ee9 100644 --- a/tests/unit/components/QuickbooksOnlineSetupPageWebTest.tsx +++ b/tests/unit/components/QuickbooksOnlineSetupPageWebTest.tsx @@ -60,7 +60,7 @@ describe('QuickbooksOnlineSetupPage (web)', () => { it('returns to the workspace accounting page', () => { renderQuickbooksOnlineSetupPage(); - expect(mockedGoBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_CONNECTIONS.getRoute(POLICY_ID)); + expect(mockedGoBack).toHaveBeenCalledWith(ROUTES.POLICY_ACCOUNTING.getRoute(POLICY_ID)); }); it('renders nothing', () => { diff --git a/tests/unit/components/XeroSetupPageWebTest.tsx b/tests/unit/components/XeroSetupPageWebTest.tsx index 5dc7886f6d5a..06c449ace7c3 100644 --- a/tests/unit/components/XeroSetupPageWebTest.tsx +++ b/tests/unit/components/XeroSetupPageWebTest.tsx @@ -60,7 +60,7 @@ describe('XeroSetupPage (web)', () => { it('returns to the workspace accounting page', () => { renderXeroSetupPage(); - expect(mockedGoBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_CONNECTIONS.getRoute(POLICY_ID)); + expect(mockedGoBack).toHaveBeenCalledWith(ROUTES.POLICY_ACCOUNTING.getRoute(POLICY_ID)); }); it('renders nothing', () => { diff --git a/tests/unit/getWorkspaceMenuItemsTest.ts b/tests/unit/getWorkspaceMenuItemsTest.ts index 50a0e58b9513..8e436c478c2e 100644 --- a/tests/unit/getWorkspaceMenuItemsTest.ts +++ b/tests/unit/getWorkspaceMenuItemsTest.ts @@ -16,7 +16,9 @@ const icons = { Users: mockIcon, Hashtag: mockIcon, Document: mockIcon, + Sync: mockIcon, Connect: mockIcon, + Receipt: mockIcon, Briefcase: mockIcon, Folder: mockIcon, Tag: mockIcon, @@ -32,6 +34,8 @@ const icons = { InvoiceGeneric: mockIcon, Gear: mockIcon, Bolt: mockIcon, + Bot: mockIcon, + UserPlus: mockIcon, }; function buildPolicy(role: Policy['role']): Policy { @@ -177,8 +181,8 @@ describe('getWorkspaceMenuItems', () => { convertToDisplayString: () => '', }); - expect(buildItems(false).find((item) => item.translationKey === 'workspace.common.connections')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); - expect(buildItems(true).find((item) => item.translationKey === 'workspace.common.connections')?.brickRoadIndicator).toBeUndefined(); + expect(buildItems(false).find((item) => item.translationKey === 'workspace.common.accounting')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); + expect(buildItems(true).find((item) => item.translationKey === 'workspace.common.accounting')?.brickRoadIndicator).toBeUndefined(); }); it('shows an error indicator when receipt partner credentials require attention', () => { @@ -193,7 +197,7 @@ describe('getWorkspaceMenuItems', () => { convertToDisplayString: () => '', }); - expect(items.find((item) => item.translationKey === 'workspace.common.connections')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); + expect(items.find((item) => item.translationKey === 'workspace.common.receiptPartners')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); }); it('shows an error indicator when company cards require attention', () => { @@ -315,10 +319,10 @@ describe('getWorkspaceMenuItems', () => { convertToDisplayString: () => '', }); - expect(items.find((item) => item.translationKey === 'workspace.common.connections')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.INFO); + expect(items.find((item) => item.translationKey === 'workspace.common.hr')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.INFO); }); - it('shows an information indicator on Connections when the QBO refresh token is about to expire', () => { + it('shows an information indicator on Accounting when the QBO refresh token is about to expire', () => { const policy = createMock({ ...buildPolicy(CONST.POLICY.ROLE.ADMIN), areConnectionsEnabled: true, @@ -338,10 +342,10 @@ describe('getWorkspaceMenuItems', () => { convertToDisplayString: () => '', }); - expect(items.find((item) => item.translationKey === 'workspace.common.connections')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.INFO); + expect(items.find((item) => item.translationKey === 'workspace.common.accounting')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.INFO); }); - it('keeps the error indicator on Connections when a sync error exists even if the QBO refresh token is about to expire', () => { + it('keeps the error indicator on Accounting when a sync error exists even if the QBO refresh token is about to expire', () => { const policy = createMock({ ...buildPolicy(CONST.POLICY.ROLE.ADMIN), areConnectionsEnabled: true, @@ -361,7 +365,7 @@ describe('getWorkspaceMenuItems', () => { convertToDisplayString: () => '', }); - expect(items.find((item) => item.translationKey === 'workspace.common.connections')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); + expect(items.find((item) => item.translationKey === 'workspace.common.accounting')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); }); it('shows an error indicator when the Merge HR connection has an authentication error', () => { @@ -384,33 +388,85 @@ describe('getWorkspaceMenuItems', () => { convertToDisplayString: () => '', }); - expect(items.find((item) => item.translationKey === 'workspace.common.connections')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); + expect(items.find((item) => item.translationKey === 'workspace.common.hr')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); }); - it('shows a single Connections row even when no integration feature is enabled', () => { - // Given an admin on a workspace that has none of the integration features turned on + it('hides the Recruiting row when the Merge ATS beta is disabled', () => { + const policy = createMock({...buildPolicy(CONST.POLICY.ROLE.ADMIN), isRecruitingEnabled: true}); + + const buildItems = (isRecruitingBetaEnabled: boolean) => + getWorkspaceMenuItems({ + policy, + policyID: policy.id, + currentUserLogin, + icons, + isRecruitingBetaEnabled, + convertToDisplayString: () => '', + }); + + expect(buildItems(false).find((item) => item.translationKey === 'workspace.common.recruiting')).toBeUndefined(); + + const recruitingItem = buildItems(true).find((item) => item.translationKey === 'workspace.common.recruiting'); + expect(recruitingItem?.getRoute()).toBe(ROUTES.WORKSPACE_RECRUITING.getRoute(policy.id)); + expect(recruitingItem?.screenName).toBe(SCREENS.WORKSPACE.RECRUITING); + }); + + it('shows the Recruiting row when an ATS connection exists even without the policy flag', () => { const policy = createMock({ ...buildPolicy(CONST.POLICY.ROLE.ADMIN), - areConnectionsEnabled: false, - isHREnabled: false, - isRecruitingEnabled: false, - receiptPartners: {enabled: false}, + isRecruitingEnabled: undefined, + connections: { + [CONST.POLICY.CONNECTIONS.NAME.MERGE_ATS]: { + config: {integration: 'greenhouse'}, + lastSync: {syncStatus: CONST.MERGE.SYNC_STATUS.DONE}, + }, + }, }); - // When the Workspace menu is built const items = getWorkspaceMenuItems({ policy, policyID: policy.id, currentUserLogin, icons, + isRecruitingBetaEnabled: true, convertToDisplayString: () => '', }); - // Then every integration is reached from the one Connections row, because integrations no longer need a feature toggle - const connectionsItem = items.find((item) => item.translationKey === 'workspace.common.connections'); - expect(connectionsItem?.getRoute()).toBe(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policy.id)); - expect(connectionsItem?.screenName).toBe(SCREENS.WORKSPACE.CONNECTIONS); - expect(items.map((item) => item.translationKey)).not.toEqual(expect.arrayContaining(['workspace.common.accounting', 'workspace.common.hr', 'workspace.common.recruiting'])); + expect(items.find((item) => item.translationKey === 'workspace.common.recruiting')).toBeDefined(); + }); + + it('hides the Recruiting row on a workspace whose plan cannot access the feature', () => { + const policy = createMock({...buildPolicy(CONST.POLICY.ROLE.ADMIN), type: CONST.POLICY.TYPE.TEAM, isRecruitingEnabled: true}); + + const items = getWorkspaceMenuItems({ + policy, + policyID: policy.id, + currentUserLogin, + icons, + isRecruitingBetaEnabled: true, + convertToDisplayString: () => '', + }); + + expect(items.find((item) => item.translationKey === 'workspace.common.recruiting')).toBeUndefined(); + }); + + it('does not highlight a newly enabled Recruiting feature while the Merge ATS beta is disabled', () => { + const policy = createMock({ + ...buildPolicy(CONST.POLICY.ROLE.ADMIN), + isRecruitingEnabled: true, + pendingFields: {isRecruitingEnabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }); + + const items = getWorkspaceMenuItems({ + policy, + policyID: policy.id, + currentUserLogin, + icons, + previousPendingFields: {}, + convertToDisplayString: () => '', + }); + + expect(items.filter((item) => item.highlighted)).toHaveLength(0); }); it('preserves the complete enabled Workspace menu order and presentation data', () => { @@ -448,6 +504,7 @@ describe('getWorkspaceMenuItems', () => { currentUserLogin, icons, isVendorMatchingBetaEnabled: true, + isRecruitingBetaEnabled: true, convertToDisplayString, }); @@ -456,7 +513,11 @@ describe('getWorkspaceMenuItems', () => { 'workspace.common.members', 'workspace.common.rooms', 'common.reports', - 'workspace.common.connections', + 'workspace.common.accounting', + 'workspace.common.hr', + 'workspace.common.recruiting', + 'workspace.common.receiptPartners', + 'workspace.common.mcp', 'workspace.common.categories', 'workspace.common.tags', 'workspace.common.taxes', @@ -477,7 +538,11 @@ describe('getWorkspaceMenuItems', () => { ROUTES.WORKSPACE_MEMBERS.getRoute(policy.id), ROUTES.WORKSPACE_ROOMS.getRoute(policy.id), ROUTES.WORKSPACE_REPORTS.getRoute(policy.id), - ROUTES.WORKSPACE_CONNECTIONS.getRoute(policy.id), + ROUTES.POLICY_ACCOUNTING.getRoute(policy.id), + ROUTES.WORKSPACE_HR.getRoute(policy.id), + ROUTES.WORKSPACE_RECRUITING.getRoute(policy.id), + ROUTES.WORKSPACE_RECEIPT_PARTNERS.getRoute(policy.id), + ROUTES.WORKSPACE_MCP.getRoute(policy.id), ROUTES.WORKSPACE_CATEGORIES.getRoute(policy.id), ROUTES.WORKSPACE_TAGS.getRoute(policy.id), ROUTES.WORKSPACE_TAXES.getRoute(policy.id), @@ -498,7 +563,11 @@ describe('getWorkspaceMenuItems', () => { SCREENS.WORKSPACE.MEMBERS, SCREENS.WORKSPACE.ROOMS, SCREENS.WORKSPACE.REPORTS, - SCREENS.WORKSPACE.CONNECTIONS, + SCREENS.WORKSPACE.ACCOUNTING.ROOT, + SCREENS.WORKSPACE.HR, + SCREENS.WORKSPACE.RECRUITING, + SCREENS.WORKSPACE.RECEIPT_PARTNERS, + SCREENS.WORKSPACE.MCP, SCREENS.WORKSPACE.CATEGORIES, SCREENS.WORKSPACE.TAGS, SCREENS.WORKSPACE.TAXES, @@ -519,3 +588,92 @@ describe('getWorkspaceMenuItems', () => { expect(convertToDisplayString).toHaveBeenCalledWith(123, policy.outputCurrency); }); }); + +describe('getWorkspaceMenuItems with the unified Connections beta', () => { + it('replaces the separate integration rows with a single Connections row', () => { + // Given an admin on a workspace with every integration feature turned on + const policy = createMock({ + ...buildPolicy(CONST.POLICY.ROLE.ADMIN), + areConnectionsEnabled: true, + isHREnabled: true, + isRecruitingEnabled: true, + receiptPartners: {enabled: true}, + isMCPEnabled: true, + }); + + // When the Workspace menu is built with the beta on + const items = getWorkspaceMenuItems({ + policy, + policyID: policy.id, + currentUserLogin, + icons, + isRecruitingBetaEnabled: true, + isUnifiedConnectionsBetaEnabled: true, + convertToDisplayString: () => '', + }); + + // Then the integrations are reached from one Connections row, which also owns the old pages' screens so their links stay in the menu + const connectionsItem = items.find((item) => item.translationKey === 'workspace.common.connections'); + expect(connectionsItem?.getRoute()).toBe(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policy.id)); + expect(connectionsItem?.aliasScreenNames).toEqual([ + SCREENS.WORKSPACE.ACCOUNTING.ROOT, + SCREENS.WORKSPACE.HR, + SCREENS.WORKSPACE.RECRUITING, + SCREENS.WORKSPACE.RECEIPT_PARTNERS, + SCREENS.WORKSPACE.MCP, + ]); + const translationKeys = items.map((item) => item.translationKey); + for (const replacedKey of [ + 'workspace.common.accounting', + 'workspace.common.hr', + 'workspace.common.recruiting', + 'workspace.common.receiptPartners', + 'workspace.common.mcp', + ] as const) { + expect(translationKeys).not.toContain(replacedKey); + } + }); + + it('shows the Connections row even when no integration feature is enabled', () => { + // Given an admin on a workspace that has none of the integration features turned on + const policy = createMock({ + ...buildPolicy(CONST.POLICY.ROLE.ADMIN), + areConnectionsEnabled: false, + isHREnabled: false, + isRecruitingEnabled: false, + receiptPartners: {enabled: false}, + }); + + // When the Workspace menu is built with the beta on + const items = getWorkspaceMenuItems({ + policy, + policyID: policy.id, + currentUserLogin, + icons, + isUnifiedConnectionsBetaEnabled: true, + convertToDisplayString: () => '', + }); + + // Then the row is still there, because connecting an integration turns its feature on + expect(items.find((item) => item.translationKey === 'workspace.common.connections')?.screenName).toBe(SCREENS.WORKSPACE.CONNECTIONS); + }); + + it("surfaces each replaced row's indicator on the Connections row", () => { + // Given a workspace whose receipt partner credentials need attention + const policy = createMock({...buildPolicy(CONST.POLICY.ROLE.ADMIN), receiptPartners: {enabled: true}}); + + // When the Workspace menu is built with the beta on + const items = getWorkspaceMenuItems({ + policy, + policyID: policy.id, + currentUserLogin, + icons, + shouldShowEnterCredentialsError: true, + isUnifiedConnectionsBetaEnabled: true, + convertToDisplayString: () => '', + }); + + // Then the error shows on Connections, since the Receipt partners row it used to show on is gone + expect(items.find((item) => item.translationKey === 'workspace.common.connections')?.brickRoadIndicator).toBe(CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR); + }); +}); diff --git a/tests/unit/hooks/useGettingStartedItems.test.ts b/tests/unit/hooks/useGettingStartedItems.test.ts index 6c11989f67ce..1b129e6ec4ad 100644 --- a/tests/unit/hooks/useGettingStartedItems.test.ts +++ b/tests/unit/hooks/useGettingStartedItems.test.ts @@ -386,7 +386,7 @@ describe('useGettingStartedItems', () => { await waitForBatchedUpdates(); const connectItem = result.current.items.find((item) => item.key === 'connectAccounting'); - expect(connectItem?.route).toBe(ROUTES.WORKSPACE_CONNECTIONS.getRoute(POLICY_ID)); + expect(connectItem?.route).toBe(ROUTES.WORKSPACE_ACCOUNTING.getRoute(POLICY_ID)); }); it('should be not completed when workspace has no accounting connection', async () => { diff --git a/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx b/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx index b41dee3d09bf..4c162387fe76 100644 --- a/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx +++ b/tests/unit/pages/home/GettingStartedSection/GettingStartedSectionTest.tsx @@ -543,7 +543,7 @@ describe('GettingStartedSection', () => { const row = screen.getByText(/homePage\.gettingStartedSection\.connectAccounting:/); fireEvent.press(row); - expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_CONNECTIONS.getRoute(TEST_POLICY_ID)); + expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.WORKSPACE_ACCOUNTING.getRoute(TEST_POLICY_ID)); }); it('navigates to workspace categories when "Customize categories" row is pressed', async () => { diff --git a/tests/unit/pages/workspace/accounting/netsuite/NetSuiteSetupPageTest.tsx b/tests/unit/pages/workspace/accounting/netsuite/NetSuiteSetupPageTest.tsx index 8ef19e1ba3cd..d30d292275d8 100644 --- a/tests/unit/pages/workspace/accounting/netsuite/NetSuiteSetupPageTest.tsx +++ b/tests/unit/pages/workspace/accounting/netsuite/NetSuiteSetupPageTest.tsx @@ -95,7 +95,7 @@ describe('NetSuiteSetupPage', () => { mockHeaderProps.current?.onBackButtonPress?.(); - expect(mockedGoBack).toHaveBeenCalledWith(ROUTES.WORKSPACE_CONNECTIONS.getRoute(POLICY_ID)); + expect(mockedGoBack).toHaveBeenCalledWith(ROUTES.POLICY_ACCOUNTING.getRoute(POLICY_ID)); }); it('renders nothing when the account ID is missing from the route', () => { diff --git a/tests/utils/TestNavigationContainer.tsx b/tests/utils/TestNavigationContainer.tsx index 89447f5a2c31..ec4183408b09 100644 --- a/tests/utils/TestNavigationContainer.tsx +++ b/tests/utils/TestNavigationContainer.tsx @@ -109,7 +109,7 @@ function TestWorkspaceSplitNavigator({route}: {route: RouteProp From 3dfbde9557bcd8760480c767f1e44987db1bbd26 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 6 Oct 2026 02:10:47 +0530 Subject: [PATCH 38/51] Run the legacy More Features tests without the unified Connections beta Signed-off-by: krishna2323 --- tests/ui/WorkspaceMoreFeaturesPageTest.tsx | 6 ++++++ tests/unit/ConnectionsAccountingPageQBOTokenExpiryTest.tsx | 5 ++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/ui/WorkspaceMoreFeaturesPageTest.tsx b/tests/ui/WorkspaceMoreFeaturesPageTest.tsx index 7e509b3a9624..27972f02c631 100644 --- a/tests/ui/WorkspaceMoreFeaturesPageTest.tsx +++ b/tests/ui/WorkspaceMoreFeaturesPageTest.tsx @@ -308,6 +308,8 @@ describe('WorkspaceMoreFeaturesPage', () => { await TestHelper.signInWithTestUser(); hasAccountingConnectionsMock.mockReturnValue(true); await act(async () => { + // The test user is on the 'all' beta, which includes the unified Connections beta that hides this toggle + await Onyx.merge(ONYXKEYS.BETAS, []); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildPolicy({id: POLICY_ID})); }); @@ -321,6 +323,8 @@ describe('WorkspaceMoreFeaturesPage', () => { await TestHelper.signInWithTestUser(); hasAccountingConnectionsMock.mockReturnValue(true); await act(async () => { + // The test user is on the 'all' beta, which includes the unified Connections beta that hides this toggle + await Onyx.merge(ONYXKEYS.BETAS, []); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildPolicy({id: POLICY_ID})); }); @@ -389,6 +393,8 @@ describe('WorkspaceMoreFeaturesPage', () => { await TestHelper.signInWithTestUser(); useIsUberConnectedMock.mockReturnValue(true); await act(async () => { + // The test user is on the 'all' beta, which includes the unified Connections beta that hides this toggle + await Onyx.merge(ONYXKEYS.BETAS, []); await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, buildPolicy({id: POLICY_ID})); }); diff --git a/tests/unit/ConnectionsAccountingPageQBOTokenExpiryTest.tsx b/tests/unit/ConnectionsAccountingPageQBOTokenExpiryTest.tsx index 717e8c2a0b5c..f40e28e739b1 100644 --- a/tests/unit/ConnectionsAccountingPageQBOTokenExpiryTest.tsx +++ b/tests/unit/ConnectionsAccountingPageQBOTokenExpiryTest.tsx @@ -86,12 +86,11 @@ jest.mock('@components/ActivityIndicator', () => ({__esModule: true, default: () // The header's three-dot menu only renders its entries inside a popover once pressed. Rendering their labels inline lets the tests // read which credentials entry the page offers without driving the popover. -jest.mock('@components/HeaderWithBackButton', () => { +jest.mock('@components/Header/primitives/HeaderThreeDotsMenu', () => { const {default: MockText} = jest.requireActual<{default: typeof TextComponent}>('@components/Text'); return { __esModule: true, - default: ({threeDotsMenuItems, shouldShowThreeDotsButton}: {threeDotsMenuItems?: PopoverMenuItem[]; shouldShowThreeDotsButton?: boolean}) => - shouldShowThreeDotsButton ? threeDotsMenuItems?.map((menuItem) => {`${OVERFLOW_MENU_ITEM_PREFIX}${menuItem.text}`}) : null, + default: ({items}: {items: PopoverMenuItem[]}) => items.map((menuItem) => {`${OVERFLOW_MENU_ITEM_PREFIX}${menuItem.text}`}), }; }); From a2932e4c122f690af199bab619c0bc0e70147b81 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 6 Oct 2026 02:43:32 +0530 Subject: [PATCH 39/51] Start the Connections page on the All tab instead of persisting the last one Signed-off-by: krishna2323 --- src/CONST/index.ts | 1 - .../connections/WorkspaceConnectionsPage.tsx | 15 +++------------ 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index f6acc9885ce2..18d3864a9fff 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6790,7 +6790,6 @@ const CONST = { RECEIPTS: 'receipts', AI: 'ai', }, - CONNECTIONS_TAB_TYPE: 'connectionsTabType', WORKFLOWS_TAB_TYPE: 'workflowsTabType', WORKFLOWS: { SUBMISSIONS: 'submissions', diff --git a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx index fe0f46f27c65..80c5d09333b1 100644 --- a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx +++ b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx @@ -32,7 +32,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; import {openPolicyHRPage, openPolicyRecruitingPage} from '@libs/actions/PolicyConnections'; -import Tab from '@libs/actions/Tab'; import Navigation from '@libs/Navigation/Navigation'; import {isControlPolicy} from '@libs/PolicyUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; @@ -73,12 +72,6 @@ type RouteParams = { isIntuitEnterpriseSuite?: string; }; -/** - * The SELECTED_TAB Onyx collection is typed as the IOU request tabs, so a persisted Connections tab is looked up here - * to narrow it without a type assertion. - */ -const CONNECTIONS_TABS_BY_KEY = new Map(Object.values(CONST.TAB.CONNECTIONS).map((tab) => [tab, tab])); - type MergeSetupFlow = { setupLink: string; category: MergeProviderCardCategory; @@ -115,7 +108,7 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { const [account] = useOnyx(ONYXKEYS.ACCOUNT); const {startIntegrationFlow} = useAccountingActions(); const {canWrite: canWriteAccounting} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); - const [lastSelectedTab] = useOnyx(`${ONYXKEYS.COLLECTION.SELECTED_TAB}${CONST.TAB.CONNECTIONS_TAB_TYPE}`); + const [lastSelectedTab, setLastSelectedTab] = useState(CONST.TAB.CONNECTIONS.ALL); const [mergeSetupFlow, setMergeSetupFlow] = useState(); const route = useRoute(); const params = route.params as RouteParams | undefined; @@ -144,9 +137,7 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { // Recruiting providers are behind a beta, so the tab only shows once there is something to list in it const hasRecruitingListings = listings.some((listing) => listing.category === CONST.TAB.CONNECTIONS.RECRUITING); const visibleTabs = Object.values(CONST.TAB.CONNECTIONS).filter((tab) => tab !== CONST.TAB.CONNECTIONS.RECRUITING || hasRecruitingListings); - // The tab is kept in Onyx so the page lands on it again after a remount, e.g. when coming back from a connect flow in another browser tab - const persistedTab = CONNECTIONS_TABS_BY_KEY.get(lastSelectedTab ?? ''); - const activeTab = persistedTab && visibleTabs.includes(persistedTab) ? persistedTab : CONST.TAB.CONNECTIONS.ALL; + const activeTab = visibleTabs.includes(lastSelectedTab) ? lastSelectedTab : CONST.TAB.CONNECTIONS.ALL; const [searchValue, setSearchValue, searchResults, appliedSearchValue] = useSearchResults( availableListings, @@ -313,7 +304,7 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { activeTabKey={selectedTab} onTabPress={(tab) => { setSearchValue(''); - Tab.setSelectedTab(CONST.TAB.CONNECTIONS_TAB_TYPE, tab); + setLastSelectedTab(tab); }} onActiveTabPress={() => setSearchValue('')} // On mobile the tabs scroll edge to edge, so the page gutter moves inside the scroll content From e3395a8270a2bf78c4a9a3e0341390a34adb1d7a Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 6 Oct 2026 19:29:42 +0530 Subject: [PATCH 40/51] Drop the trial cutoff for the Connections tooltip and fix stale comments Signed-off-by: krishna2323 --- src/CONST/index.ts | 2 -- src/components/ProductTrainingContext/TOOLTIPS.ts | 7 ++----- src/components/ProductTrainingContext/index.tsx | 3 --- src/pages/workspace/connections/types.ts | 4 ++-- .../connections/useReceiptPartnerConnectionListings.ts | 2 +- 5 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 18d3864a9fff..97a651776252 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9094,8 +9094,6 @@ const CONST = { SEVEN_DAYS: 7 * 24 * 60 * 60 * 1000, }, - // Users whose free trial started on or after this date never saw the separate Accounting and HR pages - CONNECTIONS_MOVED_NEW_USER_CUTOFF_DATE: '2026-10-01', INBOX_TAB_STALE_UNREAD_MONTHS: 3, CHANGE_POLICY_TRAINING_MODAL: 'changePolicyModal', AGENTS_RULES_BANNER: 'agentsRulesBanner', diff --git a/src/components/ProductTrainingContext/TOOLTIPS.ts b/src/components/ProductTrainingContext/TOOLTIPS.ts index 817e3936dba5..275ceb642b5d 100644 --- a/src/components/ProductTrainingContext/TOOLTIPS.ts +++ b/src/components/ProductTrainingContext/TOOLTIPS.ts @@ -32,9 +32,6 @@ type ShouldShowConditionProps = { isUserPolicyAdmin: boolean; hasBeenAddedToNudgeMigration: boolean; isUserInPaidPolicy: boolean; - - /** The user's free-trial start date, e.g. "2026-06-24". Accounts without a workspace trial don't have one. */ - firstDayFreeTrial: string | undefined; }; type TooltipData = { @@ -123,8 +120,8 @@ const TOOLTIPS: Record = { onHideTooltip: (isDismissedUsingCloseButton = false) => dismissProductTraining(CONNECTIONS_MOVED, isDismissedUsingCloseButton), name: CONNECTIONS_MOVED, priority: 1650, - // Only users who knew the old pages need to hear they moved. The workspace menu limits it to members who can read Connections. - shouldShow: ({firstDayFreeTrial}) => !firstDayFreeTrial || new Date(firstDayFreeTrial).getTime() < new Date(CONST.CONNECTIONS_MOVED_NEW_USER_CUTOFF_DATE).getTime(), + // The workspace menu limits it to members who can read Connections + shouldShow: () => true, }, }; diff --git a/src/components/ProductTrainingContext/index.tsx b/src/components/ProductTrainingContext/index.tsx index 7985d57ddad1..11e2782ca77a 100644 --- a/src/components/ProductTrainingContext/index.tsx +++ b/src/components/ProductTrainingContext/index.tsx @@ -76,7 +76,6 @@ function ProductTrainingContextProvider({children}: ChildrenProps) { }, [allPolicies, currentUserLogin, allPoliciesMetadata, currentUserLoginMetadata]); const [dismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); - const [firstDayFreeTrial] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL); const {shouldUseNarrowLayout} = useResponsiveLayout(); @@ -173,7 +172,6 @@ function ProductTrainingContextProvider({children}: ChildrenProps) { isUserPolicyAdmin, hasBeenAddedToNudgeMigration, isUserInPaidPolicy, - firstDayFreeTrial, }); }, [ @@ -188,7 +186,6 @@ function ProductTrainingContextProvider({children}: ChildrenProps) { isUserPolicyEmployee, isUserPolicyAdmin, isUserInPaidPolicy, - firstDayFreeTrial, ], ); diff --git a/src/pages/workspace/connections/types.ts b/src/pages/workspace/connections/types.ts index 78fea150c248..23f2b9381c27 100644 --- a/src/pages/workspace/connections/types.ts +++ b/src/pages/workspace/connections/types.ts @@ -22,7 +22,7 @@ type ConnectionStatus = { /** One integration on the Connections page, either available to connect or already connected. */ type ConnectionListing = { - /** Unique across all categories, also used to decide which listings appear on the Popular tab */ + /** Unique across all categories */ key: string; category: ConnectionCategory; @@ -37,7 +37,7 @@ type ConnectionListing = { onConnect: () => void; - /** Shows an "Offer available!" badge for a partner promotion. The badge only opens the offer when `onPress` is set. */ + /** Shows an "Offer" badge for a partner promotion. The badge only opens the offer when `onPress` is set. */ offer?: {onPress?: () => void}; /** Shows a spinner instead of "+" while the data the connection flow needs is still loading */ diff --git a/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts index 98c97d23cf53..f89d46b7896d 100644 --- a/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts +++ b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts @@ -1,5 +1,5 @@ /** - * Builds the Travel & Delivery listings (receipt partners such as Uber for Business) for the Connections page. + * Builds the Receipts listings (receipt partners such as Uber for Business) for the Connections page. */ import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; import useLocalize from '@hooks/useLocalize'; From ef87203d08f0eb60250eb0d49dd105674e96f231 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Tue, 6 Oct 2026 19:49:02 +0530 Subject: [PATCH 41/51] Remove the Campfire beta check from Connections now that main removed the beta Signed-off-by: krishna2323 --- src/pages/workspace/connections/ConnectionsAccountingPage.tsx | 4 ---- .../workspace/connections/useAccountingConnectionListings.ts | 4 ---- 2 files changed, 8 deletions(-) diff --git a/src/pages/workspace/connections/ConnectionsAccountingPage.tsx b/src/pages/workspace/connections/ConnectionsAccountingPage.tsx index 495204e0e5c4..1a9d69d7a8fc 100644 --- a/src/pages/workspace/connections/ConnectionsAccountingPage.tsx +++ b/src/pages/workspace/connections/ConnectionsAccountingPage.tsx @@ -112,12 +112,8 @@ function ConnectionsAccountingPage({policy}: PolicyAccountingPageProps) { const [cardLists] = useCardsLists(); const connectionSyncStage = connectionSyncProgress?.stageInProgress; - const canUseCampfireIntegration = isBetaEnabled(CONST.BETAS.CAMPFIRE) || !!policy?.connections?.campfire; const canUseBusinessCentralIntegration = isBetaEnabled(CONST.BETAS.BUSINESS_CENTRAL) || !!policy?.connections?.businessCentral; const accountingIntegrations = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES.filter((name) => { - if (name === CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE) { - return canUseCampfireIntegration; - } if (name === CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL) { return canUseBusinessCentralIntegration; } diff --git a/src/pages/workspace/connections/useAccountingConnectionListings.ts b/src/pages/workspace/connections/useAccountingConnectionListings.ts index e1d75fb5ae11..28b587f985c6 100644 --- a/src/pages/workspace/connections/useAccountingConnectionListings.ts +++ b/src/pages/workspace/connections/useAccountingConnectionListings.ts @@ -59,12 +59,8 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL return []; } - const canUseCampfireIntegration = isBetaEnabled(CONST.BETAS.CAMPFIRE) || !!policy?.connections?.campfire; const canUseBusinessCentralIntegration = isBetaEnabled(CONST.BETAS.BUSINESS_CENTRAL) || !!policy?.connections?.businessCentral; const accountingIntegrations = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES.filter((name) => { - if (name === CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE) { - return canUseCampfireIntegration; - } if (name === CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL) { return canUseBusinessCentralIntegration; } From 4ebd9e14fcfefbe013cca8a84f19ecfd216857dc Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 8 Oct 2026 13:33:44 +0530 Subject: [PATCH 42/51] Turn on connections before the Control upgrade so NetSuite can connect from Collect Signed-off-by: krishna2323 --- .../workspace/accounting/AccountingContext/index.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/pages/workspace/accounting/AccountingContext/index.tsx b/src/pages/workspace/accounting/AccountingContext/index.tsx index c7a91e4410b4..ad3d22940129 100644 --- a/src/pages/workspace/accounting/AccountingContext/index.tsx +++ b/src/pages/workspace/accounting/AccountingContext/index.tsx @@ -158,6 +158,11 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider isUnifiedConnectionsBetaEnabled, ); + // Connections has no feature toggle, so turn it on here. The Control upgrade only enables the integration's own feature. + if (isUnifiedConnectionsBetaEnabled && !policy?.areConnectionsEnabled) { + enablePolicyConnections(policyID, true, false); + } + const workspaceUpgradeNavigationDetails = accountingIntegrationData?.workspaceUpgradeNavigationDetails; if (workspaceUpgradeNavigationDetails && !isControlPolicy(policy)) { Navigation.navigate( @@ -165,10 +170,6 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider ); return; } - // Connections has no feature toggle, so the feature is turned on once the plan allows the integration - if (isUnifiedConnectionsBetaEnabled && !policy?.areConnectionsEnabled) { - enablePolicyConnections(policyID, true, false); - } setActiveIntegration({ ...newActiveIntegration, key: Math.random(), From c333a7dbbfb079cdabde0ed95e2d6df1642108c3 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Thu, 8 Oct 2026 13:39:15 +0530 Subject: [PATCH 43/51] Restore the receipt partners route params lost in the main merge Signed-off-by: krishna2323 --- src/libs/Navigation/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 2b65f559ede8..713d889b3280 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -833,6 +833,8 @@ type SettingsNavigatorParamList = { policyID: string; }; [SCREENS.WORKSPACE.CONNECTIONS_RECEIPT_PARTNERS]: { + policyID: string; + }; [SCREENS.WORKSPACE.DISTANCE_RATES_WORK_ARRANGEMENT]: { policyID: string; }; From a85c32b929bbb2b109f3439ba7b263c8643b7949 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 9 Oct 2026 00:25:34 +0530 Subject: [PATCH 44/51] Keep the Connections panels on Back and hide off-screen tooltips Signed-off-by: krishna2323 --- src/ROUTES.ts | 24 +++++++++++++++++-- .../BaseEducationalTooltip.tsx | 9 +++++-- src/pages/workspace/WorkspaceInitialPage.tsx | 8 ++++++- .../certinia/CertiniaCompanySelectorPage.tsx | 5 ++-- .../advanced/CertiniaAdvancedPage.tsx | 5 ++-- .../certinia/export/CertiniaExportPage.tsx | 17 ++++++------- .../advanced/SageIntacctAdvancedPage.tsx | 2 +- .../export/DynamicSageIntacctExportPage.tsx | 5 +--- ...eIntacctTravelBillingConfigurationPage.tsx | 5 ++-- .../QuickbooksDesktopAutoSyncPageBase.tsx | 7 +++--- .../DynamicQuickbooksDesktopExportPage.tsx | 17 +++---------- ...sDesktopTravelBillingConfigurationPage.tsx | 5 ++-- .../import/QuickbooksDesktopImportPage.tsx | 2 +- src/pages/workspace/accounting/utils.tsx | 14 +++++------ .../XeroOrganizationConfigurationPage.tsx | 3 +-- .../DynamicXeroBankAccountSelectPage.tsx | 10 ++++---- .../DynamicXeroExportConfigurationPage.tsx | 5 ++-- ...onReimbursableDefaultContactSelectPage.tsx | 10 ++++---- ...DynamicXeroPreferredExporterSelectPage.tsx | 9 +++---- .../DynamicXeroPurchaseBillDateSelectPage.tsx | 10 ++++---- ...amicXeroPurchaseBillStatusSelectorPage.tsx | 10 ++++---- ...amicXeroTravelBillingConfigurationPage.tsx | 5 ++-- .../hr/gusto/GustoFinalApproverPage.tsx | 2 -- .../hr/merge/MergeHRFinalApproverPage.tsx | 2 -- .../hr/zenefits/ZenefitsFinalApproverPage.tsx | 2 -- .../merge/MergeFinalApproverPageBase.tsx | 2 +- 26 files changed, 103 insertions(+), 92 deletions(-) diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 69d1dd09d4cc..4abd51b5ac88 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1312,13 +1312,33 @@ const DYNAMIC_ROUTES = { path: 'hr-sync-results', // The results screen opens automatically when an HR sync finishes, and a sync can complete // while the user is on the HR page, the Connections page or its HR panel, or the members list. - entryScreens: [SCREENS.WORKSPACE.HR, SCREENS.WORKSPACE.CONNECTIONS, SCREENS.WORKSPACE.CONNECTIONS_HR, SCREENS.WORKSPACE.MEMBERS], + // With the unified connections beta, the Connections page also renders on the old integration routes. + entryScreens: [ + SCREENS.WORKSPACE.HR, + SCREENS.WORKSPACE.CONNECTIONS, + SCREENS.WORKSPACE.CONNECTIONS_HR, + SCREENS.WORKSPACE.MEMBERS, + SCREENS.WORKSPACE.ACCOUNTING.ROOT, + SCREENS.WORKSPACE.RECRUITING, + SCREENS.WORKSPACE.RECEIPT_PARTNERS, + SCREENS.WORKSPACE.MCP, + ], }, WORKSPACE_RECRUITING_SYNC_RESULTS: { // The results screen opens automatically when a recruiting sync finishes, and a sync can complete // while the user is on the recruiting page, the Connections page or its recruiting panel, or the members list. + // With the unified connections beta, the Connections page also renders on the old integration routes. path: 'recruiting-sync-results', - entryScreens: [SCREENS.WORKSPACE.RECRUITING, SCREENS.WORKSPACE.CONNECTIONS, SCREENS.WORKSPACE.CONNECTIONS_RECRUITING, SCREENS.WORKSPACE.MEMBERS], + entryScreens: [ + SCREENS.WORKSPACE.RECRUITING, + SCREENS.WORKSPACE.CONNECTIONS, + SCREENS.WORKSPACE.CONNECTIONS_RECRUITING, + SCREENS.WORKSPACE.MEMBERS, + SCREENS.WORKSPACE.ACCOUNTING.ROOT, + SCREENS.WORKSPACE.HR, + SCREENS.WORKSPACE.RECEIPT_PARTNERS, + SCREENS.WORKSPACE.MCP, + ], }, WORKSPACE_OWNER_CHANGE_CHECK: { path: 'change-owner/:policyID/:accountID/:error', diff --git a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx index b19a42c8dbff..8e59b37ca5e5 100644 --- a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx +++ b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx @@ -161,7 +161,12 @@ function BaseEducationalTooltip({ } // When tooltip is used inside an animated view (e.g. popover), we need to wait for the animation to finish before measuring content. const timerID = setTimeout(() => { - show.current?.(); + // An anchor that starts off screen, like a menu item under the bottom tab bar in landscape, must stay hidden. + if (shouldHideOnScroll) { + renderTooltip(); + } else { + show.current?.(); + } // Mark the first display as done only once it has actually happened, so paths that re-measure // on a later layout (e.g. rotation) don't fire against a still-animating layout beforehand. hasDisplayedTooltipRef.current = true; @@ -169,7 +174,7 @@ function BaseEducationalTooltip({ return () => { clearTimeout(timerID); }; - }, [shouldMeasure, shouldRender, shouldShowTooltip, shouldSuppressTooltip]); + }, [shouldMeasure, shouldRender, shouldShowTooltip, shouldSuppressTooltip, shouldHideOnScroll, renderTooltip]); useEffect(() => { if (!shouldRender || !shouldShowTooltip || shouldSuppressTooltip || !shouldMeasure) { diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 58b6628997cd..aeb573832ed0 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -21,6 +21,7 @@ import usePermissions from '@hooks/usePermissions'; import usePolicyConnectionsPrefetch from '@hooks/usePolicyConnectionsPrefetch'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useScrollEventEmitter from '@hooks/useScrollEventEmitter'; import useSingleExecution from '@hooks/useSingleExecution'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -207,6 +208,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac ...item, action: singleExecution(waitForNavigate(() => Navigation.navigate(item.getRoute()))), })); + const triggerScrollEvent = useScrollEventEmitter(); const {shouldShowProductTrainingTooltip: shouldShowConnectionsTooltip, hideProductTrainingTooltip: hideConnectionsTooltip} = useProductTrainingContext( CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.CONNECTIONS_MOVED, isWorkspacesTabFocused && !hasPolicyCreationError && workspaceMenuItems.some((item) => item.screenName === SCREENS.WORKSPACE.CONNECTIONS), @@ -294,7 +296,11 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac shouldDisplayAccountButton /> - + dismissError(policyID, policy?.pendingAction)} diff --git a/src/pages/workspace/accounting/certinia/CertiniaCompanySelectorPage.tsx b/src/pages/workspace/accounting/certinia/CertiniaCompanySelectorPage.tsx index 404e697f179a..ae58a49cbe3e 100644 --- a/src/pages/workspace/accounting/certinia/CertiniaCompanySelectorPage.tsx +++ b/src/pages/workspace/accounting/certinia/CertiniaCompanySelectorPage.tsx @@ -17,7 +17,6 @@ import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; import React from 'react'; @@ -58,7 +57,7 @@ function CertiniaCompanySelectorPage({policy}: WithPolicyConnectionsProps) { if (row.value !== companyID && policyID) { updateFinancialForceCompany(policyID, row.value, companyID ?? null, !!config?.hasPSA); } - Navigation.goBack(policyID ? ROUTES.POLICY_ACCOUNTING.getRoute(policyID) : undefined); + Navigation.goBack(); }; return ( @@ -72,7 +71,7 @@ function CertiniaCompanySelectorPage({policy}: WithPolicyConnectionsProps) { onSelectRow={selectCompany} shouldSingleExecuteRowSelect initiallyFocusedOptionKey={companyID} - onBackButtonPress={() => Navigation.goBack(policyID ? ROUTES.POLICY_ACCOUNTING.getRoute(policyID) : undefined)} + onBackButtonPress={() => Navigation.goBack()} title="workspace.certinia.company" listEmptyContent={listEmptyContent} connectionName={CONST.POLICY.CONNECTIONS.NAME.CERTINIA} diff --git a/src/pages/workspace/accounting/certinia/advanced/CertiniaAdvancedPage.tsx b/src/pages/workspace/accounting/certinia/advanced/CertiniaAdvancedPage.tsx index 06bf6cbc6cb8..10337baa3a6b 100644 --- a/src/pages/workspace/accounting/certinia/advanced/CertiniaAdvancedPage.tsx +++ b/src/pages/workspace/accounting/certinia/advanced/CertiniaAdvancedPage.tsx @@ -26,7 +26,7 @@ import { } from '@userActions/connections/FinancialForce'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import React from 'react'; @@ -39,7 +39,6 @@ function CertiniaAdvancedPage({policy}: WithPolicyConnectionsProps) { const advancedConfig = config?.advanced; const isPSA = !!config?.hasPSA; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_ADVANCED.path); - const advancedPath = policyID ? `${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_ADVANCED.path}` : undefined; const selectedFxExpenseAccountName = data?.expenseAccounts?.find(({id}) => id === config?.fxExpenseAccount)?.name; return ( @@ -134,7 +133,7 @@ function CertiniaAdvancedPage({policy}: WithPolicyConnectionsProps) { title={selectedFxExpenseAccountName} description={translate('workspace.certinia.fxExpenseAccount')} wrapperStyle={[styles.ph5, styles.pv3]} - onPress={!advancedPath ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_FX_EXPENSE_ACCOUNT.path, advancedPath))} + onPress={!policyID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_FX_EXPENSE_ACCOUNT.path))} brickRoadIndicator={areSettingsInErrorFields([CONST.CERTINIA_CONFIG.FX_EXPENSE_ACCOUNT], config?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} /> diff --git a/src/pages/workspace/accounting/certinia/export/CertiniaExportPage.tsx b/src/pages/workspace/accounting/certinia/export/CertiniaExportPage.tsx index ab2737c99c6a..aca9fef4a834 100644 --- a/src/pages/workspace/accounting/certinia/export/CertiniaExportPage.tsx +++ b/src/pages/workspace/accounting/certinia/export/CertiniaExportPage.tsx @@ -2,6 +2,7 @@ import ConnectionLayout from '@components/ConnectionLayout'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -14,7 +15,7 @@ import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnec import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import React from 'react'; @@ -35,7 +36,7 @@ function CertiniaExportPage({policy}: WithPolicyConnectionsProps) { const {config, data} = policy?.connections?.financialforce ?? {}; const exportConfig = config?.export; const hasPSA = !!config?.hasPSA; - const exportPath = policyID ? `${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_EXPORT.path}` : undefined; + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_EXPORT.path); // Skip the lookup when `vendorAccount` is empty (its seeded default) so we don't try to match an empty id against the synced vendor list. const selectedVendor = exportConfig?.vendorAccount ? data?.vendors?.find((vendor) => vendor.id === exportConfig?.vendorAccount) : undefined; // Only show "None" when no vendorAccount is configured. When a vendorAccount id is set but the synced vendor list @@ -51,7 +52,7 @@ function CertiniaExportPage({policy}: WithPolicyConnectionsProps) { // We use the logical OR (||) here instead of ?? because `exporter` could be an empty string on a fresh connection // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing title: exportConfig?.exporter || policyOwner, - onPress: !exportPath ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_PREFERRED_EXPORTER.path, exportPath)), + onPress: !policyID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_PREFERRED_EXPORTER.path)), subscribedSettings: [CONST.CERTINIA_CONFIG.EXPORTER], }; @@ -60,7 +61,7 @@ function CertiniaExportPage({policy}: WithPolicyConnectionsProps) { { description: translate('workspace.certinia.reportExportStatus.label'), title: normalizedReportExportStatus ? translate(`workspace.certinia.reportExportStatus.values.${normalizedReportExportStatus}`) : exportStatus, - onPress: !exportPath ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_REPORT_EXPORT_STATUS.path, exportPath)), + onPress: !policyID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_REPORT_EXPORT_STATUS.path)), subscribedSettings: [CONST.CERTINIA_CONFIG.EXPORT_STATUS], }, { @@ -84,13 +85,13 @@ function CertiniaExportPage({policy}: WithPolicyConnectionsProps) { { description: translate('workspace.certinia.exportStatus.label'), title: normalizedFFAExportStatus ? translate(`workspace.certinia.exportStatus.values.${normalizedFFAExportStatus}`) : undefined, - onPress: !exportPath ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_EXPORT_STATUS.path, exportPath)), + onPress: !policyID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_EXPORT_STATUS.path)), subscribedSettings: [CONST.CERTINIA_CONFIG.EXPORT_STATUS], }, { description: translate('workspace.certinia.exportDate.label'), title: exportDate ? translate(`workspace.certinia.exportDate.values.${exportDate}`) : undefined, - onPress: !exportPath ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_EXPORT_DATE.path, exportPath)), + onPress: !policyID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_EXPORT_DATE.path)), subscribedSettings: [CONST.CERTINIA_CONFIG.EXPORT_DATE], }, { @@ -109,7 +110,7 @@ function CertiniaExportPage({policy}: WithPolicyConnectionsProps) { { description: translate('workspace.accounting.defaultVendor'), title: defaultVendorTitle, - onPress: !exportPath ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_DEFAULT_VENDOR.path, exportPath)), + onPress: !policyID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_DEFAULT_VENDOR.path)), subscribedSettings: [CONST.CERTINIA_CONFIG.VENDOR_ACCOUNT], }, ]; @@ -123,7 +124,7 @@ function CertiniaExportPage({policy}: WithPolicyConnectionsProps) { title="workspace.certinia.exportDescription" accessVariants={[CONST.POLICY.ACCESS_VARIANTS.ADMIN, CONST.POLICY.ACCESS_VARIANTS.PAID]} policyID={policyID} - onBackButtonPress={() => Navigation.goBack(policyID ? ROUTES.POLICY_ACCOUNTING.getRoute(policyID) : undefined)} + onBackButtonPress={() => Navigation.goBack(backPath)} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} contentContainerStyle={styles.pb2} titleStyle={styles.ph5} diff --git a/src/pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage.tsx b/src/pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage.tsx index 85c9bea93c6d..61e9cc8b3939 100644 --- a/src/pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage.tsx +++ b/src/pages/workspace/accounting/intacct/advanced/SageIntacctAdvancedPage.tsx @@ -108,7 +108,7 @@ function SageIntacctAdvancedPage({policy}: WithPolicyProps) { contentContainerStyle={styles.pb2} titleStyle={styles.ph5} connectionName={CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT} - onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING.getRoute(policyID))} + onBackButtonPress={() => Navigation.goBack()} > Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_AUTO_SYNC.path)))}> diff --git a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctExportPage.tsx b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctExportPage.tsx index db311a9197f4..5a84b0ac259c 100644 --- a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctExportPage.tsx +++ b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctExportPage.tsx @@ -30,7 +30,6 @@ function DynamicSageIntacctExportPage({policy}: WithPolicyProps) { const policyID = policy?.id; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT.path); const {export: exportConfig, pendingFields, errorFields} = policy?.connections?.intacct?.config ?? {}; - const exportPath = policyID ? `${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT.path}` : undefined; const travelPayableAccount = policy?.connections?.intacct?.data?.creditCards?.find((account) => account.name === exportConfig?.travelInvoicingPayableAccountID); const workspaceAccountID = useWorkspaceAccountID(policyID); @@ -62,9 +61,7 @@ function DynamicSageIntacctExportPage({policy}: WithPolicyProps) { { title: travelPayableAccount?.name, description: translate('workspace.common.travelInvoicing'), - action: !exportPath - ? undefined - : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_TRAVEL_BILLING_CONFIGURATION.path, exportPath)), + action: !policyID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_TRAVEL_BILLING_CONFIGURATION.path)), subscribedSettings: [CONST.SAGE_INTACCT_CONFIG.TRAVEL_BILLING_PAYABLE_ACCOUNT], }, ] diff --git a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctTravelBillingConfigurationPage.tsx b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctTravelBillingConfigurationPage.tsx index cc133d878886..ed745bb6cbfb 100644 --- a/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctTravelBillingConfigurationPage.tsx +++ b/src/pages/workspace/accounting/intacct/export/DynamicSageIntacctTravelBillingConfigurationPage.tsx @@ -16,7 +16,7 @@ import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnec import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import React from 'react'; @@ -30,7 +30,6 @@ function DynamicSageIntacctTravelBillingConfigurationPage({policy}: WithPolicyCo const config = policy?.connections?.intacct?.config; const travelPayableAccount = policy?.connections?.intacct?.data?.creditCards?.find((account) => account.name === config?.export?.travelInvoicingPayableAccountID); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_TRAVEL_BILLING_CONFIGURATION.path); - const travelBillingPath = `${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT.path}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_TRAVEL_BILLING_CONFIGURATION.path}`; return ( Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT.path, travelBillingPath))} + onPress={() => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT.path))} value={travelPayableAccount?.name} > {areSettingsInErrorFields(payableAccountSetting, config?.errorFields) && } diff --git a/src/pages/workspace/accounting/qbd/advanced/QuickbooksDesktopAutoSyncPageBase.tsx b/src/pages/workspace/accounting/qbd/advanced/QuickbooksDesktopAutoSyncPageBase.tsx index 9e8c47aabc2d..07c1db53600a 100644 --- a/src/pages/workspace/accounting/qbd/advanced/QuickbooksDesktopAutoSyncPageBase.tsx +++ b/src/pages/workspace/accounting/qbd/advanced/QuickbooksDesktopAutoSyncPageBase.tsx @@ -7,7 +7,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {updateQuickbooksDesktopAutoSync} from '@libs/actions/connections/QuickbooksDesktop'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {settingsPendingAction} from '@libs/PolicyUtils'; @@ -17,7 +16,7 @@ import {clearQBDErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type {Policy} from '@src/types/onyx'; @@ -42,8 +41,8 @@ function QuickbooksDesktopAutoSyncPageBase({policy, navigateBackTo}: QuickbooksD settingsPendingAction([CONST.QUICKBOOKS_DESKTOP_CONFIG.AUTO_SYNC], pendingFields) ?? settingsPendingAction([CONST.QUICKBOOKS_DESKTOP_CONFIG.ACCOUNTING_METHOD], pendingFields); const goBack = useCallback(() => { - Navigation.goBack(navigateBackTo ?? createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_DESKTOP_ADVANCED.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID))); - }, [policyID, navigateBackTo]); + Navigation.goBack(navigateBackTo); + }, [navigateBackTo]); return ( account.id === qbdConfig?.export?.travelInvoicingPayableAccountID); - const exportPath = policyID ? `${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT.path}` : undefined; const workspaceAccountID = useWorkspaceAccountID(policyID); const [cardSettings] = useOnyx(getTravelBillingCardSettingsKey(workspaceAccountID)); @@ -85,15 +84,7 @@ function DynamicQuickbooksDesktopExportPage({policy}: WithPolicyConnectionsProps }, { description: translate('workspace.accounting.exportCompanyCard'), - onPress: !policyID - ? undefined - : () => - Navigation.navigate( - createDynamicRoute( - DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT.path, - `${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT.path}`, - ), - ), + onPress: !policyID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_COMPANY_CARD_EXPENSE_ACCOUNT.path)), brickRoadIndicator: qbdConfig?.errorFields?.exportCompanyCard ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, title: qbdConfig?.export?.nonReimbursable ? translate(`workspace.qbd.accounts.${qbdConfig?.export?.nonReimbursable}`) : undefined, subscribedSettings: [ @@ -107,9 +98,7 @@ function DynamicQuickbooksDesktopExportPage({policy}: WithPolicyConnectionsProps ? [ { description: translate('workspace.common.travelInvoicing'), - onPress: !exportPath - ? undefined - : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_TRAVEL_BILLING_CONFIGURATION.path, exportPath)), + onPress: !policyID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_TRAVEL_BILLING_CONFIGURATION.path)), title: travelPayableAccount?.name, subscribedSettings: [CONST.QUICKBOOKS_DESKTOP_CONFIG.TRAVEL_BILLING_PAYABLE_ACCOUNT], }, diff --git a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopTravelBillingConfigurationPage.tsx b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopTravelBillingConfigurationPage.tsx index d72a07428655..bdc331d2407e 100644 --- a/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopTravelBillingConfigurationPage.tsx +++ b/src/pages/workspace/accounting/qbd/export/QuickbooksDesktopTravelBillingConfigurationPage.tsx @@ -16,7 +16,7 @@ import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnec import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {ValueOf} from 'type-fest'; @@ -44,13 +44,12 @@ function QuickbooksDesktopTravelBillingConfigurationPage({policy}: WithPolicyCon const {payableAccounts} = policy?.connections?.quickbooksDesktop?.data ?? {}; const travelPayableAccount = payableAccounts?.find((item) => item.id === qbdConfig?.export?.travelInvoicingPayableAccountID); const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_TRAVEL_BILLING_CONFIGURATION.path); - const travelBillingPath = `${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT.path}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_TRAVEL_BILLING_CONFIGURATION.path}`; const sections: QBDSection[] = [ { title: travelPayableAccount?.name, description: translate('workspace.common.travelInvoicingPayableAccount'), - onPress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT.path, travelBillingPath)), + onPress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT.path)), subscribedSettings: payableAccount, pendingAction: settingsPendingAction(payableAccount, qbdConfig?.pendingFields), brickRoadIndicator: areSettingsInErrorFields(payableAccount, qbdConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, diff --git a/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopImportPage.tsx b/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopImportPage.tsx index 08eb489effe2..080d2da301b3 100644 --- a/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopImportPage.tsx +++ b/src/pages/workspace/accounting/qbd/import/QuickbooksDesktopImportPage.tsx @@ -68,7 +68,7 @@ function QuickbooksDesktopImportPage({policy}: WithPolicyProps) { contentContainerStyle={styles.pb2} titleStyle={styles.ph5} connectionName={CONST.POLICY.CONNECTIONS.NAME.QBD} - onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING.getRoute(policyID))} + onBackButtonPress={() => Navigation.goBack()} > {sections.map((section) => ( Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT.path, basePath)), + onExportPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_ONLINE_EXPORT.path)), subscribedExportSettings: [ CONST.QUICKBOOKS_CONFIG.EXPORT, CONST.QUICKBOOKS_CONFIG.EXPORT_DATE, @@ -244,7 +243,7 @@ function getAccountingIntegrationData( CONST.XERO_CONFIG.IMPORT_TAX_RATES, ...getTrackingCategories(policy).map((category) => `${CONST.XERO_CONFIG.TRACKING_CATEGORY_PREFIX}${category.id}`), ], - onExportPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path, basePath)), + onExportPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path)), subscribedExportSettings: [ CONST.XERO_CONFIG.EXPORTER, CONST.XERO_CONFIG.BILL_DATE, @@ -355,7 +354,7 @@ function getAccountingIntegrationData( CONST.SAGE_INTACCT_CONFIG.TAX, ...(policy?.connections?.intacct?.config?.mappings?.dimensions ?? []).map((dimension) => `${CONST.SAGE_INTACCT_CONFIG.DIMENSION_PREFIX}${dimension.dimension}`), ], - onExportPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID))), + onExportPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXPORT.path)), subscribedExportSettings: [ CONST.SAGE_INTACCT_CONFIG.EXPORTER, CONST.SAGE_INTACCT_CONFIG.EXPORT_DATE, @@ -396,8 +395,7 @@ function getAccountingIntegrationData( ), onImportPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_IMPORT.getRoute(policyID)), onExportPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_QUICKBOOKS_DESKTOP_EXPORT.path)), - onAdvancedPagePress: () => - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_DESKTOP_ADVANCED.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID))), + onAdvancedPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_DESKTOP_ADVANCED.path)), subscribedImportSettings: [ CONST.QUICKBOOKS_DESKTOP_CONFIG.ENABLE_NEW_CATEGORIES, CONST.QUICKBOOKS_DESKTOP_CONFIG.MAPPINGS.CLASSES, @@ -479,9 +477,9 @@ function getAccountingIntegrationData( ), onImportPagePress: () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CERTINIA_IMPORT.getRoute(policyID)), subscribedImportSettings: certiniaSubscribedImportSettings, - onExportPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_EXPORT.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID))), + onExportPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_EXPORT.path)), subscribedExportSettings: certiniaSubscribedExportSettings, - onAdvancedPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_ADVANCED.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID))), + onAdvancedPagePress: () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_CERTINIA_ADVANCED.path)), subscribedAdvancedSettings: certiniaSubscribedAdvancedSettings, onCardReconciliationPagePress: () => Navigation.navigate(ROUTES.WORKSPACE_ACCOUNTING_CARD_RECONCILIATION.getRoute(policyID, CONST.POLICY.CONNECTIONS.ROUTE.CERTINIA)), pendingFields: certiniaConfig?.pendingFields, diff --git a/src/pages/workspace/accounting/xero/XeroOrganizationConfigurationPage.tsx b/src/pages/workspace/accounting/xero/XeroOrganizationConfigurationPage.tsx index e8882f8f7082..08f598b635b9 100644 --- a/src/pages/workspace/accounting/xero/XeroOrganizationConfigurationPage.tsx +++ b/src/pages/workspace/accounting/xero/XeroOrganizationConfigurationPage.tsx @@ -22,7 +22,6 @@ import variables from '@styles/variables'; import {clearXeroErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import React, {useMemo} from 'react'; @@ -95,7 +94,7 @@ function XeroOrganizationConfigurationPage({ onSelectRow={saveSelection} initiallyFocusedOptionKey={currentXeroOrganization?.id} headerContent={listHeaderComponent} - onBackButtonPress={() => Navigation.goBack(ROUTES.POLICY_ACCOUNTING.getRoute(policyID))} + onBackButtonPress={() => Navigation.goBack()} title="workspace.xero.organization" listEmptyContent={listEmptyContent} pendingAction={xeroConfig?.pendingFields?.tenantID} diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroBankAccountSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroBankAccountSelectPage.tsx index 4a4b68448689..f90d18ff8d0e 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroBankAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroBankAccountSelectPage.tsx @@ -3,13 +3,13 @@ import type {SelectorType} from '@components/SelectionScreen'; import SelectionScreen from '@components/SelectionScreen'; import Text from '@components/Text'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearXeroErrorField} from '@libs/actions/Policy/Policy'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {getXeroBankAccounts, settingsPendingAction} from '@libs/PolicyUtils'; @@ -21,7 +21,7 @@ import variables from '@styles/variables'; import {updateXeroExportNonReimbursableAccount} from '@userActions/connections/Xero'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; @@ -38,9 +38,11 @@ function DynamicXeroBankAccountSelectPage({policy}: WithPolicyConnectionsProps) [config?.export?.nonReimbursableAccount, policy], ); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT_BANK_ACCOUNT_SELECT.path); + const goBack = useCallback(() => { - Navigation.goBack(policyID ? createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID)) : undefined); - }, [policyID]); + Navigation.goBack(backPath); + }, [backPath]); const listHeaderComponent = useMemo( () => ( diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx index 92940342da7c..7b819badc1ee 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroExportConfigurationPage.tsx @@ -18,7 +18,7 @@ import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnec import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import React, {useMemo} from 'react'; @@ -41,7 +41,6 @@ function DynamicXeroExportConfigurationPage({policy}: WithPolicyConnectionsProps // invalid the moment the new sync completes. const isVendorFeatureAvailable = isXeroVendorMatchingActive(policy); const defaultVendorName = getXeroSupplierByID(policy, defaultVendor)?.name ?? ''; - const exportPath = policyID ? `${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path}` : undefined; const workspaceAccountID = useWorkspaceAccountID(policyID); const [cardSettings] = useOnyx(getTravelBillingCardSettingsKey(workspaceAccountID)); const travelSettings = getCardSettings(cardSettings, CONST.TRAVEL.PROGRAM_TRAVEL_US); @@ -92,7 +91,7 @@ function DynamicXeroExportConfigurationPage({policy}: WithPolicyConnectionsProps { title: translate('workspace.xero.bankTransactions'), description: translate('workspace.common.travelInvoicing'), - onPress: !exportPath ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_TRAVEL_BILLING_CONFIGURATION.path, exportPath)), + onPress: !policyID ? undefined : () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_TRAVEL_BILLING_CONFIGURATION.path)), subscribedSettings: [CONST.XERO_CONFIG.TRAVEL_BILLING_PAYABLE_ACCOUNT], }, ] diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx index bf9a55ee0bc4..6986442cc594 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroNonReimbursableDefaultContactSelectPage.tsx @@ -3,6 +3,7 @@ import type {SelectorType} from '@components/SelectionScreen'; import SelectionScreen from '@components/SelectionScreen'; import Text from '@components/Text'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -10,7 +11,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {updateManyPolicyConnectionConfigs} from '@libs/actions/connections'; import {clearXeroErrorField} from '@libs/actions/Policy/Policy'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {getXeroSuppliers, isXeroVendorMatchingActive, settingsPendingAction, sortVendors} from '@libs/PolicyUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; @@ -21,7 +21,7 @@ import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; @@ -96,9 +96,11 @@ function DynamicXeroNonReimbursableDefaultContactSelectPage({policy}: WithPolicy [shouldShowClearOption, clearOption, filteredSupplierOptions], ); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_NON_REIMBURSABLE_DEFAULT_CONTACT_SELECT.path); + const goBack = useCallback(() => { - Navigation.goBack(policyID ? createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID)) : undefined); - }, [policyID]); + Navigation.goBack(backPath); + }, [backPath]); const selectSupplier = useCallback( ({value}: SelectorType) => { diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroPreferredExporterSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroPreferredExporterSelectPage.tsx index 834ad2af7603..2cc45f201673 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroPreferredExporterSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroPreferredExporterSelectPage.tsx @@ -3,12 +3,12 @@ import SelectionScreen from '@components/SelectionScreen'; import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearXeroErrorField} from '@libs/actions/Policy/Policy'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import {getAdminEmployees, isExpensifyTeam, settingsPendingAction} from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; @@ -19,7 +19,7 @@ import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import {updateXeroExportExporter} from '@userActions/connections/Xero'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import isEmpty from 'lodash/isEmpty'; import React, {useCallback, useMemo} from 'react'; @@ -38,9 +38,10 @@ function DynamicXeroPreferredExporterSelectPage({policy}: WithPolicyConnectionsP const {login: currentUserLogin} = useCurrentUserPersonalDetails(); const policyID = policy?.id; + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_PREFERRED_EXPORTER_SELECT.path); const goBack = useCallback(() => { - Navigation.goBack(policyID ? createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID)) : undefined); - }, [policyID]); + Navigation.goBack(backPath); + }, [backPath]); const data: CardListItem[] = useMemo(() => { if (!isEmpty(policyOwner) && isEmpty(exporters)) { diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillDateSelectPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillDateSelectPage.tsx index 91b75802f8fe..036381960fb0 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillDateSelectPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillDateSelectPage.tsx @@ -3,12 +3,12 @@ import SelectionScreen from '@components/SelectionScreen'; import type {SelectorType} from '@components/SelectionScreen'; import Text from '@components/Text'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearXeroErrorField} from '@libs/actions/Policy/Policy'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import {settingsPendingAction} from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; @@ -19,7 +19,7 @@ import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import {updateXeroExportBillDate} from '@userActions/connections/Xero'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {ValueOf} from 'type-fest'; @@ -43,9 +43,11 @@ function DynamicXeroPurchaseBillDateSelectPage({policy}: WithPolicyConnectionsPr isSelected: config?.export?.billDate === dateType, })); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT_PURCHASE_BILL_DATE_SELECT.path); + const goBack = useCallback(() => { - Navigation.goBack(policyID ? createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID)) : undefined); - }, [policyID]); + Navigation.goBack(backPath); + }, [backPath]); const headerContent = useMemo( () => ( diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillStatusSelectorPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillStatusSelectorPage.tsx index e898ed23f7d9..bdebc13610b9 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillStatusSelectorPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroPurchaseBillStatusSelectorPage.tsx @@ -3,12 +3,12 @@ import SelectionScreen from '@components/SelectionScreen'; import type {SelectorType} from '@components/SelectionScreen'; import Text from '@components/Text'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import {clearXeroErrorField} from '@libs/actions/Policy/Policy'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import {settingsPendingAction} from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; @@ -19,7 +19,7 @@ import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import {updateXeroExportBillStatus} from '@userActions/connections/Xero'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {ValueOf} from 'type-fest'; @@ -38,9 +38,11 @@ function DynamicXeroPurchaseBillStatusSelectorPage({policy}: WithPolicyConnectio const {config} = policy?.connections?.xero ?? {}; const invoiceStatus = config?.export?.billStatus?.purchase; + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_BILL_STATUS_SELECTOR.path); + const goBack = useCallback(() => { - Navigation.goBack(policyID ? createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path, ROUTES.POLICY_ACCOUNTING.getRoute(policyID)) : undefined); - }, [policyID]); + Navigation.goBack(backPath); + }, [backPath]); const data: MenuListItem[] = Object.values(CONST.XERO_CONFIG.INVOICE_STATUS).map((status) => ({ value: status, diff --git a/src/pages/workspace/accounting/xero/export/DynamicXeroTravelBillingConfigurationPage.tsx b/src/pages/workspace/accounting/xero/export/DynamicXeroTravelBillingConfigurationPage.tsx index 51185e7a303e..40056392baaf 100644 --- a/src/pages/workspace/accounting/xero/export/DynamicXeroTravelBillingConfigurationPage.tsx +++ b/src/pages/workspace/accounting/xero/export/DynamicXeroTravelBillingConfigurationPage.tsx @@ -18,7 +18,7 @@ import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnec import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; import React from 'react'; @@ -31,7 +31,6 @@ function DynamicXeroTravelBillingConfigurationPage({policy}: WithPolicyConnectio const policyID = policy?.id ?? String(CONST.DEFAULT_NUMBER_ID); const config = policy?.connections?.xero?.config; const backPath = useDynamicBackPath(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_TRAVEL_BILLING_CONFIGURATION.path); - const travelBillingPath = `${ROUTES.POLICY_ACCOUNTING.getRoute(policyID)}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_EXPORT.path}/${DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_TRAVEL_BILLING_CONFIGURATION.path}`; const {bankAccounts} = policy?.connections?.xero?.data ?? {}; const travelPayableAccount = bankAccounts?.find((account) => account.id === config?.export?.travelInvoicingPayableAccountID); @@ -68,7 +67,7 @@ function DynamicXeroTravelBillingConfigurationPage({policy}: WithPolicyConnectio if (!policyID) { return; } - Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT.path, travelBillingPath)); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_XERO_TRAVEL_BILLING_PAYABLE_ACCOUNT_SELECT.path)); }} value={travelPayableAccount?.name} > diff --git a/src/pages/workspace/hr/gusto/GustoFinalApproverPage.tsx b/src/pages/workspace/hr/gusto/GustoFinalApproverPage.tsx index 2125a3f61cb6..908fb0ab5831 100644 --- a/src/pages/workspace/hr/gusto/GustoFinalApproverPage.tsx +++ b/src/pages/workspace/hr/gusto/GustoFinalApproverPage.tsx @@ -9,7 +9,6 @@ import MergeFinalApproverPageBase from '@pages/workspace/merge/MergeFinalApprove import type {MergeFinalApproverProviderConfig} from '@pages/workspace/merge/MergeFinalApproverPageBase'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import React from 'react'; @@ -27,7 +26,6 @@ function GustoFinalApproverPage({ testID: 'GustoFinalApproverPage', isConnected: isGustoConnected, featureName: CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED, - backRoute: ROUTES.WORKSPACE_HR.getRoute(policyID), getCurrentFinalApprover: (policy) => policy?.connections?.gusto?.config?.finalApprover ?? null, getProviderName: () => translate('workspace.hr.gusto.title'), getHeaderTitle: () => translate('workspace.merge.finalApprover'), diff --git a/src/pages/workspace/hr/merge/MergeHRFinalApproverPage.tsx b/src/pages/workspace/hr/merge/MergeHRFinalApproverPage.tsx index 86d954f95301..307487c667b2 100644 --- a/src/pages/workspace/hr/merge/MergeHRFinalApproverPage.tsx +++ b/src/pages/workspace/hr/merge/MergeHRFinalApproverPage.tsx @@ -10,7 +10,6 @@ import MergeFinalApproverPageBase from '@pages/workspace/merge/MergeFinalApprove import type {MergeFinalApproverProviderConfig} from '@pages/workspace/merge/MergeFinalApproverPageBase'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import React from 'react'; @@ -28,7 +27,6 @@ function MergeHRFinalApproverPage({ testID: 'MergeHRFinalApproverPage', isConnected: (policy) => isMergeConnected(policy, CONST.POLICY.CONNECTIONS.NAME.MERGE_HR), featureName: CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED, - backRoute: ROUTES.WORKSPACE_HR.getRoute(policyID), getCurrentFinalApprover: (policy) => policy?.connections?.merge_hris?.config?.finalApprover ?? null, getProviderName: (policy) => getConnectedHRProvider(policy)?.displayName ?? CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.merge_hris, getHeaderTitle: (providerName) => translate('workspace.hr.providerFinalApprover', providerName), diff --git a/src/pages/workspace/hr/zenefits/ZenefitsFinalApproverPage.tsx b/src/pages/workspace/hr/zenefits/ZenefitsFinalApproverPage.tsx index 119effb66fff..9fa2076d6050 100644 --- a/src/pages/workspace/hr/zenefits/ZenefitsFinalApproverPage.tsx +++ b/src/pages/workspace/hr/zenefits/ZenefitsFinalApproverPage.tsx @@ -9,7 +9,6 @@ import MergeFinalApproverPageBase from '@pages/workspace/merge/MergeFinalApprove import type {MergeFinalApproverProviderConfig} from '@pages/workspace/merge/MergeFinalApproverPageBase'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import React from 'react'; @@ -27,7 +26,6 @@ function ZenefitsFinalApproverPage({ testID: 'ZenefitsFinalApproverPage', isConnected: isZenefitsConnected, featureName: CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED, - backRoute: ROUTES.WORKSPACE_HR.getRoute(policyID), getCurrentFinalApprover: (policy) => policy?.connections?.zenefits?.config?.finalApprover ?? null, getProviderName: () => translate('workspace.hr.zenefits.title'), getHeaderTitle: () => translate('workspace.merge.finalApprover'), diff --git a/src/pages/workspace/merge/MergeFinalApproverPageBase.tsx b/src/pages/workspace/merge/MergeFinalApproverPageBase.tsx index aa9768f0e070..77e0a198e50a 100644 --- a/src/pages/workspace/merge/MergeFinalApproverPageBase.tsx +++ b/src/pages/workspace/merge/MergeFinalApproverPageBase.tsx @@ -23,7 +23,7 @@ import React from 'react'; type MergeFinalApproverProviderConfig = { testID: string; featureName: PolicyFeatureName; - backRoute: Route; + backRoute?: Route; shouldBeBlocked?: boolean; isConnected: (policy: OnyxEntry) => boolean; getCurrentFinalApprover: (policy: OnyxEntry) => string | null; From eb206d8c4fc6936315b999b57a0e745ae97486b9 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 9 Oct 2026 01:15:41 +0530 Subject: [PATCH 45/51] Restore the MCP help link and show the Connections tooltip only on connected workspaces Signed-off-by: krishna2323 --- src/pages/workspace/WorkspaceInitialPage.tsx | 3 ++- .../connections/WorkspaceConnectionsPage.tsx | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index aeb573832ed0..244a3710749a 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -211,7 +211,8 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const triggerScrollEvent = useScrollEventEmitter(); const {shouldShowProductTrainingTooltip: shouldShowConnectionsTooltip, hideProductTrainingTooltip: hideConnectionsTooltip} = useProductTrainingContext( CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.CONNECTIONS_MOVED, - isWorkspacesTabFocused && !hasPolicyCreationError && workspaceMenuItems.some((item) => item.screenName === SCREENS.WORKSPACE.CONNECTIONS), + // New workspaces never used the old integration pages, so only point this out where something is already connected. + isWorkspacesTabFocused && !hasPolicyCreationError && !isEmptyObject(policy?.connections) && workspaceMenuItems.some((item) => item.screenName === SCREENS.WORKSPACE.CONNECTIONS), ); // Route the Connections tooltip was first shown on, so leaving that page counts as dismissing it const connectionsTooltipRouteRef = useRef(undefined); diff --git a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx index 80c5d09333b1..a052b0f6b96e 100644 --- a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx +++ b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx @@ -46,6 +46,7 @@ import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnec import variables from '@styles/variables'; +import {openExternalLink} from '@userActions/Link'; import {openPolicyReceiptPartnersPage} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; @@ -97,7 +98,7 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { const policyID = policy?.id; const styles = useThemeStyles(); const theme = useTheme(); - const icons = useMemoizedLazyExpensifyIcons(['ArrowRight']); + const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'QuestionMark']); const illustrations = useMemoizedLazyIllustrations(['EmptyShelves']); const {translate} = useLocalize(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); @@ -336,6 +337,20 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) {
)} + {!shouldShowEmptyState && selectedTab === CONST.TAB.CONNECTIONS.AI && ( + + + + {translate('workspace.mcp.helpPrompt')} openExternalLink(CONST.MCP_HELP_URL)}>{translate('workspace.mcp.helpLink')} + + + )} {!shouldShowEmptyState && ( Date: Fri, 9 Oct 2026 01:21:52 +0530 Subject: [PATCH 46/51] Keep the NetSuite tax and travel invoicing pages on Back Signed-off-by: krishna2323 --- ...SuiteProvincialTaxPostingAccountSelectPage.tsx | 15 +++------------ .../NetSuiteTaxPostingAccountSelectPage.tsx | 15 +++------------ .../NetSuiteTravelBillingConfigurationPage.tsx | 8 +++----- 3 files changed, 9 insertions(+), 29 deletions(-) diff --git a/src/pages/workspace/accounting/netsuite/export/NetSuiteProvincialTaxPostingAccountSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/NetSuiteProvincialTaxPostingAccountSelectPage.tsx index 9b157228e59e..fe2d222139df 100644 --- a/src/pages/workspace/accounting/netsuite/export/NetSuiteProvincialTaxPostingAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/NetSuiteProvincialTaxPostingAccountSelectPage.tsx @@ -8,7 +8,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {updateNetSuiteProvincialTaxPostingAccount} from '@libs/actions/connections/NetSuiteCommands'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {canUseProvincialTaxNetSuite, getNetSuiteTaxAccountOptions} from '@libs/NetSuiteUtils'; import {settingsPendingAction} from '@libs/PolicyUtils'; @@ -21,7 +20,6 @@ import variables from '@styles/variables'; import {clearNetSuiteErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import React, {useCallback, useMemo} from 'react'; @@ -31,13 +29,6 @@ function NetSuiteProvincialTaxPostingAccountSelectPage({policy}: WithPolicyConne const illustrations = useMemoizedLazyIllustrations(['Telescope']); const policyID = policy?.id; - const netSuiteExportBackPath = useMemo(() => { - if (!policyID) { - return ROUTES.HOME; - } - return createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.path, ROUTES.WORKSPACE_ACCOUNTING.getRoute(policyID)); - }, [policyID]); - const config = policy?.connections?.netsuite?.options.config; const {subsidiaryList} = policy?.connections?.netsuite?.options?.data ?? {}; const selectedSubsidiary = useMemo(() => (subsidiaryList ?? []).find((subsidiary) => subsidiary.internalID === config?.subsidiaryID), [subsidiaryList, config?.subsidiaryID]); @@ -54,9 +45,9 @@ function NetSuiteProvincialTaxPostingAccountSelectPage({policy}: WithPolicyConne if (config?.provincialTaxPostingAccount !== value && policyID) { updateNetSuiteProvincialTaxPostingAccount(policyID, value, config?.provincialTaxPostingAccount); } - Navigation.goBack(netSuiteExportBackPath); + Navigation.goBack(); }, - [netSuiteExportBackPath, policyID, config?.provincialTaxPostingAccount], + [policyID, config?.provincialTaxPostingAccount], ); const listEmptyContent = useMemo( @@ -82,7 +73,7 @@ function NetSuiteProvincialTaxPostingAccountSelectPage({policy}: WithPolicyConne data={netsuiteTaxAccountOptions} onSelectRow={updateTaxAccount} initiallyFocusedOptionKey={initiallyFocusedOptionKey} - onBackButtonPress={() => Navigation.goBack(netSuiteExportBackPath)} + onBackButtonPress={() => Navigation.goBack()} title="workspace.netsuite.journalEntriesProvTaxPostingAccount" listEmptyContent={listEmptyContent} connectionName={CONST.POLICY.CONNECTIONS.NAME.NETSUITE} diff --git a/src/pages/workspace/accounting/netsuite/export/NetSuiteTaxPostingAccountSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/NetSuiteTaxPostingAccountSelectPage.tsx index ed76e9c47c4c..7bd5c7732235 100644 --- a/src/pages/workspace/accounting/netsuite/export/NetSuiteTaxPostingAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/NetSuiteTaxPostingAccountSelectPage.tsx @@ -9,7 +9,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {updateNetSuiteTaxPostingAccount} from '@libs/actions/connections/NetSuiteCommands'; import {getLatestErrorField} from '@libs/ErrorUtils'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {canUseTaxNetSuite, getNetSuiteTaxAccountOptions} from '@libs/NetSuiteUtils'; import {settingsPendingAction} from '@libs/PolicyUtils'; @@ -22,7 +21,6 @@ import variables from '@styles/variables'; import {clearNetSuiteErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import React, {useCallback, useMemo} from 'react'; @@ -33,13 +31,6 @@ function NetSuiteTaxPostingAccountSelectPage({policy}: WithPolicyConnectionsProp const {isBetaEnabled} = usePermissions(); const policyID = policy?.id; - const netSuiteExportBackPath = useMemo(() => { - if (!policyID) { - return ROUTES.HOME; - } - return createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.path, ROUTES.WORKSPACE_ACCOUNTING.getRoute(policyID)); - }, [policyID]); - const config = policy?.connections?.netsuite?.options.config; const {subsidiaryList} = policy?.connections?.netsuite?.options?.data ?? {}; const selectedSubsidiary = useMemo(() => (subsidiaryList ?? []).find((subsidiary) => subsidiary.internalID === config?.subsidiaryID), [subsidiaryList, config?.subsidiaryID]); @@ -56,9 +47,9 @@ function NetSuiteTaxPostingAccountSelectPage({policy}: WithPolicyConnectionsProp if (config?.taxPostingAccount !== value && policyID) { updateNetSuiteTaxPostingAccount(policyID, value, config?.taxPostingAccount); } - Navigation.goBack(netSuiteExportBackPath); + Navigation.goBack(); }, - [netSuiteExportBackPath, policyID, config?.taxPostingAccount], + [policyID, config?.taxPostingAccount], ); const listEmptyContent = useMemo( @@ -84,7 +75,7 @@ function NetSuiteTaxPostingAccountSelectPage({policy}: WithPolicyConnectionsProp data={netsuiteTaxAccountOptions} onSelectRow={updateTaxAccount} initiallyFocusedOptionKey={initiallyFocusedOptionKey} - onBackButtonPress={() => Navigation.goBack(netSuiteExportBackPath)} + onBackButtonPress={() => Navigation.goBack()} title="workspace.netsuite.journalEntriesTaxPostingAccount" listEmptyContent={listEmptyContent} connectionName={CONST.POLICY.CONNECTIONS.NAME.NETSUITE} diff --git a/src/pages/workspace/accounting/netsuite/export/NetSuiteTravelBillingConfigurationPage.tsx b/src/pages/workspace/accounting/netsuite/export/NetSuiteTravelBillingConfigurationPage.tsx index 9fb6dbc2f079..889437e4134c 100644 --- a/src/pages/workspace/accounting/netsuite/export/NetSuiteTravelBillingConfigurationPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/NetSuiteTravelBillingConfigurationPage.tsx @@ -6,7 +6,6 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Parser from '@libs/Parser'; import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils'; @@ -17,12 +16,12 @@ import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnec import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import CONST from '@src/CONST'; -import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type {ValueOf} from 'type-fest'; -import React, {useMemo} from 'react'; +import React from 'react'; type NetSuiteSectionType = { title?: string; @@ -41,7 +40,6 @@ function NetSuiteTravelBillingConfigurationPage({policy}: WithPolicyConnectionsP const styles = useThemeStyles(); const policyID = policy?.id ?? String(CONST.DEFAULT_NUMBER_ID); - const netSuiteExportBackPath = useMemo(() => createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.path, ROUTES.WORKSPACE_ACCOUNTING.getRoute(policyID)), [policyID]); const config = policy?.connections?.netsuite?.options?.config; const {payableList} = policy?.connections?.netsuite?.options?.data ?? {}; @@ -88,7 +86,7 @@ function NetSuiteTravelBillingConfigurationPage({policy}: WithPolicyConnectionsP contentContainerStyle={styles.pb2} titleStyle={styles.ph5} connectionName={CONST.POLICY.CONNECTIONS.NAME.NETSUITE} - onBackButtonPress={() => Navigation.goBack(netSuiteExportBackPath)} + onBackButtonPress={() => Navigation.goBack()} > Date: Fri, 9 Oct 2026 01:25:19 +0530 Subject: [PATCH 47/51] Limit the first-display tooltip check to the workspace menu Signed-off-by: krishna2323 --- src/components/MenuItem/MenuItem.tsx | 5 +++++ .../BaseEducationalTooltip.tsx | 20 +++++++++++-------- src/components/Tooltip/types.ts | 3 +++ src/pages/workspace/WorkspaceInitialPage.tsx | 1 + 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/components/MenuItem/MenuItem.tsx b/src/components/MenuItem/MenuItem.tsx index 6418a1116630..21dc75bd995f 100644 --- a/src/components/MenuItem/MenuItem.tsx +++ b/src/components/MenuItem/MenuItem.tsx @@ -361,6 +361,9 @@ type MenuItemBaseProps = ForwardedFSClassProps & /** Whether the tooltip should hide on scroll */ shouldHideOnScroll?: boolean; + /** Whether the tooltip should stay hidden on first display when the item starts off screen */ + shouldCheckTooltipVisibilityOnFirstDisplay?: boolean; + /** Whether the tooltip should hide when the screen loses navigation focus */ shouldHideTooltipOnNavigate?: boolean; @@ -536,6 +539,7 @@ function MenuItem({ shouldRenderTooltip = false, shouldDisplayEducationalTooltip, shouldHideOnScroll = false, + shouldCheckTooltipVisibilityOnFirstDisplay = false, shouldHideTooltipOnNavigate = true, tooltipAnchorAlignment, tooltipWrapperStyle = {}, @@ -797,6 +801,7 @@ function MenuItem({ shouldTeleportPortalToModalLayer={shouldTeleportPortalToModalLayer} onTooltipPress={onEducationTooltipPress} shouldHideOnScroll={shouldHideOnScroll} + shouldCheckVisibilityOnFirstDisplay={shouldCheckTooltipVisibilityOnFirstDisplay} shouldHideOnNavigate={shouldHideTooltipOnNavigate} > diff --git a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx index 8e59b37ca5e5..2e7d4b889bce 100644 --- a/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx +++ b/src/components/Tooltip/EducationalTooltip/BaseEducationalTooltip.tsx @@ -12,7 +12,7 @@ import CONST from '@src/CONST'; import type {HostInstance, LayoutRectangle, NativeSyntheticEvent} from 'react-native'; import {NavigationContext, useIsFocused} from '@react-navigation/native'; -import React, {memo, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState} from 'react'; +import React, {memo, useCallback, useContext, useEffect, useEffectEvent, useLayoutEffect, useRef, useState} from 'react'; import {DeviceEventEmitter, Dimensions} from 'react-native'; import measureTooltipCoordinate, {getTooltipCoordinates} from './measureTooltipCoordinate'; @@ -29,6 +29,7 @@ function BaseEducationalTooltip({ shouldDisplayTooltip, shouldHideOnNavigate = true, shouldHideOnScroll = false, + shouldCheckVisibilityOnFirstDisplay = false, uniqueID, ...props }: EducationalTooltipProps) { @@ -151,6 +152,14 @@ function BaseEducationalTooltip({ }; }, []); + const showOnFirstDisplay = useEffectEvent(() => { + if (shouldCheckVisibilityOnFirstDisplay) { + renderTooltip(); + return; + } + show.current?.(); + }); + useEffect(() => { if (!shouldMeasure || shouldSuppressTooltip || !shouldShowTooltip) { return; @@ -161,12 +170,7 @@ function BaseEducationalTooltip({ } // When tooltip is used inside an animated view (e.g. popover), we need to wait for the animation to finish before measuring content. const timerID = setTimeout(() => { - // An anchor that starts off screen, like a menu item under the bottom tab bar in landscape, must stay hidden. - if (shouldHideOnScroll) { - renderTooltip(); - } else { - show.current?.(); - } + showOnFirstDisplay(); // Mark the first display as done only once it has actually happened, so paths that re-measure // on a later layout (e.g. rotation) don't fire against a still-animating layout beforehand. hasDisplayedTooltipRef.current = true; @@ -174,7 +178,7 @@ function BaseEducationalTooltip({ return () => { clearTimeout(timerID); }; - }, [shouldMeasure, shouldRender, shouldShowTooltip, shouldSuppressTooltip, shouldHideOnScroll, renderTooltip]); + }, [shouldMeasure, shouldRender, shouldShowTooltip, shouldSuppressTooltip]); useEffect(() => { if (!shouldRender || !shouldShowTooltip || shouldSuppressTooltip || !shouldMeasure) { diff --git a/src/components/Tooltip/types.ts b/src/components/Tooltip/types.ts index b7a758d207ff..a0eeceae473c 100644 --- a/src/components/Tooltip/types.ts +++ b/src/components/Tooltip/types.ts @@ -95,6 +95,9 @@ type EducationalTooltipProps = ChildrenProps & /** Whether the tooltip should hide during scrolling */ shouldHideOnScroll?: boolean; + /** Whether the first display should stay hidden when the anchor starts off screen. Only applies with `shouldHideOnScroll`. */ + shouldCheckVisibilityOnFirstDisplay?: boolean; + uniqueID?: string; }; diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 244a3710749a..fc10cc4f0bd8 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -353,6 +353,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac // The workspace menu is the sidebar, which isn't the navigation-focused screen on wide layouts. shouldHideTooltipOnNavigate={false} shouldHideOnScroll={shouldUseNarrowLayout} + shouldCheckTooltipVisibilityOnFirstDisplay={shouldUseNarrowLayout} /> ); })} From d0abd51c4fec919b316d46de5f1a7d21ebfc9803 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 9 Oct 2026 22:57:22 +0530 Subject: [PATCH 48/51] Fix Back fallbacks, Uber card errors and panel gating on Connections Signed-off-by: krishna2323 --- .../TabSelector/TabSelectorScrollFade.tsx | 4 ++-- .../useIsUnifiedConnectionsBetaEnabled.ts | 11 +++++++++ src/languages/de.ts | 6 ++--- src/languages/el.ts | 6 ++--- src/languages/en.ts | 6 ++--- src/languages/es.ts | 6 ++--- src/languages/fr.ts | 6 ++--- src/languages/it.ts | 6 ++--- src/languages/ja.ts | 6 ++--- src/languages/nl.ts | 6 ++--- src/languages/pl.ts | 6 ++--- src/languages/pt-BR.ts | 6 ++--- src/languages/zh-hans.ts | 6 ++--- ...eProvincialTaxPostingAccountSelectPage.tsx | 15 +++++++++--- .../NetSuiteTaxPostingAccountSelectPage.tsx | 15 +++++++++--- ...NetSuiteTravelBillingConfigurationPage.tsx | 9 +++++-- .../QuickbooksDesktopAutoSyncPageBase.tsx | 15 +++++++++--- .../connections/ConnectionsAccountingPage.tsx | 3 --- .../connections/ConnectionsMergePageBase.tsx | 13 ++-------- .../ConnectionsReceiptPartnersPage.tsx | 10 +++----- .../connections/WorkspaceConnectionsPage.tsx | 23 +++++++++++------- .../useReceiptPartnerConnectionListings.ts | 24 ++++++++++++++----- src/pages/workspace/connections/utils.ts | 10 +++++++- ...kspaceConnectionsPageAutoStartFlowTest.tsx | 1 + 24 files changed, 126 insertions(+), 93 deletions(-) create mode 100644 src/hooks/useIsUnifiedConnectionsBetaEnabled.ts diff --git a/src/components/TabSelector/TabSelectorScrollFade.tsx b/src/components/TabSelector/TabSelectorScrollFade.tsx index ee8148333c53..fb7455a8c124 100644 --- a/src/components/TabSelector/TabSelectorScrollFade.tsx +++ b/src/components/TabSelector/TabSelectorScrollFade.tsx @@ -4,7 +4,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; -import React from 'react'; +import React, {useId} from 'react'; import {View} from 'react-native'; import Svg, {Defs, LinearGradient, Rect, Stop} from 'react-native-svg'; @@ -18,7 +18,7 @@ function TabSelectorScrollFade({side}: TabSelectorScrollFadeProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const gradientID = `tabSelectorScrollFade-${side}`; + const gradientID = useId(); return ( `${providerName} (HRIS)`, atsListing: (providerName: string) => `${providerName} (ATS)`, suggestIntegration: 'Sie sehen Ihre nicht? Schlagen Sie eine Integration vor, wir schauen sie uns an.', - noResultsPrompt: 'Bitte passen Sie Ihre Suche an oder', - suggestAnIntegration: 'Integration vorschlagen', - noResultsPromptEnd: '.', + noResultsPrompt: 'Bitte passen Sie Ihre Suche an oder schlagen Sie eine Integration vor.', allConnectedTitle: 'Sie sind vollständig verbunden', - allConnectedPrompt: 'Hier ist bereits alles verbunden. Sie können auch', + allConnectedPrompt: 'Hier ist bereits alles verbunden. Sie können auch eine Integration vorschlagen.', }, receiptPartners: { uber: { diff --git a/src/languages/el.ts b/src/languages/el.ts index f6582a24db03..f484f2f08317 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -5083,11 +5083,9 @@ ${amount} για ${merchant} - ${date}`, hrisListing: (providerName: string) => `${providerName} (HRIS)`, atsListing: (providerName: string) => `${providerName} (ATS)`, suggestIntegration: 'Δεν βλέπετε τη δική σας; Προτείνετε μια ενσωμάτωση και θα το εξετάσουμε.', - noResultsPrompt: 'Παρακαλούμε προσαρμόστε την αναζήτησή σας ή', - suggestAnIntegration: 'προτείνετε μια ενσωμάτωση', - noResultsPromptEnd: '.', + noResultsPrompt: 'Παρακαλούμε προσαρμόστε την αναζήτησή σας ή προτείνετε μια ενσωμάτωση.', allConnectedTitle: 'Έχετε συνδεθεί πλήρως', - allConnectedPrompt: 'Όλα εδώ είναι ήδη συνδεδεμένα. Μπορείτε επίσης να', + allConnectedPrompt: 'Όλα εδώ είναι ήδη συνδεδεμένα. Μπορείτε επίσης να προτείνετε μια ενσωμάτωση.', }, receiptPartners: { uber: { diff --git a/src/languages/en.ts b/src/languages/en.ts index d66bb15f7213..3361be9bb4a4 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5188,11 +5188,9 @@ const translations = { hrisListing: (providerName: string) => `${providerName} (HRIS)`, atsListing: (providerName: string) => `${providerName} (ATS)`, suggestIntegration: "Don't see yours? Suggest an integration, we'll look into it.", - noResultsPrompt: 'Please adjust your search or', - suggestAnIntegration: 'suggest an integration', - noResultsPromptEnd: '.', + noResultsPrompt: 'Please adjust your search or suggest an integration.', allConnectedTitle: "You're all connected", - allConnectedPrompt: 'Everything here is already connected. You can also', + allConnectedPrompt: 'Everything here is already connected. You can also suggest an integration.', }, receiptPartners: { uber: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 5bfd4c431946..0b50ee0d3670 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4935,11 +4935,9 @@ ${amount} para ${merchant} - ${date}`, hrisListing: (providerName: string) => `${providerName} (HRIS)`, atsListing: (providerName: string) => `${providerName} (ATS)`, suggestIntegration: '¿No ves la tuya? Sugiere una integración y lo estudiaremos.', - noResultsPrompt: 'Ajusta tu búsqueda o', - suggestAnIntegration: 'sugiere una integración', - noResultsPromptEnd: '.', + noResultsPrompt: 'Ajusta tu búsqueda o sugiere una integración.', allConnectedTitle: 'Ya está todo conectado', - allConnectedPrompt: 'Todo aquí ya está conectado. También puedes', + allConnectedPrompt: 'Todo aquí ya está conectado. También puedes sugerir una integración.', }, receiptPartners: { uber: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index c1e0050b4483..5f15886eee74 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5016,11 +5016,9 @@ ${amount} pour ${merchant} - ${date}`, hrisListing: (providerName: string) => `${providerName} (SIRH)`, atsListing: (providerName: string) => `${providerName} (ATS)`, suggestIntegration: 'Vous ne voyez pas la vôtre ? Suggérez une intégration, nous allons l’examiner.', - noResultsPrompt: 'Veuillez modifier votre recherche ou', - suggestAnIntegration: 'suggérer une intégration', - noResultsPromptEnd: '.', + noResultsPrompt: 'Veuillez modifier votre recherche ou suggérer une intégration.', allConnectedTitle: 'Vous êtes entièrement connecté', - allConnectedPrompt: 'Tout est déjà connecté ici. Vous pouvez aussi', + allConnectedPrompt: 'Tout est déjà connecté ici. Vous pouvez aussi suggérer une intégration.', }, receiptPartners: { uber: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 09b03fdb75f5..f33311b7777f 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -4979,11 +4979,9 @@ ${amount} per ${merchant} - ${date}`, hrisListing: (providerName: string) => `${providerName} (HRIS)`, atsListing: (providerName: string) => `${providerName} (ATS)`, suggestIntegration: "Non vedi la tua? Suggerisci un'integrazione, la valuteremo.", - noResultsPrompt: 'Modifica la tua ricerca oppure', - suggestAnIntegration: "suggerisci un'integrazione", - noResultsPromptEnd: '.', + noResultsPrompt: `Modifica la tua ricerca oppure suggerisci un'integrazione.`, allConnectedTitle: 'Sei tutto connesso', - allConnectedPrompt: 'Qui è già tutto collegato. Puoi anche', + allConnectedPrompt: `Qui è già tutto collegato. Puoi anche suggerire un'integrazione.`, }, receiptPartners: { uber: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 0f13d3c3e37c..e6a32f384a6c 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -4944,11 +4944,9 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' hrisListing: (providerName: string) => `${providerName}(HRIS)`, atsListing: (providerName: string) => `${providerName}(ATS)`, suggestIntegration: 'お探しのものが見つかりませんか?連携を提案してください、こちらで検討します。', - noResultsPrompt: '検索条件を調整するか', - suggestAnIntegration: '連携を提案する', - noResultsPromptEnd: '。', + noResultsPrompt: '検索条件を調整するか、連携を提案してください。', allConnectedTitle: 'すべて接続されました', - allConnectedPrompt: 'ここにあるものはすべてすでに連携されています。さらに、次のこともできます', + allConnectedPrompt: 'ここにあるものはすべてすでに連携されています。連携を提案することもできます。', }, receiptPartners: { uber: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index a150618af690..90e1db150ab5 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -4981,11 +4981,9 @@ ${amount} voor ${merchant} - ${date}`, hrisListing: (providerName: string) => `${providerName} (HRIS)`, atsListing: (providerName: string) => `${providerName} (ATS)`, suggestIntegration: 'Zie je de jouwe niet? Stel een integratie voor, dan kijken we ernaar.', - noResultsPrompt: 'Pas je zoekopdracht aan of', - suggestAnIntegration: 'stel een integratie voor', - noResultsPromptEnd: '.', + noResultsPrompt: 'Pas je zoekopdracht aan of stel een integratie voor.', allConnectedTitle: 'Je bent helemaal verbonden', - allConnectedPrompt: 'Alles hier is al verbonden. Je kunt ook', + allConnectedPrompt: 'Alles hier is al verbonden. Je kunt ook een integratie voorstellen.', }, receiptPartners: { uber: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index d28bfb45151f..9f2b626a1762 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5010,11 +5010,9 @@ ${amount} dla ${merchant} - ${date}`, hrisListing: (providerName: string) => `${providerName} (HRIS)`, atsListing: (providerName: string) => `${providerName} (ATS)`, suggestIntegration: 'Nie widzisz swojej? Zaproponuj integrację, przyjrzymy się temu.', - noResultsPrompt: 'Dostosuj wyszukiwanie lub', - suggestAnIntegration: 'zaproponuj integrację', - noResultsPromptEnd: '.', + noResultsPrompt: 'Dostosuj wyszukiwanie lub zaproponuj integrację.', allConnectedTitle: 'Wszystko połączone', - allConnectedPrompt: 'Wszystko tutaj jest już połączone. Możesz też', + allConnectedPrompt: 'Wszystko tutaj jest już połączone. Możesz też zaproponować integrację.', }, receiptPartners: { uber: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index d3a0893925f0..9f76900a89bd 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -4972,11 +4972,9 @@ ${amount} para ${merchant} - ${date}`, hrisListing: (providerName: string) => `${providerName} (RHIS)`, atsListing: (providerName: string) => `${providerName} (ATS)`, suggestIntegration: 'Não encontrou a sua? Sugira uma integração e nós vamos analisar.', - noResultsPrompt: 'Ajuste sua pesquisa ou', - suggestAnIntegration: 'sugerir uma integração', - noResultsPromptEnd: '.', + noResultsPrompt: 'Ajuste sua pesquisa ou sugira uma integração.', allConnectedTitle: 'Tudo conectado', - allConnectedPrompt: 'Tudo aqui já está conectado. Você também pode', + allConnectedPrompt: 'Tudo aqui já está conectado. Você também pode sugerir uma integração.', }, receiptPartners: { uber: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 78f7d9cb71bb..9f64bd2f92ea 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4830,11 +4830,9 @@ ${amount},商户:${merchant} - 日期:${date}`, hrisListing: (providerName: string) => `${providerName}(HRIS)`, atsListing: (providerName: string) => `${providerName}(ATS)`, suggestIntegration: '没找到适合你的?建议一个集成,我们会进行评估。', - noResultsPrompt: '请调整您的搜索条件或', - suggestAnIntegration: '建议一个集成', - noResultsPromptEnd: '。', + noResultsPrompt: '请调整您的搜索条件或建议一个集成。', allConnectedTitle: '全部已连接', - allConnectedPrompt: '这里的一切都已连接完成。您也可以', + allConnectedPrompt: '这里的一切都已连接完成。您也可以建议一个集成。', }, receiptPartners: { uber: { diff --git a/src/pages/workspace/accounting/netsuite/export/NetSuiteProvincialTaxPostingAccountSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/NetSuiteProvincialTaxPostingAccountSelectPage.tsx index fe2d222139df..2767f7120590 100644 --- a/src/pages/workspace/accounting/netsuite/export/NetSuiteProvincialTaxPostingAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/NetSuiteProvincialTaxPostingAccountSelectPage.tsx @@ -2,16 +2,19 @@ import BlockingView from '@components/BlockingViews/BlockingView'; import type {SelectorType} from '@components/SelectionScreen'; import SelectionScreen from '@components/SelectionScreen'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateNetSuiteProvincialTaxPostingAccount} from '@libs/actions/connections/NetSuiteCommands'; import {getLatestErrorField} from '@libs/ErrorUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {canUseProvincialTaxNetSuite, getNetSuiteTaxAccountOptions} from '@libs/NetSuiteUtils'; import {settingsPendingAction} from '@libs/PolicyUtils'; +import {getAccountingSettingsRoute} from '@pages/workspace/connections/utils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; @@ -20,6 +23,7 @@ import variables from '@styles/variables'; import {clearNetSuiteErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import React, {useCallback, useMemo} from 'react'; @@ -29,6 +33,11 @@ function NetSuiteProvincialTaxPostingAccountSelectPage({policy}: WithPolicyConne const illustrations = useMemoizedLazyIllustrations(['Telescope']); const policyID = policy?.id; + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); + const netSuiteExportBackPath = policyID + ? createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.path, getAccountingSettingsRoute(isUnifiedConnectionsBetaEnabled, policyID)) + : ROUTES.HOME; + const config = policy?.connections?.netsuite?.options.config; const {subsidiaryList} = policy?.connections?.netsuite?.options?.data ?? {}; const selectedSubsidiary = useMemo(() => (subsidiaryList ?? []).find((subsidiary) => subsidiary.internalID === config?.subsidiaryID), [subsidiaryList, config?.subsidiaryID]); @@ -45,9 +54,9 @@ function NetSuiteProvincialTaxPostingAccountSelectPage({policy}: WithPolicyConne if (config?.provincialTaxPostingAccount !== value && policyID) { updateNetSuiteProvincialTaxPostingAccount(policyID, value, config?.provincialTaxPostingAccount); } - Navigation.goBack(); + Navigation.goBack(netSuiteExportBackPath); }, - [policyID, config?.provincialTaxPostingAccount], + [netSuiteExportBackPath, policyID, config?.provincialTaxPostingAccount], ); const listEmptyContent = useMemo( @@ -73,7 +82,7 @@ function NetSuiteProvincialTaxPostingAccountSelectPage({policy}: WithPolicyConne data={netsuiteTaxAccountOptions} onSelectRow={updateTaxAccount} initiallyFocusedOptionKey={initiallyFocusedOptionKey} - onBackButtonPress={() => Navigation.goBack()} + onBackButtonPress={() => Navigation.goBack(netSuiteExportBackPath)} title="workspace.netsuite.journalEntriesProvTaxPostingAccount" listEmptyContent={listEmptyContent} connectionName={CONST.POLICY.CONNECTIONS.NAME.NETSUITE} diff --git a/src/pages/workspace/accounting/netsuite/export/NetSuiteTaxPostingAccountSelectPage.tsx b/src/pages/workspace/accounting/netsuite/export/NetSuiteTaxPostingAccountSelectPage.tsx index 7bd5c7732235..999c690799d6 100644 --- a/src/pages/workspace/accounting/netsuite/export/NetSuiteTaxPostingAccountSelectPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/NetSuiteTaxPostingAccountSelectPage.tsx @@ -2,6 +2,7 @@ import BlockingView from '@components/BlockingViews/BlockingView'; import type {SelectorType} from '@components/SelectionScreen'; import SelectionScreen from '@components/SelectionScreen'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import usePermissions from '@hooks/usePermissions'; @@ -9,10 +10,12 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {updateNetSuiteTaxPostingAccount} from '@libs/actions/connections/NetSuiteCommands'; import {getLatestErrorField} from '@libs/ErrorUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {canUseTaxNetSuite, getNetSuiteTaxAccountOptions} from '@libs/NetSuiteUtils'; import {settingsPendingAction} from '@libs/PolicyUtils'; +import {getAccountingSettingsRoute} from '@pages/workspace/connections/utils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; @@ -21,6 +24,7 @@ import variables from '@styles/variables'; import {clearNetSuiteErrorField} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import React, {useCallback, useMemo} from 'react'; @@ -31,6 +35,11 @@ function NetSuiteTaxPostingAccountSelectPage({policy}: WithPolicyConnectionsProp const {isBetaEnabled} = usePermissions(); const policyID = policy?.id; + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); + const netSuiteExportBackPath = policyID + ? createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.path, getAccountingSettingsRoute(isUnifiedConnectionsBetaEnabled, policyID)) + : ROUTES.HOME; + const config = policy?.connections?.netsuite?.options.config; const {subsidiaryList} = policy?.connections?.netsuite?.options?.data ?? {}; const selectedSubsidiary = useMemo(() => (subsidiaryList ?? []).find((subsidiary) => subsidiary.internalID === config?.subsidiaryID), [subsidiaryList, config?.subsidiaryID]); @@ -47,9 +56,9 @@ function NetSuiteTaxPostingAccountSelectPage({policy}: WithPolicyConnectionsProp if (config?.taxPostingAccount !== value && policyID) { updateNetSuiteTaxPostingAccount(policyID, value, config?.taxPostingAccount); } - Navigation.goBack(); + Navigation.goBack(netSuiteExportBackPath); }, - [policyID, config?.taxPostingAccount], + [netSuiteExportBackPath, policyID, config?.taxPostingAccount], ); const listEmptyContent = useMemo( @@ -75,7 +84,7 @@ function NetSuiteTaxPostingAccountSelectPage({policy}: WithPolicyConnectionsProp data={netsuiteTaxAccountOptions} onSelectRow={updateTaxAccount} initiallyFocusedOptionKey={initiallyFocusedOptionKey} - onBackButtonPress={() => Navigation.goBack()} + onBackButtonPress={() => Navigation.goBack(netSuiteExportBackPath)} title="workspace.netsuite.journalEntriesTaxPostingAccount" listEmptyContent={listEmptyContent} connectionName={CONST.POLICY.CONNECTIONS.NAME.NETSUITE} diff --git a/src/pages/workspace/accounting/netsuite/export/NetSuiteTravelBillingConfigurationPage.tsx b/src/pages/workspace/accounting/netsuite/export/NetSuiteTravelBillingConfigurationPage.tsx index 889437e4134c..53465fc131c6 100644 --- a/src/pages/workspace/accounting/netsuite/export/NetSuiteTravelBillingConfigurationPage.tsx +++ b/src/pages/workspace/accounting/netsuite/export/NetSuiteTravelBillingConfigurationPage.tsx @@ -3,20 +3,23 @@ import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Parser from '@libs/Parser'; import {areSettingsInErrorFields, settingsPendingAction} from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; import TravelBillingContinuousReconciliationSection from '@pages/workspace/accounting/common/TravelBillingContinuousReconciliationSection'; +import {getAccountingSettingsRoute} from '@pages/workspace/connections/utils'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import type {ValueOf} from 'type-fest'; @@ -40,6 +43,8 @@ function NetSuiteTravelBillingConfigurationPage({policy}: WithPolicyConnectionsP const styles = useThemeStyles(); const policyID = policy?.id ?? String(CONST.DEFAULT_NUMBER_ID); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); + const netSuiteExportBackPath = createDynamicRoute(DYNAMIC_ROUTES.POLICY_ACCOUNTING_NETSUITE_EXPORT.path, getAccountingSettingsRoute(isUnifiedConnectionsBetaEnabled, policyID)); const config = policy?.connections?.netsuite?.options?.config; const {payableList} = policy?.connections?.netsuite?.options?.data ?? {}; @@ -86,7 +91,7 @@ function NetSuiteTravelBillingConfigurationPage({policy}: WithPolicyConnectionsP contentContainerStyle={styles.pb2} titleStyle={styles.ph5} connectionName={CONST.POLICY.CONNECTIONS.NAME.NETSUITE} - onBackButtonPress={() => Navigation.goBack()} + onBackButtonPress={() => Navigation.goBack(netSuiteExportBackPath)} > { - Navigation.goBack(navigateBackTo); - }, [navigateBackTo]); + Navigation.goBack( + navigateBackTo ?? + (policyID + ? createDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_ACCOUNTING_QUICKBOOKS_DESKTOP_ADVANCED.path, getAccountingSettingsRoute(isUnifiedConnectionsBetaEnabled, policyID)) + : undefined), + ); + }, [isUnifiedConnectionsBetaEnabled, policyID, navigateBackTo]); return ( card.isConnected), - ); useWorkspaceDocumentTitle(undefined, `workspace.common.${category}`); diff --git a/src/pages/workspace/connections/ConnectionsReceiptPartnersPage.tsx b/src/pages/workspace/connections/ConnectionsReceiptPartnersPage.tsx index 1d21b560f29d..dc5bd66f8e53 100644 --- a/src/pages/workspace/connections/ConnectionsReceiptPartnersPage.tsx +++ b/src/pages/workspace/connections/ConnectionsReceiptPartnersPage.tsx @@ -41,8 +41,6 @@ import type {ValueOf} from 'type-fest'; import React, {useEffect} from 'react'; -import useRedirectUnconnectedPanelToConnections from './useRedirectUnconnectedPanelToConnections'; - type ConnectionsReceiptPartnersPageProps = PlatformStackScreenProps; function ConnectionsReceiptPartnersPage({route}: ConnectionsReceiptPartnersPageProps) { @@ -58,7 +56,6 @@ function ConnectionsReceiptPartnersPage({route}: ConnectionsReceiptPartnersPageP useWorkspaceDocumentTitle(policy?.name, 'workspace.common.receiptPartners'); const {getReceiptPartnersIntegrationData, shouldShowEnterCredentialsError, isUberConnected} = useGetReceiptPartnersIntegrationData(policyID); const isLoading = policy?.isLoading; - useRedirectUnconnectedPanelToConnections(policyID, !!policy && !isLoading, isUberConnected || shouldShowEnterCredentialsError); const integrations = policy?.receiptPartners; const isAutoRemove = !!integrations?.uber?.autoRemove; const isAutoInvite = !!integrations?.uber?.autoInvite; @@ -84,9 +81,8 @@ function ConnectionsReceiptPartnersPage({route}: ConnectionsReceiptPartnersPageP const {isOffline} = useNetwork({onReconnect: fetchReceiptPartners}); useEffect(() => { - fetchReceiptPartners(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + openPolicyReceiptPartnersPage(policyID); + }, [policyID]); const toggleWorkspaceUberAutoInvite = () => { togglePolicyUberAutoInvite(policyID, !isAutoInvite); @@ -168,7 +164,7 @@ function ConnectionsReceiptPartnersPage({route}: ConnectionsReceiptPartnersPageP /> ) : (
diff --git a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx index a052b0f6b96e..0861c857544f 100644 --- a/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx +++ b/src/pages/workspace/connections/WorkspaceConnectionsPage.tsx @@ -7,6 +7,7 @@ import GenericEmptyStateComponent from '@components/EmptyStateComponent/GenericE import Header from '@components/Header'; import Icon from '@components/Icon'; import {PressableWithFeedback} from '@components/Pressable'; +import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import SidePanelButton from '@components/SidePanel/SidePanelButton'; @@ -51,8 +52,11 @@ import {openPolicyReceiptPartnersPage} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Account} from '@src/types/onyx'; import type {ConnectionName} from '@src/types/onyx/Policy'; +import type {OnyxEntry} from 'react-native-onyx'; + import {useFocusEffect, useRoute} from '@react-navigation/native'; import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -80,6 +84,8 @@ type MergeSetupFlow = { }; /** HR and recruiting providers are Control-only, so their data is only requested for Control workspaces */ +const accountManagerReportIDSelector = (account: OnyxEntry) => (account?.accountManagerAccountID ? account.accountManagerReportID : undefined); + function fetchConnectionsData(policyID: string | undefined, canUseMergeConnections: boolean, isRecruitingBetaEnabled: boolean) { if (!policyID) { return; @@ -106,7 +112,7 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { const [isSearchFocused, setIsSearchFocused] = useState(false); const {isBetaEnabled} = usePermissions(); const {openConciergeAnywhere} = useOpenConciergeAnywhere(); - const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const [accountManagerReportID] = useOnyx(ONYXKEYS.ACCOUNT, {selector: accountManagerReportIDSelector}); const {startIntegrationFlow} = useAccountingActions(); const {canWrite: canWriteAccounting} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); const [lastSelectedTab, setLastSelectedTab] = useState(CONST.TAB.CONNECTIONS.ALL); @@ -161,8 +167,8 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { openConciergeAnywhere({reportID: String(policy.chatReportIDAdmins)}); return; } - if (account?.accountManagerAccountID && account.accountManagerReportID) { - openConciergeAnywhere({reportID: account.accountManagerReportID}); + if (accountManagerReportID) { + openConciergeAnywhere({reportID: accountManagerReportID}); return; } openConciergeAnywhere({forceConcierge: true}); @@ -325,11 +331,12 @@ function WorkspaceConnectionsPage({policy}: WithPolicyConnectionsProps) { headerStyles={styles.emptyStateCardIllustrationContainer} title={isSearching ? translate('common.noResultsFound') : translate('workspace.connections.allConnectedTitle')} subtitleText={ - - {isSearching ? translate('workspace.connections.noResultsPrompt') : translate('workspace.connections.allConnectedPrompt')}{' '} - {translate('workspace.connections.suggestAnIntegration')} - {translate('workspace.connections.noResultsPromptEnd')} - + + + } /> ) : ( diff --git a/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts index f89d46b7896d..37450014437e 100644 --- a/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts +++ b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts @@ -19,7 +19,7 @@ import type {Policy} from '@src/types/onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {useEffect} from 'react'; +import {useEffect, useRef} from 'react'; import type {ConnectionListing} from './types'; @@ -30,12 +30,15 @@ function useReceiptPartnerConnectionListings(policy: OnyxEntry): Connect const {canWrite, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); const prevIsUberConnected = usePrevious(isUberConnected); const {isOffline} = useNetwork(); + const hasStartedUberConnectionRef = useRef(false); - // The Uber connection finishes outside the app, so the invite flow opens once the connection shows up here + // The Uber connection finishes outside the app, so the invite flow opens once the connection shows up here. It only + // opens for a connection started from this page, not for one that shows up when the data first loads. useEffect(() => { - if (!policyID || !isUberConnected || prevIsUberConnected || !canWrite) { + if (!policyID || !isUberConnected || prevIsUberConnected || !canWrite || !hasStartedUberConnectionRef.current) { return; } + hasStartedUberConnectionRef.current = false; Navigation.navigate( createDynamicRoute( DYNAMIC_ROUTES.WORKSPACE_RECEIPT_PARTNERS_INVITE.getRoute(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER), @@ -61,10 +64,12 @@ function useReceiptPartnerConnectionListings(policy: OnyxEntry): Connect if (!policy?.receiptPartners?.enabled) { enablePolicyReceiptPartners(policyID, true, false); } + hasStartedUberConnectionRef.current = true; openExternalLink(`${CONST.UBER_CONNECT_URL}?${connectFormData}`); }; const isConnected = isUberConnected || shouldShowEnterCredentialsError; + const isBroken = shouldShowEnterCredentialsError || !!uberData.errorFields; return [ { @@ -74,14 +79,21 @@ function useReceiptPartnerConnectionListings(policy: OnyxEntry): Connect icon: uberData.icon, status: isConnected ? { - isBroken: shouldShowEnterCredentialsError, - message: shouldShowEnterCredentialsError ? translate('workspace.connections.brokenConnection') : uberData.description, + isBroken, + message: isBroken ? translate('workspace.connections.brokenConnection') : uberData.description, } : undefined, // The Uber partner offer only applies to new connections offer: isConnected ? undefined - : {onPress: canWrite ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CLAIM_OFFER.getRoute(policyID, CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER)) : undefined}, + : { + onPress: canWrite + ? () => { + hasStartedUberConnectionRef.current = true; + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CLAIM_OFFER.getRoute(policyID, CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER)); + } + : undefined, + }, onConnect: connectUber, isLoading: !policy?.receiptPartners?.uber && !isOffline && !!policy?.isLoadingReceiptPartners, onConfigure: () => Navigation.navigate(ROUTES.WORKSPACE_CONNECTIONS_RECEIPT_PARTNERS.getRoute(policyID)), diff --git a/src/pages/workspace/connections/utils.ts b/src/pages/workspace/connections/utils.ts index 9921f3fe901e..93d95597ef46 100644 --- a/src/pages/workspace/connections/utils.ts +++ b/src/pages/workspace/connections/utils.ts @@ -51,4 +51,12 @@ function getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled: boolean, return ROUTES.POLICY_ACCOUNTING.getRoute(...params); } -export {MCP_CONNECTOR, getSyncStatusMessage, getListingsForTab, getAccountingConnectionsRoute}; +/** The page an accounting integration's settings open from, which is the Connections accounting panel while the unified Connections beta is on */ +function getAccountingSettingsRoute(isUnifiedConnectionsBetaEnabled: boolean, policyID: string) { + if (isUnifiedConnectionsBetaEnabled) { + return ROUTES.WORKSPACE_CONNECTIONS_ACCOUNTING.getRoute(policyID); + } + return ROUTES.POLICY_ACCOUNTING.getRoute(policyID); +} + +export {MCP_CONNECTOR, getSyncStatusMessage, getListingsForTab, getAccountingConnectionsRoute, getAccountingSettingsRoute}; diff --git a/tests/unit/WorkspaceConnectionsPageAutoStartFlowTest.tsx b/tests/unit/WorkspaceConnectionsPageAutoStartFlowTest.tsx index ef6ea5b8323b..b89e9e6fb8d4 100644 --- a/tests/unit/WorkspaceConnectionsPageAutoStartFlowTest.tsx +++ b/tests/unit/WorkspaceConnectionsPageAutoStartFlowTest.tsx @@ -95,6 +95,7 @@ jest.mock('@components/ScrollView', () => ({ jest.mock('@components/SidePanel/SidePanelButton', () => ({__esModule: true, default: () => null})); jest.mock('@components/TabSelector/TabSelectorBase', () => ({__esModule: true, default: () => null})); +jest.mock('@components/RenderHTML', () => ({__esModule: true, default: () => null})); jest.mock('@components/TextLink', () => ({__esModule: true, default: () => null})); jest.mock('@pages/workspace/connections/ConnectionsGrid', () => ({__esModule: true, default: () => null})); jest.mock('@pages/workspace/connections/useAccountingConnectionListings', () => ({__esModule: true, default: () => []})); From 6f62404092ba8be2d08cdafd95f9aa6cb5735e0c Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 9 Oct 2026 23:03:49 +0530 Subject: [PATCH 49/51] Keep TabSelector scroll state minimal and move the Connections tooltip into a hook Signed-off-by: krishna2323 --- .../TabSelector/TabSelectorBase.tsx | 36 +++++------ src/pages/workspace/WorkspaceInitialPage.tsx | 42 +++---------- .../useConnectionsMovedTooltip.tsx | 60 +++++++++++++++++++ ...seRedirectUnconnectedPanelToConnections.ts | 27 --------- 4 files changed, 86 insertions(+), 79 deletions(-) create mode 100644 src/pages/workspace/connections/useConnectionsMovedTooltip.tsx delete mode 100644 src/pages/workspace/connections/useRedirectUnconnectedPanelToConnections.ts diff --git a/src/components/TabSelector/TabSelectorBase.tsx b/src/components/TabSelector/TabSelectorBase.tsx index df182f1df08d..273dee29700c 100644 --- a/src/components/TabSelector/TabSelectorBase.tsx +++ b/src/components/TabSelector/TabSelectorBase.tsx @@ -6,7 +6,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; -import React, {useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useMemo, useRef, useState} from 'react'; import type {TabSelectorBaseProps} from './types'; @@ -50,9 +50,20 @@ function TabSelectorBase({ const activeIndex = tabs.findIndex((tab) => tab.key === activeTabKey); - const [scrollMetrics, setScrollMetrics] = useState({offset: 0, viewportWidth: 0, contentWidth: 0}); - const canScrollLeft = scrollMetrics.offset > 1; - const canScrollRight = scrollMetrics.offset + scrollMetrics.viewportWidth < scrollMetrics.contentWidth - 1; + // The measurements change on every scroll frame, so they live in a ref and only the fade visibility is state + const scrollMetricsRef = useRef({offset: 0, viewportWidth: 0, contentWidth: 0}); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + const updateScrollFade = (metrics: Partial) => { + if (!shouldShowScrollFade) { + return; + } + const {offset, viewportWidth, contentWidth} = {...scrollMetricsRef.current, ...metrics}; + scrollMetricsRef.current = {offset, viewportWidth, contentWidth}; + setCanScrollLeft(offset > 1); + setCanScrollRight(offset + viewportWidth < contentWidth - 1); + }; // After a tab change, reset affectedAnimatedTabs once the transition is done so // tabs settle back into the default animated state. @@ -70,24 +81,13 @@ function TabSelectorBase({ scrollEventThrottle={CONST.TIMING.MIN_SMOOTH_SCROLL_EVENT_THROTTLE} onLayout={(e) => { onContainerLayout(e); - if (shouldShowScrollFade) { - const viewportWidth = e.nativeEvent.layout.width; - setScrollMetrics((metrics) => ({...metrics, viewportWidth})); - } - }} - onContentSizeChange={(contentWidth) => { - if (!shouldShowScrollFade) { - return; - } - setScrollMetrics((metrics) => ({...metrics, contentWidth})); + updateScrollFade({viewportWidth: e.nativeEvent.layout.width}); }} + onContentSizeChange={(contentWidth) => updateScrollFade({contentWidth})} onScroll={(e) => { onContainerScroll(e); triggerScrollEvent(); - if (shouldShowScrollFade) { - const offset = e.nativeEvent.contentOffset.x; - setScrollMetrics((metrics) => ({...metrics, offset})); - } + updateScrollFade({offset: e.nativeEvent.contentOffset.x}); }} ref={containerRef} style={styles.scrollableTabSelector} diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index fc10cc4f0bd8..60de7be8f11d 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -4,10 +4,8 @@ import HighlightableMenuItem from '@components/HighlightableMenuItem'; import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS'; import TabBarBottomContent from '@components/Navigation/TabBarBottomContent'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; -import {useProductTrainingContext} from '@components/ProductTrainingContext'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; -import Text from '@components/Text'; import useCardFeedErrors from '@hooks/useCardFeedErrors'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; @@ -23,8 +21,6 @@ import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScrollEventEmitter from '@hooks/useScrollEventEmitter'; import useSingleExecution from '@hooks/useSingleExecution'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; @@ -32,7 +28,6 @@ import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; import {isConnectionInProgress} from '@libs/actions/connections'; import {clearErrors, openPolicyInitialPage, removeWorkspace} from '@libs/actions/Policy/Policy'; import {getRules} from '@libs/actions/Policy/Rules'; -import {dismissProductTraining} from '@libs/actions/Welcome'; import goBackFromWorkspaceSettingPages from '@libs/Navigation/helpers/goBackFromWorkspaceSettingPages'; import WorkspaceCreationReveal from '@libs/Navigation/helpers/WorkspaceCreationReveal'; import Navigation from '@libs/Navigation/Navigation'; @@ -59,6 +54,7 @@ import {View} from 'react-native'; import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; +import useConnectionsMovedTooltip from './connections/useConnectionsMovedTooltip'; import getWorkspaceMenuItems from './getWorkspaceMenuItems'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; @@ -77,8 +73,6 @@ function dismissError(policyID: string | undefined, pendingAction: PendingAction function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: WorkspaceInitialPageProps) { const styles = useThemeStyles(); - const theme = useTheme(); - const StyleUtils = useStyleUtils(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {translate} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); @@ -209,34 +203,15 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac action: singleExecution(waitForNavigate(() => Navigation.navigate(item.getRoute()))), })); const triggerScrollEvent = useScrollEventEmitter(); - const {shouldShowProductTrainingTooltip: shouldShowConnectionsTooltip, hideProductTrainingTooltip: hideConnectionsTooltip} = useProductTrainingContext( - CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.CONNECTIONS_MOVED, + const { + shouldShowTooltip: shouldShowConnectionsTooltip, + hideTooltip: hideConnectionsTooltip, + renderTooltipContent: renderConnectionsTooltip, + } = useConnectionsMovedTooltip( // New workspaces never used the old integration pages, so only point this out where something is already connected. isWorkspacesTabFocused && !hasPolicyCreationError && !isEmptyObject(policy?.connections) && workspaceMenuItems.some((item) => item.screenName === SCREENS.WORKSPACE.CONNECTIONS), - ); - // Route the Connections tooltip was first shown on, so leaving that page counts as dismissing it - const connectionsTooltipRouteRef = useRef(undefined); - useEffect(() => { - if (shouldShowConnectionsTooltip && !connectionsTooltipRouteRef.current) { - connectionsTooltipRouteRef.current = activeRoute; - } - const shownOnRoute = connectionsTooltipRouteRef.current; - if (!shownOnRoute || (shownOnRoute === activeRoute && isWorkspacesTabFocused)) { - return; - } - connectionsTooltipRouteRef.current = undefined; - dismissProductTraining(CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.CONNECTIONS_MOVED); - }, [activeRoute, isWorkspacesTabFocused, shouldShowConnectionsTooltip]); - const renderConnectionsTooltip = () => ( - - - {translate('common.new')}{' '} - {translate('productTrainingTooltip.connectionsMoved')} - - + activeRoute, + isWorkspacesTabFocused, ); // Close RHP if we land on a route that no longer exists in the menu const getItemScreenNames = (item: TupleToUnion) => [item.screenName, ...(item.aliasScreenNames ?? [])]; @@ -323,7 +298,6 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const isConnectionsItem = item.screenName === SCREENS.WORKSPACE.CONNECTIONS; const onPress = () => { if (isConnectionsItem) { - connectionsTooltipRouteRef.current = undefined; hideConnectionsTooltip(); } item.action(); diff --git a/src/pages/workspace/connections/useConnectionsMovedTooltip.tsx b/src/pages/workspace/connections/useConnectionsMovedTooltip.tsx new file mode 100644 index 000000000000..c4ad2c298fae --- /dev/null +++ b/src/pages/workspace/connections/useConnectionsMovedTooltip.tsx @@ -0,0 +1,60 @@ +import {useProductTrainingContext} from '@components/ProductTrainingContext'; +import Text from '@components/Text'; + +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {dismissProductTraining} from '@libs/actions/Welcome'; + +import CONST from '@src/CONST'; + +import React, {useEffect, useRef} from 'react'; +import {View} from 'react-native'; + +/** + * The tooltip on the workspace menu's Connections item telling users their integrations moved there. Leaving the page + * it was first shown on counts as dismissing it, so it only shows once. + */ +function useConnectionsMovedTooltip(shouldShow: boolean, activeRoute: string | undefined, isWorkspacesTabFocused: boolean) { + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const {shouldShowProductTrainingTooltip, hideProductTrainingTooltip} = useProductTrainingContext(CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.CONNECTIONS_MOVED, shouldShow); + const shownOnRouteRef = useRef(undefined); + + useEffect(() => { + if (shouldShowProductTrainingTooltip && !shownOnRouteRef.current) { + shownOnRouteRef.current = activeRoute; + } + const shownOnRoute = shownOnRouteRef.current; + if (!shownOnRoute || (shownOnRoute === activeRoute && isWorkspacesTabFocused)) { + return; + } + shownOnRouteRef.current = undefined; + dismissProductTraining(CONST.PRODUCT_TRAINING_TOOLTIP_NAMES.CONNECTIONS_MOVED); + }, [activeRoute, isWorkspacesTabFocused, shouldShowProductTrainingTooltip]); + + const hideTooltip = () => { + shownOnRouteRef.current = undefined; + hideProductTrainingTooltip(); + }; + + const renderTooltipContent = () => ( + + + {translate('common.new')}{' '} + {translate('productTrainingTooltip.connectionsMoved')} + + + ); + + return {shouldShowTooltip: shouldShowProductTrainingTooltip, hideTooltip, renderTooltipContent}; +} + +export default useConnectionsMovedTooltip; diff --git a/src/pages/workspace/connections/useRedirectUnconnectedPanelToConnections.ts b/src/pages/workspace/connections/useRedirectUnconnectedPanelToConnections.ts deleted file mode 100644 index 7e6716d3a398..000000000000 --- a/src/pages/workspace/connections/useRedirectUnconnectedPanelToConnections.ts +++ /dev/null @@ -1,27 +0,0 @@ -import Navigation from '@libs/Navigation/Navigation'; - -import ROUTES from '@src/ROUTES'; - -import {useEffect, useRef} from 'react'; - -/** - * Sends a visit to a connection's settings panel back to the Connections page when that connection isn't set up, so a - * stale link to a panel still lands somewhere useful. Only the first loaded state is checked, so disconnecting from - * inside the panel keeps its own navigation. - */ -function useRedirectUnconnectedPanelToConnections(policyID: string | undefined, isDataReady: boolean, isConnected: boolean) { - const hasCheckedRef = useRef(false); - - useEffect(() => { - if (hasCheckedRef.current || !policyID || !isDataReady) { - return; - } - hasCheckedRef.current = true; - if (isConnected) { - return; - } - Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)); - }, [policyID, isDataReady, isConnected]); -} - -export default useRedirectUnconnectedPanelToConnections; From 210a33b858f73fdde9ffdf70921801fdbf56cecd Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 9 Oct 2026 23:16:13 +0530 Subject: [PATCH 50/51] Use one beta hook, an options object and one enable helper for Connections Signed-off-by: krishna2323 --- .../ConnectToSageIntacctFlow/index.tsx | 6 +- .../SearchRouter/useNavigationSuggestions.tsx | 4 +- src/libs/actions/Policy/Policy.ts | 27 ++++++++ src/pages/workspace/WorkspaceInitialPage.tsx | 4 +- .../WorkspaceMoreFeaturesPage/index.tsx | 3 +- .../accounting/AccountingContext/index.tsx | 49 +++++++------ .../workspace/accounting/ClaimOfferPage.tsx | 11 ++- .../accounting/PolicyAccountingPage.tsx | 37 +++------- .../NetSuiteSetupPage/index.native.tsx | 7 +- .../NetSuiteExistingConnectionsPage.tsx | 6 +- .../QuickBooksDesktopSetupFlowSyncPage.tsx | 6 +- src/pages/workspace/accounting/utils.tsx | 68 ++++++++++++------- .../connections/ConnectionsAccountingPage.tsx | 23 ++++--- .../connections/ConnectionsMergePageBase.tsx | 6 +- .../ConnectionsReceiptPartnersPage.tsx | 6 +- .../connections/WorkspaceConnectionsPage.tsx | 5 +- .../useAccountingConnectionListings.ts | 28 +++----- .../connections/useMCPConnectionListings.ts | 8 +-- .../connections/useMergeConnectionListings.ts | 8 +-- .../useReceiptPartnerConnectionListings.ts | 6 +- .../withUnifiedConnectionsBeta.tsx | 8 +-- .../upgrade/WorkspaceUpgradePage.tsx | 5 +- .../AccountingConnectionConfirmationTest.tsx | 10 +-- .../accounting/qbd/subscribedSettingsTest.ts | 2 +- .../qbo/defaultVendorSubscriptionsTest.ts | 10 +-- 25 files changed, 179 insertions(+), 174 deletions(-) diff --git a/src/components/ConnectToSageIntacctFlow/index.tsx b/src/components/ConnectToSageIntacctFlow/index.tsx index 3aa2d1a4e6da..73ff7e5d307e 100644 --- a/src/components/ConnectToSageIntacctFlow/index.tsx +++ b/src/components/ConnectToSageIntacctFlow/index.tsx @@ -1,6 +1,6 @@ import useHasReusablePoliciesConnectedTo from '@hooks/useHasReusablePoliciesConnectedTo'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import {isAuthenticationError} from '@libs/actions/connections'; import Navigation from '@libs/Navigation/Navigation'; @@ -21,7 +21,7 @@ function ConnectToSageIntacctFlow({policyID}: ConnectToSageIntacctFlowProps) { const hasReusablePoliciesConnectedToSageIntacct = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, policyID); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const shouldGoToEnterCredentials = isAuthenticationError(policy, CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT); - const {isBetaEnabled} = usePermissions(); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); useEffect(() => { if (shouldGoToEnterCredentials) { @@ -29,7 +29,7 @@ function ConnectToSageIntacctFlow({policyID}: ConnectToSageIntacctFlowProps) { return; } if (!hasReusablePoliciesConnectedToSageIntacct) { - Navigation.navigate(`${getAccountingConnectionsRoute(isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS), policyID)}/${DYNAMIC_ROUTES.SAGE_INTACCT_PREREQUISITES.path}`); + Navigation.navigate(`${getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID)}/${DYNAMIC_ROUTES.SAGE_INTACCT_PREREQUISITES.path}`); return; } Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_EXISTING_CONNECTIONS.getRoute(policyID)); diff --git a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx index 2b5924367a38..219e9cb181b5 100644 --- a/src/components/Search/SearchRouter/useNavigationSuggestions.tsx +++ b/src/components/Search/SearchRouter/useNavigationSuggestions.tsx @@ -10,6 +10,7 @@ import TextWithTooltip from '@components/TextWithTooltip'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -396,6 +397,7 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) const {convertToDisplayString} = useCurrencyListActions(); const {isOffline} = useNetwork(); const {isBetaEnabled} = usePermissions(); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const icons = useMemoizedLazyExpensifyIcons(SEARCH_ROUTER_ICON_NAMES); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -451,7 +453,7 @@ function useNavigationSuggestions(query: string, shouldWatchForApprovals = true) isOffline: !!isOffline, isVendorMatchingBetaEnabled: isBetaEnabled(CONST.BETAS.VENDOR_MATCHING), isRecruitingBetaEnabled: isBetaEnabled(CONST.BETAS.MERGE_ATS), - isUnifiedConnectionsBetaEnabled: isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS), + isUnifiedConnectionsBetaEnabled, shouldUseNarrowLayout, convertToDisplayString, getItemText: (item) => translate(item.translationKey), diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 6021709dbcb8..914a565376ab 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -5083,6 +5083,32 @@ function enablePolicyReceiptPartners(policyID: string, enabled: boolean, shouldG } } +type ConnectionFeatureName = + | typeof CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED + | typeof CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED + | typeof CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED + | typeof CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED + | typeof CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED; + +const ENABLE_CONNECTION_FEATURE = { + [CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED]: enablePolicyConnections, + [CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED]: enablePolicyHR, + [CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED]: enablePolicyRecruiting, + [CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED]: enablePolicyReceiptPartners, + [CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED]: enablePolicyMCP, +} as const; + +/** + * The Connections page has no feature toggles, so connecting an integration from it turns the integration's feature on. + * The user stays on the page they connected from. + */ +function enablePolicyFeatureForConnection(policy: OnyxEntry, featureName: ConnectionFeatureName) { + if (!policy?.id || PolicyUtils.isPolicyFeatureEnabled(policy, featureName)) { + return; + } + ENABLE_CONNECTION_FEATURE[featureName](policy.id, true, false); +} + /** Save the preferred export method for a policy */ function savePreferredExportMethod(policyID: string, exportMethod: ReportExportType) { Onyx.merge(`${ONYXKEYS.LAST_EXPORT_METHOD}`, {[policyID]: exportMethod}); @@ -8095,6 +8121,7 @@ export { enablePolicyRecruiting, enablePolicyMCP, enablePolicyReceiptPartners, + enablePolicyFeatureForConnection, enablePolicyReportFields, enablePolicyInvoiceFields, enablePolicyTaxes, diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 60de7be8f11d..8e5d53b5a96f 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -10,6 +10,7 @@ import ScrollView from '@components/ScrollView'; import useCardFeedErrors from '@hooks/useCardFeedErrors'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import useIsWorkspacesTabFocused from '@hooks/useIsWorkspacesTabFocused'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -77,6 +78,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const {translate} = useLocalize(); const {convertToDisplayString} = useCurrencyListActions(); const {isBetaEnabled} = usePermissions(); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); const isFocused = useIsFocused(); const isWorkspacesTabFocused = useIsWorkspacesTabFocused(); @@ -196,7 +198,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac shouldShowRBR, isVendorMatchingBetaEnabled: isBetaEnabled(CONST.BETAS.VENDOR_MATCHING), isRecruitingBetaEnabled: isBetaEnabled(CONST.BETAS.MERGE_ATS), - isUnifiedConnectionsBetaEnabled: isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS), + isUnifiedConnectionsBetaEnabled, convertToDisplayString, }).map((item) => ({ ...item, diff --git a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx index ee093d1b93f9..ff1098f77ce4 100644 --- a/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx +++ b/src/pages/workspace/WorkspaceMoreFeaturesPage/index.tsx @@ -9,6 +9,7 @@ import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDefaultFundID from '@hooks/useDefaultFundID'; import useIsPolicyConnectedToUberReceiptPartner from '@hooks/useIsPolicyConnectedToUberReceiptPartner'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -103,7 +104,7 @@ function WorkspaceMoreFeaturesPage({policy, route}: WorkspaceMoreFeaturesPagePro const {showConfirmModal} = useConfirmModal(); const isRecruitingBetaEnabled = isBetaEnabled(CONST.BETAS.MERGE_ATS); // Connections replaces this section's pages, and turns each feature on as its first integration connects - const isUnifiedConnectionsBetaEnabled = isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); const illustrations = useMemoizedLazyIllustrations([ 'FolderOpen', 'Accounting', diff --git a/src/pages/workspace/accounting/AccountingContext/index.tsx b/src/pages/workspace/accounting/AccountingContext/index.tsx index ad3d22940129..c5be214ebf89 100644 --- a/src/pages/workspace/accounting/AccountingContext/index.tsx +++ b/src/pages/workspace/accounting/AccountingContext/index.tsx @@ -4,9 +4,9 @@ import useCardFeeds from '@hooks/useCardFeeds'; import useCardsLists from '@hooks/useCardsLists'; import useConfirmModal from '@hooks/useConfirmModal'; import useHasReusablePoliciesConnectedTo from '@hooks/useHasReusablePoliciesConnectedTo'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import usePermissions from '@hooks/usePermissions'; import {getAccountingIntegrationDisplayName} from '@libs/AccountingUtils'; import {removePolicyConnection} from '@libs/actions/connections'; @@ -15,7 +15,7 @@ import {isControlPolicy, tryNavigateToSubmitWorkspaceUpgrade} from '@libs/Policy import {getAccountingIntegrationData} from '@pages/workspace/accounting/utils'; -import {enablePolicyConnections} from '@userActions/Policy/Policy'; +import {enablePolicyFeatureForConnection} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -46,8 +46,7 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider const [activeIntegration, setActiveIntegration] = useState(); const {translate} = useLocalize(); const {showConfirmModal, closeModalByID} = useConfirmModal(); - const {isBetaEnabled} = usePermissions(); - const isUnifiedConnectionsBetaEnabled = isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); const policyID = policy?.id; // `removePolicyConnection` only runs once the user confirms, which can be a while after the flow started, so the @@ -146,21 +145,20 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider dualEntry: hasReusablePoliciesConnectedToDualEntry, campfire: hasReusablePoliciesConnectedToCampfire, }, - undefined, - undefined, - newActiveIntegration.integrationToDisconnect, - newActiveIntegration.shouldDisconnectIntegrationBeforeConnecting, - undefined, - accountingIcons, - cardFeeds, - cardLists, - newActiveIntegration.isIntuitEnterpriseSuite, - isUnifiedConnectionsBetaEnabled, + { + integrationToDisconnect: newActiveIntegration.integrationToDisconnect, + shouldDisconnectIntegrationBeforeConnecting: newActiveIntegration.shouldDisconnectIntegrationBeforeConnecting, + expensifyIcons: accountingIcons, + cardFeeds, + cardList: cardLists, + isIntuitEnterpriseSuiteOverride: newActiveIntegration.isIntuitEnterpriseSuite, + isUnifiedConnectionsBetaEnabled, + }, ); // Connections has no feature toggle, so turn it on here. The Control upgrade only enables the integration's own feature. - if (isUnifiedConnectionsBetaEnabled && !policy?.areConnectionsEnabled) { - enablePolicyConnections(policyID, true, false); + if (isUnifiedConnectionsBetaEnabled) { + enablePolicyFeatureForConnection(policy, CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED); } const workspaceUpgradeNavigationDetails = accountingIntegrationData?.workspaceUpgradeNavigationDetails; @@ -286,16 +284,15 @@ function AccountingContextProvider({children, policy}: AccountingContextProvider dualEntry: hasReusablePoliciesConnectedToDualEntry, campfire: hasReusablePoliciesConnectedToCampfire, }, - policy, - activeIntegration.key, - undefined, - undefined, - undefined, - accountingIcons, - cardFeeds, - cardLists, - activeIntegration.isIntuitEnterpriseSuite, - isUnifiedConnectionsBetaEnabled, + { + policy, + key: activeIntegration.key, + expensifyIcons: accountingIcons, + cardFeeds, + cardList: cardLists, + isIntuitEnterpriseSuiteOverride: activeIntegration.isIntuitEnterpriseSuite, + isUnifiedConnectionsBetaEnabled, + }, )?.setupConnectionFlow; }; diff --git a/src/pages/workspace/accounting/ClaimOfferPage.tsx b/src/pages/workspace/accounting/ClaimOfferPage.tsx index 5a90ec2024c2..624d931c4e0b 100644 --- a/src/pages/workspace/accounting/ClaimOfferPage.tsx +++ b/src/pages/workspace/accounting/ClaimOfferPage.tsx @@ -9,10 +9,10 @@ import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import {openExternalLink} from '@libs/actions/Link'; @@ -26,7 +26,7 @@ import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyConnectionsProps} from '@pages/workspace/withPolicyConnections'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; -import {enablePolicyReceiptPartners} from '@userActions/Policy/Policy'; +import {enablePolicyFeatureForConnection} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -61,8 +61,7 @@ function ClaimOfferPage({route, policy}: ClaimOfferPageProps) { const expensifyIcons = useMemoizedLazyExpensifyIcons(['TreasureChestGreenWithSparkle']); const integrations = policy?.receiptPartners; const {isUberConnected} = useGetReceiptPartnersIntegrationData(policyID); - const {isBetaEnabled} = usePermissions(); - const isUnifiedConnectionsBetaEnabled = isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); const accountingIntegrations = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES; @@ -101,8 +100,8 @@ function ClaimOfferPage({route, policy}: ClaimOfferPageProps) { featureName: CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED, onConnect: () => { // Connections offers Uber before Receipt partners is on, so the feature is turned on as Uber connects - if (isUnifiedConnectionsBetaEnabled && !policy?.receiptPartners?.enabled) { - enablePolicyReceiptPartners(policyID, true, false); + if (isUnifiedConnectionsBetaEnabled) { + enablePolicyFeatureForConnection(policy, CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED); } openExternalLink(`${CONST.UBER_CONNECT_URL}?${integrations?.uber?.connectFormData}`); }, diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index 1891b5c0a220..4bb78bc4cff6 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -557,15 +557,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { dualEntry: hasReusablePoliciesConnectedToDualEntry, campfire: hasReusablePoliciesConnectedToCampfire, }, - undefined, - undefined, - undefined, - undefined, - undefined, - accountingIcons, - cardFeeds, - cardLists, - isIntuitEnterpriseSuite, + {expensifyIcons: accountingIcons, cardFeeds, cardList: cardLists, isIntuitEnterpriseSuiteOverride: isIntuitEnterpriseSuite}, ); if (!integrationData) { return undefined; @@ -645,15 +637,14 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { dualEntry: hasReusablePoliciesConnectedToDualEntry, campfire: hasReusablePoliciesConnectedToCampfire, }, - policy, - undefined, - undefined, - undefined, - isBetaEnabled(CONST.BETAS.NETSUITE_USA_TAX), - accountingIcons, - cardFeeds, - cardLists, - isConnectedToIntuitEnterpriseSuite, + { + policy, + canUseNetSuiteUSATax: isBetaEnabled(CONST.BETAS.NETSUITE_USA_TAX), + expensifyIcons: accountingIcons, + cardFeeds, + cardList: cardLists, + isIntuitEnterpriseSuiteOverride: isConnectedToIntuitEnterpriseSuite, + }, ); const iconProps = integrationData?.icon ? {icon: integrationData.icon, iconType: CONST.ICON_TYPE_AVATAR} : {}; @@ -845,15 +836,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { dualEntry: hasReusablePoliciesConnectedToDualEntry, campfire: hasReusablePoliciesConnectedToCampfire, }, - undefined, - undefined, - undefined, - undefined, - undefined, - accountingIcons, - cardFeeds, - cardLists, - isIntuitEnterpriseSuite, + {expensifyIcons: accountingIcons, cardFeeds, cardList: cardLists, isIntuitEnterpriseSuiteOverride: isIntuitEnterpriseSuite}, ); if (!integrationData) { return undefined; diff --git a/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.native.tsx b/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.native.tsx index f987325181cc..2af24d2f033d 100644 --- a/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.native.tsx +++ b/src/pages/workspace/accounting/netsuite/NetSuiteSetupPage/index.native.tsx @@ -1,4 +1,4 @@ -import usePermissions from '@hooks/usePermissions'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import {getNetSuiteSetupLink} from '@libs/actions/connections/NetSuiteCommands'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -7,7 +7,6 @@ import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import AccountingSetupWebViewPage from '@pages/workspace/accounting/AccountingSetupWebViewPage'; import {getAccountingConnectionsRoute} from '@pages/workspace/connections/utils'; -import CONST from '@src/CONST'; import type SCREENS from '@src/SCREENS'; import React from 'react'; @@ -17,7 +16,7 @@ type NetSuiteSetupPageProps = PlatformStackScreenProps ); } diff --git a/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteExistingConnectionsPage.tsx b/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteExistingConnectionsPage.tsx index 0b522fa9c2a6..b2a7fc4ceffa 100644 --- a/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteExistingConnectionsPage.tsx +++ b/src/pages/workspace/accounting/netsuite/NetSuiteTokenInput/NetSuiteExistingConnectionsPage.tsx @@ -1,8 +1,8 @@ import ConnectionLayout from '@components/ConnectionLayout'; import MenuItemList from '@components/MenuItemList'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import useLocalize from '@hooks/useLocalize'; -import usePermissions from '@hooks/usePermissions'; import useReusablePoliciesConnectedTo from '@hooks/useReusablePoliciesConnectedTo'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -25,8 +25,8 @@ function NetSuiteExistingConnectionsPage({route}: ExistingConnectionsPageProps) const {translate, datetimeToRelative} = useLocalize(); const styles = useThemeStyles(); const policyID: string = route.params.policyID; - const {isBetaEnabled} = usePermissions(); - const accountingConnectionsRoute = getAccountingConnectionsRoute(isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS), policyID); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); + const accountingConnectionsRoute = getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID); const {reusablePoliciesConnectedTo: reusablePoliciesConnectedToNetSuite} = useReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.NETSUITE, policyID); const menuItems = reusablePoliciesConnectedToNetSuite.map((policy) => { diff --git a/src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupFlowSyncPage.tsx b/src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupFlowSyncPage.tsx index 5e892f134706..9acc7157d51b 100644 --- a/src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupFlowSyncPage.tsx +++ b/src/pages/workspace/accounting/qbd/QuickBooksDesktopSetupFlowSyncPage.tsx @@ -1,5 +1,5 @@ +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import {isConnectionInProgress, syncConnection} from '@libs/actions/connections'; import Navigation from '@libs/Navigation/Navigation'; @@ -20,7 +20,7 @@ function QuickBooksDesktopSetupFlowSyncPage({route}: QuickBooksDesktopSetupFlowS const policyID: string = route.params.policyID; const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID ?? '-1'}`); const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID ?? '-1'}`); - const {isBetaEnabled} = usePermissions(); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); useEffect(() => { if (!policyID) { @@ -32,7 +32,7 @@ function QuickBooksDesktopSetupFlowSyncPage({route}: QuickBooksDesktopSetupFlowS syncConnection(policy, CONST.POLICY.CONNECTIONS.NAME.QBD, true); } - Navigation.navigate(getAccountingConnectionsRoute(isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS), policyID)); + Navigation.navigate(getAccountingConnectionsRoute(isUnifiedConnectionsBetaEnabled, policyID)); // disabling this rule, as we want this to run only on the first render // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/pages/workspace/accounting/utils.tsx b/src/pages/workspace/accounting/utils.tsx index 3db2012aadef..a40d0b0f9b54 100644 --- a/src/pages/workspace/accounting/utils.tsx +++ b/src/pages/workspace/accounting/utils.tsx @@ -65,35 +65,55 @@ function getCurrentAccountingIntegrationName(policy: OnyxEntry, translat return currentConnectionName === CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY.quickbooksOnline ? getQuickbooksOnlineIntegrationName(policy, translate) : currentConnectionName; } -// eslint-disable-next-line @typescript-eslint/max-params +type AccountingIntegrationIcons = Record< + | 'IntacctSquare' + | 'IntuitSquare' + | 'QBOSquare' + | 'XeroSquare' + | 'NetSuiteSquare' + | 'QBDSquare' + | 'CertiniaSquare' + | 'RilletSquare' + | 'DualEntrySquare' + | 'CampfireSquare' + | 'BusinessCentralSquare', + IconAsset +>; + +type GetAccountingIntegrationDataOptions = { + policy?: Policy; + key?: number; + integrationToDisconnect?: ConnectionName; + shouldDisconnectIntegrationBeforeConnecting?: boolean; + canUseNetSuiteUSATax?: boolean; + expensifyIcons?: AccountingIntegrationIcons; + cardFeeds?: CombinedCardFeeds; + cardList?: Record; + + /** Picks QBO or Intuit Enterprise Suite explicitly instead of following the existing connection */ + isIntuitEnterpriseSuiteOverride?: boolean; + + /** Routes the connect flows through the Connections page instead of the Accounting page */ + isUnifiedConnectionsBetaEnabled?: boolean; +}; + function getAccountingIntegrationData( connectionName: PolicyConnectionName, policyID: string, translate: LocaleContextProps['translate'], existingConnections: {sageIntacct: boolean; qbd: boolean; certinia: boolean; rillet: boolean; dualEntry: boolean; campfire: boolean}, - policy?: Policy, - key?: number, - integrationToDisconnect?: ConnectionName, - shouldDisconnectIntegrationBeforeConnecting?: boolean, - canUseNetSuiteUSATax?: boolean, - expensifyIcons?: Record< - | 'IntacctSquare' - | 'IntuitSquare' - | 'QBOSquare' - | 'XeroSquare' - | 'NetSuiteSquare' - | 'QBDSquare' - | 'CertiniaSquare' - | 'RilletSquare' - | 'DualEntrySquare' - | 'CampfireSquare' - | 'BusinessCentralSquare', - IconAsset - >, - cardFeeds?: CombinedCardFeeds, - cardList?: Record, - isIntuitEnterpriseSuiteOverride?: boolean, - isUnifiedConnectionsBetaEnabled = false, + { + policy, + key, + integrationToDisconnect, + shouldDisconnectIntegrationBeforeConnecting, + canUseNetSuiteUSATax, + expensifyIcons, + cardFeeds, + cardList, + isIntuitEnterpriseSuiteOverride, + isUnifiedConnectionsBetaEnabled = false, + }: GetAccountingIntegrationDataOptions = {}, ): AccountingIntegration | undefined { const qboConfig = policy?.connections?.quickbooksOnline?.config; const qbdConfig = policy?.connections?.quickbooksDesktop?.config; diff --git a/src/pages/workspace/connections/ConnectionsAccountingPage.tsx b/src/pages/workspace/connections/ConnectionsAccountingPage.tsx index d22b8488c2aa..b21e3ee5c853 100644 --- a/src/pages/workspace/connections/ConnectionsAccountingPage.tsx +++ b/src/pages/workspace/connections/ConnectionsAccountingPage.tsx @@ -17,6 +17,7 @@ import useConfirmModal from '@hooks/useConfirmModal'; import useEnvironment from '@hooks/useEnvironment'; import useExpensifyCardFeeds from '@hooks/useExpensifyCardFeeds'; import useHasReusablePoliciesConnectedTo from '@hooks/useHasReusablePoliciesConnectedTo'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -85,6 +86,7 @@ function ConnectionsAccountingPage({policy}: PolicyAccountingPageProps) { const oldDotEnvironmentURL = getOldDotURLFromEnvironment(environment); const {isOffline} = useNetwork(); const {isBetaEnabled} = usePermissions(); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); const {showConfirmModal} = useConfirmModal(); const {activeIntegration} = useAccountingState(); const {startIntegrationFlow} = useAccountingActions(); @@ -430,16 +432,15 @@ function ConnectionsAccountingPage({policy}: PolicyAccountingPageProps) { dualEntry: hasReusablePoliciesConnectedToDualEntry, campfire: hasReusablePoliciesConnectedToCampfire, }, - policy, - undefined, - undefined, - undefined, - isBetaEnabled(CONST.BETAS.NETSUITE_USA_TAX), - accountingIcons, - cardFeeds, - cardLists, - isConnectedToIntuitEnterpriseSuite, - isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS), + { + policy, + canUseNetSuiteUSATax: isBetaEnabled(CONST.BETAS.NETSUITE_USA_TAX), + expensifyIcons: accountingIcons, + cardFeeds, + cardList: cardLists, + isIntuitEnterpriseSuiteOverride: isConnectedToIntuitEnterpriseSuite, + isUnifiedConnectionsBetaEnabled, + }, ); let connectionMessage; if (isSyncInProgress && connectionSyncStage) { @@ -538,7 +539,7 @@ function ConnectionsAccountingPage({policy}: PolicyAccountingPageProps) { policyID={policyID} featureName={CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED} policyFeature={CONST.POLICY.POLICY_FEATURE.ACCOUNTING} - shouldBeBlocked={!isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS) || (!connectedIntegration && !hasUnsupportedNDIntegration)} + shouldBeBlocked={!isUnifiedConnectionsBetaEnabled || (!connectedIntegration && !hasUnsupportedNDIntegration)} > card.isConnected)} + shouldBeBlocked={(shouldBeBlocked ?? false) || !isUnifiedConnectionsBetaEnabled || !cards.some((card) => card.isConnected)} > {isLoading ? ( diff --git a/src/pages/workspace/connections/useAccountingConnectionListings.ts b/src/pages/workspace/connections/useAccountingConnectionListings.ts index 28b587f985c6..de88586e2bb2 100644 --- a/src/pages/workspace/connections/useAccountingConnectionListings.ts +++ b/src/pages/workspace/connections/useAccountingConnectionListings.ts @@ -1,3 +1,4 @@ +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; /** * Builds the accounting listings for the Connections page and starts the accounting connect flows from them. * Must be rendered inside an AccountingContextProvider. @@ -12,7 +13,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {isConnectionInProgress, isConnectionUnverified} from '@libs/actions/connections'; import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; import Navigation from '@libs/Navigation/Navigation'; -import {getConnectedIntegration, getIntegrationLastSuccessfulDate, tryNavigateToSubmitWorkspaceUpgrade} from '@libs/PolicyUtils'; +import {getConnectedIntegration, getIntegrationLastSuccessfulDate} from '@libs/PolicyUtils'; import {useAccountingActions, useAccountingState} from '@pages/workspace/accounting/AccountingContext'; import {getAccountingIntegrationData, getSynchronizationErrorMessage, isIntuitEnterpriseSuiteConnection} from '@pages/workspace/accounting/utils'; @@ -37,6 +38,7 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL const styles = useThemeStyles(); const {translate, datetimeToRelative, getLocalDateFromDatetime} = useLocalize(); const {isBetaEnabled} = usePermissions(); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); const {startIntegrationFlow} = useAccountingActions(); const {activeIntegration, popoverAnchorRefs} = useAccountingState(); const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); @@ -81,9 +83,6 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL showReadOnlyModal(); return; } - if (tryNavigateToSubmitWorkspaceUpgrade(policy, true, CONST.UPGRADE_FEATURE_INTRO_MAPPING.accounting.alias, ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID))) { - return; - } startIntegrationFlow( connectedIntegration ? {name, isIntuitEnterpriseSuite, integrationToDisconnect: connectedIntegration, shouldDisconnectIntegrationBeforeConnecting: true} @@ -123,22 +122,11 @@ function useAccountingConnectionListings(policy: OnyxEntry): ConnectionL ]); return integrationOptions.flatMap(({name, isIntuitEnterpriseSuite}) => { - const integrationData = getAccountingIntegrationData( - name, - policyID, - translate, - NO_REUSABLE_CONNECTIONS, - undefined, - undefined, - undefined, - undefined, - undefined, - accountingIcons, - undefined, - undefined, - isIntuitEnterpriseSuite, - isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS), - ); + const integrationData = getAccountingIntegrationData(name, policyID, translate, NO_REUSABLE_CONNECTIONS, { + expensifyIcons: accountingIcons, + isIntuitEnterpriseSuiteOverride: isIntuitEnterpriseSuite, + isUnifiedConnectionsBetaEnabled, + }); if (!integrationData) { return []; } diff --git a/src/pages/workspace/connections/useMCPConnectionListings.ts b/src/pages/workspace/connections/useMCPConnectionListings.ts index ac5a21dce8f6..d5ae04afa79d 100644 --- a/src/pages/workspace/connections/useMCPConnectionListings.ts +++ b/src/pages/workspace/connections/useMCPConnectionListings.ts @@ -5,10 +5,8 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; -import {isMCPEnabled} from '@libs/PolicyUtils'; - import {openExternalLink} from '@userActions/Link'; -import {enablePolicyMCP} from '@userActions/Policy/Policy'; +import {enablePolicyFeatureForConnection} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import type {Policy} from '@src/types/onyx'; @@ -45,9 +43,7 @@ function useMCPConnectionListings(policy: OnyxEntry): ConnectionListing[ showReadOnlyModal(); return; } - if (!isMCPEnabled(policy)) { - enablePolicyMCP(policyID, true, false); - } + enablePolicyFeatureForConnection(policy, CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED); openExternalLink(url); }, })); diff --git a/src/pages/workspace/connections/useMergeConnectionListings.ts b/src/pages/workspace/connections/useMergeConnectionListings.ts index b6213d36f095..8249f8c6c243 100644 --- a/src/pages/workspace/connections/useMergeConnectionListings.ts +++ b/src/pages/workspace/connections/useMergeConnectionListings.ts @@ -19,7 +19,7 @@ import {getHRCards} from '@pages/workspace/hr/utils'; import type {MergeProviderCardCategory, MergeProviderCardDescriptor} from '@pages/workspace/merge/types'; import {getRecruitingCards} from '@pages/workspace/recruiting/utils'; -import {enablePolicyHR, enablePolicyRecruiting} from '@userActions/Policy/Policy'; +import {enablePolicyFeatureForConnection} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -36,13 +36,11 @@ const CATEGORY_CONFIG = { [CONST.POLICY.CONNECTIONS.CATEGORY.HR]: { featureName: CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED, upgradeAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.hr.alias, - enableFeature: enablePolicyHR, getConfigureRoute: ROUTES.WORKSPACE_CONNECTIONS_HR.getRoute, }, [CONST.POLICY.CONNECTIONS.CATEGORY.RECRUITING]: { featureName: CONST.POLICY.MORE_FEATURES.IS_RECRUITING_ENABLED, upgradeAlias: CONST.UPGRADE_FEATURE_INTRO_MAPPING.recruiting.alias, - enableFeature: enablePolicyRecruiting, getConfigureRoute: ROUTES.WORKSPACE_CONNECTIONS_RECRUITING.getRoute, }, } as const; @@ -94,9 +92,7 @@ function useMergeConnectionListings(policy: OnyxEntry, onStartSetup: (se const {setupLink} = card; const startSetup = () => { - if (!policy?.[config.featureName]) { - config.enableFeature(policyID, true, false); - } + enablePolicyFeatureForConnection(policy, config.featureName); onStartSetup(setupLink, card.category); }; diff --git a/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts index 37450014437e..19a0748d79d6 100644 --- a/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts +++ b/src/pages/workspace/connections/useReceiptPartnerConnectionListings.ts @@ -11,7 +11,7 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea import Navigation from '@libs/Navigation/Navigation'; import {openExternalLink} from '@userActions/Link'; -import {enablePolicyReceiptPartners} from '@userActions/Policy/Policy'; +import {enablePolicyFeatureForConnection} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; @@ -61,9 +61,7 @@ function useReceiptPartnerConnectionListings(policy: OnyxEntry): Connect if (!connectFormData) { return; } - if (!policy?.receiptPartners?.enabled) { - enablePolicyReceiptPartners(policyID, true, false); - } + enablePolicyFeatureForConnection(policy, CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED); hasStartedUberConnectionRef.current = true; openExternalLink(`${CONST.UBER_CONNECT_URL}?${connectFormData}`); }; diff --git a/src/pages/workspace/connections/withUnifiedConnectionsBeta.tsx b/src/pages/workspace/connections/withUnifiedConnectionsBeta.tsx index 9071e8bcdf3a..c5f9fe60decd 100644 --- a/src/pages/workspace/connections/withUnifiedConnectionsBeta.tsx +++ b/src/pages/workspace/connections/withUnifiedConnectionsBeta.tsx @@ -1,6 +1,4 @@ -import usePermissions from '@hooks/usePermissions'; - -import CONST from '@src/CONST'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import type {ComponentProps, ComponentType} from 'react'; @@ -16,9 +14,9 @@ type WorkspaceConnectionsPageProps = ComponentProps>(WrappedComponent: ComponentType): ComponentType { function WithUnifiedConnectionsBeta(props: TProps) { - const {isBetaEnabled} = usePermissions(); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); - if (isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS)) { + if (isUnifiedConnectionsBetaEnabled) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- these are the screen props of a page Connections replaced, and it only reads the policy ID and the connection to start from the route return ; } diff --git a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx index 75431c110b19..4baa052892e1 100644 --- a/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx +++ b/src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx @@ -3,6 +3,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; @@ -125,9 +126,9 @@ function WorkspaceUpgradePage({route}: WorkspaceUpgradePageProps) { const isUpgraded = !!policy?.type && upgradingFromSubmit !== undefined && (isControlPolicy(policy) || !!(upgradingFromSubmit && isPaidGroupPolicy(policy))); const {translate} = useLocalize(); - const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); + const {isBetaEnabledOrUnknown} = usePermissions(); const isVendorMatchingBetaEnabled = isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING); - const isUnifiedConnectionsBetaEnabled = isBetaEnabled(CONST.BETAS.UNIFIED_CONNECTIONS); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); const {accountID, email = ''} = useCurrentUserPersonalDetails(); const getReviewWorkspaceSettingsTaskCompletion = useReviewWorkspaceSettingsTaskCompletion(); const [priorFirstDayFreeTrial] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL); diff --git a/tests/unit/AccountingConnectionConfirmationTest.tsx b/tests/unit/AccountingConnectionConfirmationTest.tsx index e482ee4207f2..eaa05033e0a0 100644 --- a/tests/unit/AccountingConnectionConfirmationTest.tsx +++ b/tests/unit/AccountingConnectionConfirmationTest.tsx @@ -90,11 +90,11 @@ jest.mock('@hooks/useHasReusablePoliciesConnectedTo', () => ({ let mockEnabledBetas: string[] = []; jest.mock('@hooks/usePermissions', () => () => ({isBetaEnabled: (beta: string) => mockEnabledBetas.includes(beta)})); -const mockEnablePolicyConnections = jest.fn(); +const mockEnablePolicyFeatureForConnection = jest.fn(); jest.mock('@userActions/Policy/Policy', () => ({ __esModule: true, - enablePolicyConnections: (policyID: string, enabled: boolean, shouldGoBack: boolean) => { - mockEnablePolicyConnections(policyID, enabled, shouldGoBack); + enablePolicyFeatureForConnection: (policyToEnable: Policy | undefined, featureName: string) => { + mockEnablePolicyFeatureForConnection(policyToEnable?.id, featureName); }, })); @@ -367,7 +367,7 @@ describe('AccountingContextProvider connect-confirmation prompt', () => { expect(getShowConfirmModalOption('prompt')).toBe('workspace.connections.replaceConnectionPrompt:workspace.accounting.intuitEnterpriseSuite'); }); - it('should turn the Accounting feature on when a flow starts on a workspace that has it off', async () => { + it('should turn the Accounting feature on when a flow starts, since Connections has no toggle for it', async () => { // Given a workspace with the Accounting feature off, which Connections has no toggle for const ref = renderProvider({...policy, areConnectionsEnabled: false}); @@ -378,7 +378,7 @@ describe('AccountingContextProvider connect-confirmation prompt', () => { }); // Then the feature is turned on without navigating away, so the integration can be used once it connects - expect(mockEnablePolicyConnections).toHaveBeenCalledWith(POLICY_ID, true, false); + expect(mockEnablePolicyFeatureForConnection).toHaveBeenCalledWith(POLICY_ID, CONST.POLICY.MORE_FEATURES.ARE_CONNECTIONS_ENABLED); }); }); }); diff --git a/tests/unit/pages/workspace/accounting/qbd/subscribedSettingsTest.ts b/tests/unit/pages/workspace/accounting/qbd/subscribedSettingsTest.ts index 60bba39c2b72..7f02485d4e27 100644 --- a/tests/unit/pages/workspace/accounting/qbd/subscribedSettingsTest.ts +++ b/tests/unit/pages/workspace/accounting/qbd/subscribedSettingsTest.ts @@ -43,7 +43,7 @@ function getIntegrationData({ }, }); - return getAccountingIntegrationData(CONST.POLICY.CONNECTIONS.NAME.QBD, POLICY_ID, mockTranslate, EXISTING_CONNECTIONS, policy); + return getAccountingIntegrationData(CONST.POLICY.CONNECTIONS.NAME.QBD, POLICY_ID, mockTranslate, EXISTING_CONNECTIONS, {policy}); } describe('QBD subscribed settings', () => { diff --git a/tests/unit/pages/workspace/accounting/qbo/defaultVendorSubscriptionsTest.ts b/tests/unit/pages/workspace/accounting/qbo/defaultVendorSubscriptionsTest.ts index eb6770c1fe30..c6f78b9c7399 100644 --- a/tests/unit/pages/workspace/accounting/qbo/defaultVendorSubscriptionsTest.ts +++ b/tests/unit/pages/workspace/accounting/qbo/defaultVendorSubscriptionsTest.ts @@ -33,13 +33,9 @@ function buildPolicy(nonReimbursableExpensesExportDestination: NonReimbursableDe } function getIntegrationData(nonReimbursableExpensesExportDestination: NonReimbursableDestination, autoCreateVendor = false) { - return getAccountingIntegrationData( - CONST.POLICY.CONNECTIONS.NAME.QBO, - POLICY_ID, - mockTranslate, - EXISTING_CONNECTIONS, - buildPolicy(nonReimbursableExpensesExportDestination, autoCreateVendor), - ); + return getAccountingIntegrationData(CONST.POLICY.CONNECTIONS.NAME.QBO, POLICY_ID, mockTranslate, EXISTING_CONNECTIONS, { + policy: buildPolicy(nonReimbursableExpensesExportDestination, autoCreateVendor), + }); } describe('QBO default vendor export subscriptions', () => { From b7840665089684c18ad6ac5d489a9301db826e39 Mon Sep 17 00:00:00 2001 From: krishna2323 Date: Fri, 9 Oct 2026 23:57:06 +0530 Subject: [PATCH 51/51] Share the accounting, receipt partner and Merge logic between the old pages and Connections, and add tests Signed-off-by: krishna2323 --- .../accounting/PolicyAccountingPage.tsx | 690 +----------------- .../useConnectedAccountingIntegration.tsx | 573 +++++++++++++++ .../connections/ConnectionsAccountingPage.tsx | 520 +------------ .../ConnectionsMergeProviderCard.tsx | 122 +--- .../ConnectionsReceiptPartnersPage.tsx | 129 +--- .../useAccountingConnectionListings.ts | 2 +- .../workspace/merge/MergeProviderCard.tsx | 121 +-- .../merge/useMergeProviderCardDetails.tsx | 144 ++++ .../WorkspaceReceiptPartnersPage.tsx | 164 +---- .../useReceiptPartnersSettings.ts | 141 ++++ tests/actions/PolicyTest.ts | 32 + ...ctionsAccountingPageQBOTokenExpiryTest.tsx | 222 ------ tests/unit/ConnectionsPanelGatesTest.tsx | 169 +++++ ...PolicyAccountingPageQBOTokenExpiryTest.tsx | 35 +- .../useAccountingConnectionListingsTest.ts | 102 +++ tests/unit/useMCPConnectionListingsTest.ts | 56 ++ tests/unit/useMergeConnectionListingsTest.ts | 114 +++ ...useReceiptPartnerConnectionListingsTest.ts | 87 +++ tests/unit/withUnifiedConnectionsBetaTest.tsx | 53 ++ 19 files changed, 1627 insertions(+), 1849 deletions(-) create mode 100644 src/pages/workspace/accounting/useConnectedAccountingIntegration.tsx create mode 100644 src/pages/workspace/merge/useMergeProviderCardDetails.tsx create mode 100644 src/pages/workspace/receiptPartners/useReceiptPartnersSettings.ts delete mode 100644 tests/unit/ConnectionsAccountingPageQBOTokenExpiryTest.tsx create mode 100644 tests/unit/ConnectionsPanelGatesTest.tsx create mode 100644 tests/unit/useAccountingConnectionListingsTest.ts create mode 100644 tests/unit/useMCPConnectionListingsTest.ts create mode 100644 tests/unit/useMergeConnectionListingsTest.ts create mode 100644 tests/unit/useReceiptPartnerConnectionListingsTest.ts create mode 100644 tests/unit/withUnifiedConnectionsBetaTest.tsx diff --git a/src/pages/workspace/accounting/PolicyAccountingPage.tsx b/src/pages/workspace/accounting/PolicyAccountingPage.tsx index 4bb78bc4cff6..76519f4b39af 100644 --- a/src/pages/workspace/accounting/PolicyAccountingPage.tsx +++ b/src/pages/workspace/accounting/PolicyAccountingPage.tsx @@ -8,7 +8,6 @@ import Icon from '@components/Icon'; import MenuItem from '@components/MenuItem'; import MenuItemList from '@components/MenuItemList'; import type {MenuItemWithLink} from '@components/MenuItemList'; -import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -16,47 +15,17 @@ import Section from '@components/Section'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import ThreeDotsMenu from '@components/ThreeDotsMenu'; -import type ThreeDotsMenuProps from '@components/ThreeDotsMenu/types'; -import useCardFeeds from '@hooks/useCardFeeds'; -import useCardsLists from '@hooks/useCardsLists'; -import useConfirmModal from '@hooks/useConfirmModal'; -import useEnvironment from '@hooks/useEnvironment'; -import useExpensifyCardFeeds from '@hooks/useExpensifyCardFeeds'; -import useHasReusablePoliciesConnectedTo from '@hooks/useHasReusablePoliciesConnectedTo'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; -import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; -import {getQBORefreshTokenExpiryDate, getQBORefreshTokenExpiryStatus} from '@libs/AccountingUtils'; -import {isAuthenticationError, isConnectionInProgress, isConnectionUnverified, removePolicyConnection, syncConnection} from '@libs/actions/connections'; -import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; -import {isExpensifyCardFullySetUp} from '@libs/CardUtils'; -import DateUtils from '@libs/DateUtils'; -import {getOldDotURLFromEnvironment} from '@libs/Environment/Environment'; import getPlatform from '@libs/getPlatform'; -import { - areSettingsInErrorFields, - findCurrentXeroOrganization, - getConnectedIntegration, - getCurrentSageIntacctEntityName, - getCurrentXeroOrganizationName, - getIntegrationLastSuccessfulDate, - getXeroTenants, - hasAccountingConnections, - hasSupportedOnlyOnOldDotIntegration, - isControlPolicy, - settingsPendingAction, - shouldShowSyncError, -} from '@libs/PolicyUtils'; +import {hasAccountingConnections, isControlPolicy} from '@libs/PolicyUtils'; import Navigation from '@navigation/Navigation'; @@ -65,23 +34,20 @@ import withUnifiedConnectionsBeta from '@pages/workspace/connections/withUnified import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import {openOldDotLink} from '@userActions/Link'; -import {openPolicyExpensifyCardsPage} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {ConnectionName} from '@src/types/onyx/Policy'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {useFocusEffect, useRoute} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useMemo, useRef} from 'react'; import {View} from 'react-native'; import type {MenuItemData, PolicyAccountingPageProps} from './types'; import {AccountingContextProvider, useAccountingActions, useAccountingState} from './AccountingContext'; -import {getCertiniaSelectedCompanyID, isCertiniaFFAConnection} from './certinia/utils'; -import {getAccountingIntegrationData, getAccountingIntegrationDisplayName, getSynchronizationErrorMessage, isIntuitEnterpriseSuiteConnection} from './utils'; +import useConnectedAccountingIntegration from './useConnectedAccountingIntegration'; type RouteParams = { newConnectionName?: ConnectionName; @@ -92,25 +58,12 @@ type RouteParams = { function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { useWorkspaceDocumentTitle(policy?.name, 'workspace.common.accounting'); - const hasReusablePoliciesConnectedToSageIntacct = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, policy?.id); - const hasReusablePoliciesConnectedToQBD = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.QBD, policy?.id); - const hasReusablePoliciesConnectedToCertinia = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CERTINIA, policy?.id); - const hasReusablePoliciesConnectedToRillet = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.RILLET, policy?.id); - const hasReusablePoliciesConnectedToDualEntry = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, policy?.id); - const hasReusablePoliciesConnectedToCampfire = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE, policy?.id); - const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy?.id}`); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); const theme = useTheme(); const styles = useThemeStyles(); - const {translate, datetimeToRelative: getDatetimeToRelative, getLocalDateFromDatetime, dateFnsLocale} = useLocalize(); - const {environment} = useEnvironment(); - const oldDotEnvironmentURL = getOldDotURLFromEnvironment(environment); - const {isOffline} = useNetwork(); - const {isBetaEnabled} = usePermissions(); + const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {showConfirmModal} = useConfirmModal(); - const [datetimeToRelative, setDateTimeToRelative] = useState(''); - const {activeIntegration, popoverAnchorRefs} = useAccountingState(); + const {popoverAnchorRefs} = useAccountingState(); const {startIntegrationFlow} = useAccountingActions(); const [account] = useOnyx(ONYXKEYS.ACCOUNT); const {isLargeScreenWidth} = useResponsiveLayout(); @@ -121,172 +74,29 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { const shouldDisconnectIntegrationBeforeConnecting = params?.shouldDisconnectIntegrationBeforeConnecting; const shouldConnectToIntuitEnterpriseSuite = params?.isIntuitEnterpriseSuite === 'true'; const policyID = policy?.id; - const workspaceAccountID = useWorkspaceAccountID(policyID); - const allCardSettings = useExpensifyCardFeeds(policyID); - const isSyncInProgress = isConnectionInProgress(connectionSyncProgress, policy); - const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'CircularArrowBackwards', 'ExpensifyCard', 'Gear', 'Key', 'NewWindow', 'Pencil', 'QuestionMark', 'Send', 'Sync', 'Trashcan']); - const accountingIcons = useMemoizedLazyExpensifyIcons([ - 'IntacctSquare', - 'IntuitSquare', - 'QBOSquare', - 'XeroSquare', - 'NetSuiteSquare', - 'QBDSquare', - 'CertiniaSquare', - 'RilletSquare', - 'DualEntrySquare', - 'CampfireSquare', - 'BusinessCentralSquare', + const icons = useMemoizedLazyExpensifyIcons(['QuestionMark']); + const { + accountingIntegrations, + connectedIntegration, + isConnectedToIntuitEnterpriseSuite, + isSyncInProgress, + hasAccountingConnection, + hasSyncError, + hasUnsupportedNDIntegration, + synchronizationError, + canWriteAccounting, + showReadOnlyModal, + overflowMenu, + connectionDetails, + qboTokenExpiryHint, + oldDotPolicyConnectionsURL, + getAvailableIntegrationData, + } = useConnectedAccountingIntegration(policy, {menuItemWrapperStyle: [styles.sectionMenuItemTopDescription]}); + const accountingIntegrationOptions = accountingIntegrations.flatMap((name) => [ + {name, isIntuitEnterpriseSuite: name === CONST.POLICY.CONNECTIONS.NAME.QBO ? false : undefined}, + ...(name === CONST.POLICY.CONNECTIONS.NAME.QBO ? [{name, isIntuitEnterpriseSuite: true}] : []), ]); - const [cardFeeds] = useCardFeeds(policyID); - const [cardLists] = useCardsLists(); - const connectionSyncStage = connectionSyncProgress?.stageInProgress; - - const canUseBusinessCentralIntegration = isBetaEnabled(CONST.BETAS.BUSINESS_CENTRAL) || !!policy?.connections?.businessCentral; - const accountingIntegrations = useMemo( - () => - CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES.filter((name) => { - if (name === CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL) { - return canUseBusinessCentralIntegration; - } - return true; - }), - [canUseBusinessCentralIntegration], - ); - const accountingIntegrationOptions = useMemo( - () => - accountingIntegrations.flatMap((name) => [ - {name, isIntuitEnterpriseSuite: name === CONST.POLICY.CONNECTIONS.NAME.QBO ? false : undefined}, - ...(name === CONST.POLICY.CONNECTIONS.NAME.QBO ? [{name, isIntuitEnterpriseSuite: true}] : []), - ]), - [accountingIntegrations], - ); - const syncingAccountingIntegration = accountingIntegrations.find((integration) => integration === connectionSyncProgress?.connectionName); - const connectedIntegration = getConnectedIntegration(policy, accountingIntegrations) ?? syncingAccountingIntegration; - const isIntuitEnterpriseSuiteSyncInProgress = isSyncInProgress && activeIntegration?.name === CONST.POLICY.CONNECTIONS.NAME.QBO && activeIntegration.isIntuitEnterpriseSuite === true; - const isConnectedToIntuitEnterpriseSuite = - connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBO && (isIntuitEnterpriseSuiteConnection(policy) || isIntuitEnterpriseSuiteSyncInProgress); - const connectedIntegrationDisplayName = connectedIntegration ? getAccountingIntegrationDisplayName(policy, connectedIntegration, translate) : undefined; - const hasAccountingConnection = hasAccountingConnections(policy); - const {canWrite: canWriteAccounting, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); - const synchronizationError = connectedIntegration && getSynchronizationErrorMessage(policy, connectedIntegration, isSyncInProgress, translate, styles); - - const isSageIntacct = connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT; - const hasAuthError = !!connectedIntegration && !!synchronizationError && isAuthenticationError(policy, connectedIntegration); - // A QBO refresh token that is about to expire (or already has, without a sync failing yet) is warned about while the connection still looks healthy - const qboTokenExpiryStatus = - connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBO && !synchronizationError && !isSyncInProgress ? getQBORefreshTokenExpiryStatus(policy) : undefined; - const isQBOTokenExpiringSoon = !!qboTokenExpiryStatus; - const qboTokenExpiryDate = isQBOTokenExpiringSoon ? getQBORefreshTokenExpiryDate(policy) : undefined; - const shouldShowEnterCredentials = !!connectedIntegration && (hasAuthError || isSageIntacct || isQBOTokenExpiringSoon); - - // Get the last successful date of the integration. Then, if `connectionSyncProgress` is the same integration displayed and the state is 'jobDone', get the more recent update time of the two. - const successfulDate = getIntegrationLastSuccessfulDate( - getLocalDateFromDatetime, - connectedIntegration ? policy?.connections?.[connectedIntegration] : undefined, - connectedIntegration === connectionSyncProgress?.connectionName ? connectionSyncProgress : undefined, - ); - - const hasSyncError = shouldShowSyncError(policy, isSyncInProgress, accountingIntegrations); - const hasUnsupportedNDIntegration = !isEmptyObject(policy?.connections) && hasSupportedOnlyOnOldDotIntegration(policy); - - const tenants = useMemo(() => getXeroTenants(policy), [policy]); - const currentXeroOrganization = findCurrentXeroOrganization(tenants, policy?.connections?.xero?.config?.tenantID); const shouldShowSynchronizationError = !!synchronizationError; - const shouldShowReinstallConnectorMenuItem = shouldShowSynchronizationError && connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBD; - const shouldShowCardReconciliationOption = Object.values(allCardSettings ?? {})?.some((cardSetting) => isExpensifyCardFullySetUp(policy, cardSetting)); - const shouldShowReconnect = hasAuthError && connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.CERTINIA; - let credentialsMenuTextKey: Parameters[0] = 'workspace.accounting.enterCredentials'; - if (shouldShowReconnect || isQBOTokenExpiringSoon) { - credentialsMenuTextKey = 'workspace.accounting.reconnect'; - } else if (isSageIntacct && !hasAuthError) { - credentialsMenuTextKey = 'workspace.accounting.updateCredentials'; - } - - const overflowMenu: ThreeDotsMenuProps['menuItems'] = useMemo( - () => [ - ...(shouldShowReinstallConnectorMenuItem - ? [ - { - icon: icons.CircularArrowBackwards, - text: translate('workspace.accounting.reinstall'), - onSelected: () => startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.QBD}), - shouldCallAfterModalHide: true, - disabled: isOffline, - iconRight: icons.NewWindow, - }, - ] - : []), - ...(shouldShowEnterCredentials - ? [ - { - icon: icons.Key, - text: translate(credentialsMenuTextKey), - onSelected: () => { - if (isSageIntacct && policyID) { - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTER_CREDENTIALS.getRoute(policyID)); - return; - } - startIntegrationFlow({name: connectedIntegration, isIntuitEnterpriseSuite: isConnectedToIntuitEnterpriseSuite}); - }, - shouldCallAfterModalHide: true, - disabled: isOffline, - iconRight: icons.NewWindow, - }, - ] - : []), - ...(!hasAuthError - ? [ - { - icon: icons.Sync, - text: translate('workspace.accounting.syncNow'), - onSelected: () => syncConnection(policy, connectedIntegration), - disabled: isOffline, - }, - ] - : []), - { - icon: icons.Trashcan, - text: translate('workspace.accounting.disconnect'), - onSelected: () => { - showConfirmModal({ - title: translate('workspace.accounting.disconnectTitle', connectedIntegrationDisplayName), - prompt: translate('workspace.accounting.disconnectPrompt', connectedIntegrationDisplayName), - confirmText: translate('workspace.accounting.disconnect'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }).then(({action}) => { - if (action !== ModalActions.CONFIRM || !connectedIntegration || !policyID) { - return; - } - removePolicyConnection(policy, connectedIntegration); - }); - }, - shouldCallAfterModalHide: true, - }, - ], - [ - icons.NewWindow, - icons.CircularArrowBackwards, - icons.Key, - icons.Sync, - icons.Trashcan, - shouldShowEnterCredentials, - shouldShowReinstallConnectorMenuItem, - translate, - isOffline, - policy, - connectedIntegration, - connectedIntegrationDisplayName, - isConnectedToIntuitEnterpriseSuite, - startIntegrationFlow, - isSageIntacct, - hasAuthError, - credentialsMenuTextKey, - policyID, - showConfirmModal, - ], - ); // `startIntegrationFlow` changes identity whenever `policy` does, which re-runs this effect. The // Navigation.setParams below clears newConnectionName through a navigation state update that lands in a later @@ -324,241 +134,11 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { }, [newConnectionName, shouldConnectToIntuitEnterpriseSuite, integrationToDisconnect, shouldDisconnectIntegrationBeforeConnecting, policy, startIntegrationFlow, canWriteAccounting]), ); - useEffect(() => { - if (successfulDate) { - setDateTimeToRelative(getDatetimeToRelative(successfulDate)); - return; - } - setDateTimeToRelative(''); - }, [getDatetimeToRelative, successfulDate]); - - useEffect(() => { - if (!policyID || !policy?.areExpensifyCardsEnabled || !workspaceAccountID) { - return; - } - openPolicyExpensifyCardsPage(policyID, workspaceAccountID); - }, [policyID, policy?.areExpensifyCardsEnabled, workspaceAccountID]); - - const integrationSpecificMenuItems = useMemo(() => { - const sageIntacctEntityList = policy?.connections?.intacct?.data?.entities ?? []; - const netSuiteSubsidiaryList = policy?.connections?.netsuite?.options?.data?.subsidiaryList ?? []; - const rilletSubsidiaryList = policy?.connections?.rillet?.data?.subsidiaries; - const dualEntryCompanyList = policy?.connections?.dualEntry?.data?.companies; - const campfireSubsidiaryList = policy?.connections?.campfire?.data?.subsidiaries; - const businessCentralCompanyList = policy?.connections?.businessCentral?.data?.companies; - const certiniaConfig = policy?.connections?.financialforce?.config; - const certiniaCompanies = policy?.connections?.financialforce?.data?.companies ?? []; - const certiniaCompanyID = getCertiniaSelectedCompanyID(certiniaConfig); - const certiniaCompanyField = certiniaConfig?.hasPSA ? CONST.CERTINIA_CONFIG.COMPANY_ID : CONST.CERTINIA_CONFIG.COMPANY; - const selectedCertiniaCompany = certiniaCompanies.find((company) => company.id === certiniaCompanyID); - switch (connectedIntegration) { - case CONST.POLICY.CONNECTIONS.NAME.XERO: - return !policy?.connections?.xero?.data?.tenants - ? {} - : { - description: translate('workspace.xero.organization'), - iconRight: icons.ArrowRight, - title: getCurrentXeroOrganizationName(policy), - wrapperStyle: [styles.sectionMenuItemTopDescription], - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting && tenants.length > 1, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - onPress: - canWriteAccounting && tenants.length > 1 - ? () => { - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_XERO_ORGANIZATION.getRoute(policyID, currentXeroOrganization?.id)); - } - : undefined, - pendingAction: settingsPendingAction([CONST.XERO_CONFIG.TENANT_ID], policy?.connections?.xero?.config?.pendingFields), - brickRoadIndicator: areSettingsInErrorFields([CONST.XERO_CONFIG.TENANT_ID], policy?.connections?.xero?.config?.errorFields) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.NETSUITE: - return !policy?.connections?.netsuite?.options?.config?.subsidiary - ? {} - : { - description: translate('workspace.netsuite.subsidiary'), - iconRight: icons.ArrowRight, - title: policy?.connections?.netsuite?.options?.config?.subsidiary ?? '', - wrapperStyle: [styles.sectionMenuItemTopDescription], - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting && netSuiteSubsidiaryList?.length > 1, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - pendingAction: policy?.connections?.netsuite?.options?.config?.pendingFields?.subsidiary, - brickRoadIndicator: policy?.connections?.netsuite?.options?.config?.errorFields?.subsidiary ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: - canWriteAccounting && netSuiteSubsidiaryList?.length > 1 - ? () => { - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR.getRoute(policyID)); - } - : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT: - return !sageIntacctEntityList.length - ? {} - : { - description: translate('workspace.intacct.entity'), - iconRight: icons.ArrowRight, - title: getCurrentSageIntacctEntityName(policy, translate('workspace.common.topLevel')), - wrapperStyle: [styles.sectionMenuItemTopDescription], - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - pendingAction: policy?.connections?.intacct?.config?.pendingFields?.entity, - brickRoadIndicator: policy?.connections?.intacct?.config?.errorFields?.entity ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: canWriteAccounting ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTITY.getRoute(policyID)) : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.QBO: - return !policy?.connections?.quickbooksOnline?.config?.companyName - ? {} - : { - description: translate(isConnectedToIntuitEnterpriseSuite ? 'workspace.qbo.entity' : 'workspace.qbo.connectedTo'), - iconRight: isConnectedToIntuitEnterpriseSuite ? icons.ArrowRight : undefined, - title: policy?.connections?.quickbooksOnline?.config?.companyName, - wrapperStyle: [styles.sectionMenuItemTopDescription], - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: isConnectedToIntuitEnterpriseSuite && canWriteAccounting, - shouldShowDescriptionOnTop: true, - interactive: isConnectedToIntuitEnterpriseSuite && canWriteAccounting, - pendingAction: policy?.connections?.quickbooksOnline?.config?.pendingFields?.realmId, - brickRoadIndicator: policy?.connections?.quickbooksOnline?.config?.errorFields?.realmId ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: - isConnectedToIntuitEnterpriseSuite && canWriteAccounting && policyID - ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_INTUIT_ENTERPRISE_SUITE_ENTITY_SELECTOR.getRoute(policyID)) - : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.CERTINIA: - return !isCertiniaFFAConnection(certiniaConfig) - ? {} - : { - description: translate('workspace.certinia.company'), - iconRight: icons.ArrowRight, - title: selectedCertiniaCompany?.name ?? certiniaCompanyID ?? translate('common.none'), - wrapperStyle: [styles.sectionMenuItemTopDescription], - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - pendingAction: settingsPendingAction([certiniaCompanyField], certiniaConfig?.pendingFields), - brickRoadIndicator: areSettingsInErrorFields([certiniaCompanyField], certiniaConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: canWriteAccounting ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CERTINIA_COMPANY_SELECTOR.getRoute(policyID)) : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.RILLET: - return !rilletSubsidiaryList?.length - ? {} - : { - description: translate('workspace.rillet.subsidiary'), - iconRight: icons.ArrowRight, - title: rilletSubsidiaryList?.find((subsidiary) => subsidiary.id === policy?.connections?.rillet?.config?.subsidiaryID)?.tradeName ?? '', - wrapperStyle: [styles.sectionMenuItemTopDescription], - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting && rilletSubsidiaryList && rilletSubsidiaryList.length > 1, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - pendingAction: policy?.connections?.rillet?.config.pendingFields?.subsidiaryID, - brickRoadIndicator: policy?.connections?.rillet?.config.errorFields?.subsidiaryID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: - policyID && canWriteAccounting && rilletSubsidiaryList && rilletSubsidiaryList.length > 1 - ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_SUBSIDIARY_SELECTOR.getRoute(policyID)) - : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: - return !dualEntryCompanyList?.length - ? {} - : { - description: translate('workspace.dualEntry.subsidiary'), - iconRight: icons.ArrowRight, - title: dualEntryCompanyList?.find((company) => company.id === policy?.connections?.dualEntry?.config?.subsidiaryID)?.name ?? '', - wrapperStyle: [styles.sectionMenuItemTopDescription], - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting && dualEntryCompanyList && dualEntryCompanyList.length > 1, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - pendingAction: policy?.connections?.dualEntry?.config.pendingFields?.subsidiaryID, - brickRoadIndicator: policy?.connections?.dualEntry?.config.errorFields?.subsidiaryID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: - policyID && canWriteAccounting && dualEntryCompanyList && dualEntryCompanyList.length > 1 - ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_DUALENTRY_SUBSIDIARY_SELECTOR.getRoute(policyID)) - : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: - return !campfireSubsidiaryList?.length - ? {} - : { - description: translate('workspace.campfire.subsidiary'), - iconRight: icons.ArrowRight, - title: campfireSubsidiaryList?.find((subsidiary) => subsidiary.id === policy?.connections?.campfire?.config?.subsidiaryID)?.name ?? '', - wrapperStyle: [styles.sectionMenuItemTopDescription], - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting && campfireSubsidiaryList && campfireSubsidiaryList.length > 1, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - pendingAction: policy?.connections?.campfire?.config.pendingFields?.subsidiaryID, - brickRoadIndicator: policy?.connections?.campfire?.config.errorFields?.subsidiaryID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: - policyID && canWriteAccounting && campfireSubsidiaryList && campfireSubsidiaryList.length > 1 - ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CAMPFIRE_SUBSIDIARY_SELECTOR.getRoute(policyID)) - : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL: - return !businessCentralCompanyList?.length - ? {} - : { - description: translate('workspace.businessCentral.subsidiary'), - iconRight: icons.ArrowRight, - title: businessCentralCompanyList.find((company) => company.id === policy?.connections?.businessCentral?.config?.companyID)?.displayName ?? '', - wrapperStyle: [styles.sectionMenuItemTopDescription], - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting && businessCentralCompanyList.length > 1, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - pendingAction: policy?.connections?.businessCentral?.config.pendingFields?.companyID, - brickRoadIndicator: policy?.connections?.businessCentral?.config.errorFields?.companyID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: - policyID && canWriteAccounting && businessCentralCompanyList.length > 1 - ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_COMPANY_SELECTOR.getRoute(policyID)) - : undefined, - }; - - default: - return undefined; - } - }, [ - canWriteAccounting, - connectedIntegration, - currentXeroOrganization?.id, - isConnectedToIntuitEnterpriseSuite, - policy, - policyID, - styles.fontWeightNormal, - styles.sectionMenuItemTopDescription, - tenants.length, - translate, - icons.ArrowRight, - ]); - - const connectionsMenuItems: MenuItemData[] = useMemo(() => { + const getConnectionsMenuItems = (): MenuItemData[] => { if (!hasAccountingConnection && !isSyncInProgress && policyID) { return accountingIntegrationOptions .map(({name: integration, isIntuitEnterpriseSuite}) => { - const integrationData = getAccountingIntegrationData( - integration, - policyID, - translate, - { - sageIntacct: hasReusablePoliciesConnectedToSageIntacct, - qbd: hasReusablePoliciesConnectedToQBD, - certinia: hasReusablePoliciesConnectedToCertinia, - rillet: hasReusablePoliciesConnectedToRillet, - dualEntry: hasReusablePoliciesConnectedToDualEntry, - campfire: hasReusablePoliciesConnectedToCampfire, - }, - {expensifyIcons: accountingIcons, cardFeeds, cardList: cardLists, isIntuitEnterpriseSuiteOverride: isIntuitEnterpriseSuite}, - ); + const integrationData = getAvailableIntegrationData(integration, isIntuitEnterpriseSuite); if (!integrationData) { return undefined; } @@ -621,98 +201,10 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { .filter(Boolean) as MenuItemData[]; } - if (!connectedIntegration || !policyID) { + if (!connectionDetails) { return []; } - const isConnectionVerified = !isConnectionUnverified(policy, connectedIntegration); - const integrationData = getAccountingIntegrationData( - connectedIntegration, - policyID, - translate, - { - sageIntacct: hasReusablePoliciesConnectedToSageIntacct, - qbd: hasReusablePoliciesConnectedToQBD, - certinia: hasReusablePoliciesConnectedToCertinia, - rillet: hasReusablePoliciesConnectedToRillet, - dualEntry: hasReusablePoliciesConnectedToDualEntry, - campfire: hasReusablePoliciesConnectedToCampfire, - }, - { - policy, - canUseNetSuiteUSATax: isBetaEnabled(CONST.BETAS.NETSUITE_USA_TAX), - expensifyIcons: accountingIcons, - cardFeeds, - cardList: cardLists, - isIntuitEnterpriseSuiteOverride: isConnectedToIntuitEnterpriseSuite, - }, - ); - const iconProps = integrationData?.icon ? {icon: integrationData.icon, iconType: CONST.ICON_TYPE_AVATAR} : {}; - - let connectionMessage; - if (isSyncInProgress && connectionSyncStage) { - connectionMessage = translate('workspace.accounting.connections.syncStageName', connectionSyncStage, integrationData?.title); - } else if (!isConnectionVerified) { - connectionMessage = translate('workspace.accounting.notSync'); - } else { - connectionMessage = translate('workspace.accounting.lastSync', datetimeToRelative); - } - - const configurationOptions = canWriteAccounting - ? [ - { - icon: icons.Pencil, - iconRight: icons.ArrowRight, - shouldShowRightIcon: true, - title: translate('workspace.accounting.import'), - wrapperStyle: [styles.sectionMenuItemTopDescription], - onPress: integrationData?.onImportPagePress, - brickRoadIndicator: areSettingsInErrorFields(integrationData?.subscribedImportSettings, integrationData?.errorFields) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined, - pendingAction: settingsPendingAction(integrationData?.subscribedImportSettings, integrationData?.pendingFields), - }, - { - icon: icons.Send, - iconRight: icons.ArrowRight, - shouldShowRightIcon: true, - title: translate('workspace.accounting.export'), - wrapperStyle: [styles.sectionMenuItemTopDescription], - onPress: integrationData?.onExportPagePress, - brickRoadIndicator: - areSettingsInErrorFields(integrationData?.subscribedExportSettings, integrationData?.errorFields) || - shouldShowQBOReimbursableExportDestinationAccountError(policy) || - integrationData?.externalSubscribedExportSettingsHasErrorFields - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined, - pendingAction: - settingsPendingAction(integrationData?.subscribedExportSettings, integrationData?.pendingFields) ?? integrationData?.externalSubscribedExportSettingsPendingAction, - }, - ...(shouldShowCardReconciliationOption && integrationData?.onCardReconciliationPagePress - ? [ - { - icon: icons.ExpensifyCard, - iconRight: icons.ArrowRight, - shouldShowRightIcon: true, - title: translate('workspace.accounting.cardReconciliation'), - wrapperStyle: [styles.sectionMenuItemTopDescription], - onPress: integrationData?.onCardReconciliationPagePress, - }, - ] - : []), - { - icon: icons.Gear, - iconRight: icons.ArrowRight, - shouldShowRightIcon: true, - title: translate('workspace.accounting.advanced'), - wrapperStyle: [styles.sectionMenuItemTopDescription], - onPress: integrationData?.onAdvancedPagePress, - brickRoadIndicator: areSettingsInErrorFields(integrationData?.subscribedAdvancedSettings, integrationData?.errorFields) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined, - pendingAction: settingsPendingAction(integrationData?.subscribedAdvancedSettings, integrationData?.pendingFields), - }, - ] - : []; + const iconProps = connectionDetails.icon ? {icon: connectionDetails.icon, iconType: CONST.ICON_TYPE_AVATAR} : {}; let rightComponent; if (isSyncInProgress) { @@ -731,91 +223,26 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { ); } - let qboTokenExpiryHint; - if (qboTokenExpiryDate && canWriteAccounting) { - const formattedExpiryDate = DateUtils.formatWithUTCTimeZone(qboTokenExpiryDate.toISOString(), CONST.DATE.MONTH_DAY_YEAR_FORMAT, dateFnsLocale); - qboTokenExpiryHint = ( - <> - {translate( - qboTokenExpiryStatus === CONST.POLICY.CONNECTIONS.QBO_REFRESH_TOKEN_EXPIRY_STATUS.EXPIRED - ? 'workspace.accounting.qboConnectionExpired' - : 'workspace.accounting.qboConnectionExpiring', - {date: formattedExpiryDate}, - )}{' '} - startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.QBO, isIntuitEnterpriseSuite: isConnectedToIntuitEnterpriseSuite})}> - {translate('workspace.accounting.reconnect')} - - - ); - } - return [ { ...iconProps, interactive: false, wrapperStyle: [styles.sectionMenuItemTopDescription, shouldShowSynchronizationError && styles.pb0], shouldShowRightComponent: true, - title: integrationData?.title, + title: connectionDetails.title, errorText: synchronizationError, errorTextStyle: [styles.mt5], shouldShowRedDotIndicator: true, - description: connectionMessage, + description: connectionDetails.connectionMessage, hintText: qboTokenExpiryHint, rightComponent, }, - ...(isEmptyObject(integrationSpecificMenuItems) || shouldShowSynchronizationError || !hasAccountingConnection ? [] : [integrationSpecificMenuItems]), - ...(!hasAccountingConnection || !isConnectionVerified ? [] : configurationOptions), + ...connectionDetails.settingsMenuItems, ]; - }, [ - policy, - hasAccountingConnection, - isSyncInProgress, - policyID, - connectedIntegration, - translate, - isBetaEnabled, - accountingIcons, - cardFeeds, - cardLists, - connectionSyncStage, - icons.Pencil, - icons.ArrowRight, - icons.ExpensifyCard, - icons.Gear, - icons.Send, - styles.sectionMenuItemTopDescription, - styles.pb0, - styles.mt5, - styles.popoverMenuIcon, - styles.mr3, - styles.justifyContentCenter, - styles.buttonOpacityDisabled, - styles.buttonDisabled, - shouldShowCardReconciliationOption, - shouldShowSynchronizationError, - synchronizationError, - overflowMenu, - integrationSpecificMenuItems, - accountingIntegrationOptions, - isConnectedToIntuitEnterpriseSuite, - shouldUseNarrowLayout, - startIntegrationFlow, - popoverAnchorRefs, - datetimeToRelative, - qboTokenExpiryDate, - qboTokenExpiryStatus, - dateFnsLocale, - hasReusablePoliciesConnectedToSageIntacct, - hasReusablePoliciesConnectedToCertinia, - hasReusablePoliciesConnectedToRillet, - hasReusablePoliciesConnectedToDualEntry, - hasReusablePoliciesConnectedToCampfire, - hasReusablePoliciesConnectedToQBD, - canWriteAccounting, - showReadOnlyModal, - ]); + }; + const connectionsMenuItems = getConnectionsMenuItems(); - const otherIntegrationsItems = useMemo(() => { + const getOtherIntegrationsItems = () => { if ((!hasAccountingConnection && !isSyncInProgress) || !policyID) { return; } @@ -824,20 +251,7 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { ); return otherIntegrations .map(({name: integration, isIntuitEnterpriseSuite}) => { - const integrationData = getAccountingIntegrationData( - integration, - policyID, - translate, - { - sageIntacct: hasReusablePoliciesConnectedToSageIntacct, - qbd: hasReusablePoliciesConnectedToQBD, - certinia: hasReusablePoliciesConnectedToCertinia, - rillet: hasReusablePoliciesConnectedToRillet, - dualEntry: hasReusablePoliciesConnectedToDualEntry, - campfire: hasReusablePoliciesConnectedToCampfire, - }, - {expensifyIcons: accountingIcons, cardFeeds, cardList: cardLists, isIntuitEnterpriseSuiteOverride: isIntuitEnterpriseSuite}, - ); + const integrationData = getAvailableIntegrationData(integration, isIntuitEnterpriseSuite); if (!integrationData) { return undefined; } @@ -887,32 +301,8 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { }; }) .filter(Boolean) as MenuItemWithLink[]; - }, [ - hasAccountingConnection, - isSyncInProgress, - accountingIntegrationOptions, - connectedIntegration, - isConnectedToIntuitEnterpriseSuite, - policyID, - translate, - hasReusablePoliciesConnectedToSageIntacct, - hasReusablePoliciesConnectedToCertinia, - hasReusablePoliciesConnectedToRillet, - hasReusablePoliciesConnectedToDualEntry, - hasReusablePoliciesConnectedToCampfire, - hasReusablePoliciesConnectedToQBD, - styles.justifyContentCenter, - styles.buttonOpacityDisabled, - styles.buttonDisabled, - styles.sectionMenuItemTopDescription, - startIntegrationFlow, - popoverAnchorRefs, - accountingIcons, - cardFeeds, - cardLists, - canWriteAccounting, - showReadOnlyModal, - ]); + }; + const otherIntegrationsItems = getOtherIntegrationsItems(); const [chatTextLink, chatReportID] = useMemo(() => { // If they have an onboarding specialist assigned display the following and link to the #admins room with the account executive. @@ -928,8 +318,6 @@ function PolicyAccountingPage({policy}: PolicyAccountingPageProps) { return [translate('workspace.accounting.talkToConcierge'), conciergeReportID]; }, [account?.accountManagerAccountID, account?.accountManagerReportID, conciergeReportID, policy?.chatReportIDAdmins, translate]); - const oldDotPolicyConnectionsURL = policyID ? `${oldDotEnvironmentURL}/${CONST.OLDDOT_URLS.POLICY_CONNECTIONS_URL_ENCODED(policyID)}` : ''; - return ( ; + + /** Runs once the user confirms disconnecting, before the connection is removed */ + onDisconnectConfirmed?: () => void; +}; + +/** + * Everything the Accounting page and the Connections accounting panel derive for the workspace's connected accounting + * integration: its status, the overflow menu, and the settings rows. + */ +function useConnectedAccountingIntegration(policy: OnyxEntry, {menuItemWrapperStyle, onDisconnectConfirmed}: UseConnectedAccountingIntegrationOptions = {}) { + const policyID = policy?.id; + const hasReusablePoliciesConnectedToSageIntacct = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, policyID); + const hasReusablePoliciesConnectedToQBD = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.QBD, policyID); + const hasReusablePoliciesConnectedToCertinia = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CERTINIA, policyID); + const hasReusablePoliciesConnectedToRillet = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.RILLET, policyID); + const hasReusablePoliciesConnectedToDualEntry = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, policyID); + const hasReusablePoliciesConnectedToCampfire = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE, policyID); + const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); + const styles = useThemeStyles(); + const {translate, datetimeToRelative: getDatetimeToRelative, getLocalDateFromDatetime, dateFnsLocale} = useLocalize(); + const {environment} = useEnvironment(); + const {isOffline} = useNetwork(); + const {isBetaEnabled} = usePermissions(); + const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); + const {showConfirmModal} = useConfirmModal(); + const {activeIntegration} = useAccountingState(); + const {startIntegrationFlow} = useAccountingActions(); + const workspaceAccountID = useWorkspaceAccountID(policyID); + const allCardSettings = useExpensifyCardFeeds(policyID); + const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'CircularArrowBackwards', 'ExpensifyCard', 'Gear', 'Key', 'NewWindow', 'Pencil', 'Send', 'Sync', 'Trashcan']); + const accountingIcons = useMemoizedLazyExpensifyIcons([ + 'IntacctSquare', + 'IntuitSquare', + 'QBOSquare', + 'XeroSquare', + 'NetSuiteSquare', + 'QBDSquare', + 'CertiniaSquare', + 'RilletSquare', + 'DualEntrySquare', + 'CampfireSquare', + 'BusinessCentralSquare', + ]); + const [cardFeeds] = useCardFeeds(policyID); + const [cardLists] = useCardsLists(); + const {canWrite: canWriteAccounting, showReadOnlyModal} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); + + const existingConnections = { + sageIntacct: hasReusablePoliciesConnectedToSageIntacct, + qbd: hasReusablePoliciesConnectedToQBD, + certinia: hasReusablePoliciesConnectedToCertinia, + rillet: hasReusablePoliciesConnectedToRillet, + dualEntry: hasReusablePoliciesConnectedToDualEntry, + campfire: hasReusablePoliciesConnectedToCampfire, + }; + + /** Data for an integration the workspace could connect, as listed with a Setup button */ + const getAvailableIntegrationData = (integration: ConnectionName, isIntuitEnterpriseSuite?: boolean) => + policyID + ? getAccountingIntegrationData(integration, policyID, translate, existingConnections, { + expensifyIcons: accountingIcons, + cardFeeds, + cardList: cardLists, + isIntuitEnterpriseSuiteOverride: isIntuitEnterpriseSuite, + }) + : undefined; + + const isSyncInProgress = isConnectionInProgress(connectionSyncProgress, policy); + const connectionSyncStage = connectionSyncProgress?.stageInProgress; + const canUseBusinessCentralIntegration = isBetaEnabled(CONST.BETAS.BUSINESS_CENTRAL) || !!policy?.connections?.businessCentral; + const accountingIntegrations = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES.filter((name) => { + if (name === CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL) { + return canUseBusinessCentralIntegration; + } + return true; + }); + const syncingAccountingIntegration = accountingIntegrations.find((integration) => integration === connectionSyncProgress?.connectionName); + const connectedIntegration = getConnectedIntegration(policy, accountingIntegrations) ?? syncingAccountingIntegration; + const isIntuitEnterpriseSuiteSyncInProgress = isSyncInProgress && activeIntegration?.name === CONST.POLICY.CONNECTIONS.NAME.QBO && activeIntegration.isIntuitEnterpriseSuite === true; + const isConnectedToIntuitEnterpriseSuite = + connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBO && (isIntuitEnterpriseSuiteConnection(policy) || isIntuitEnterpriseSuiteSyncInProgress); + const connectedIntegrationDisplayName = connectedIntegration ? getAccountingIntegrationDisplayName(policy, connectedIntegration, translate) : undefined; + const hasAccountingConnection = hasAccountingConnections(policy); + const synchronizationError = connectedIntegration && getSynchronizationErrorMessage(policy, connectedIntegration, isSyncInProgress, translate, styles); + + const isSageIntacct = connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT; + const hasAuthError = !!connectedIntegration && !!synchronizationError && isAuthenticationError(policy, connectedIntegration); + // A QBO refresh token that is about to expire (or already has, without a sync failing yet) is warned about while the connection still looks healthy + const qboTokenExpiryStatus = + connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBO && !synchronizationError && !isSyncInProgress ? getQBORefreshTokenExpiryStatus(policy) : undefined; + const isQBOTokenExpiringSoon = !!qboTokenExpiryStatus; + const qboTokenExpiryDate = isQBOTokenExpiringSoon ? getQBORefreshTokenExpiryDate(policy) : undefined; + const shouldShowEnterCredentials = !!connectedIntegration && (hasAuthError || isSageIntacct || isQBOTokenExpiringSoon); + + // Get the last successful date of the integration. Then, if `connectionSyncProgress` is the same integration displayed and the state is 'jobDone', get the more recent update time of the two. + const successfulDate = getIntegrationLastSuccessfulDate( + getLocalDateFromDatetime, + connectedIntegration ? policy?.connections?.[connectedIntegration] : undefined, + connectedIntegration === connectionSyncProgress?.connectionName ? connectionSyncProgress : undefined, + ); + const datetimeToRelative = successfulDate ? getDatetimeToRelative(successfulDate) : ''; + + const hasSyncError = shouldShowSyncError(policy, isSyncInProgress, accountingIntegrations); + const hasUnsupportedNDIntegration = !isEmptyObject(policy?.connections) && hasSupportedOnlyOnOldDotIntegration(policy); + + const tenants = getXeroTenants(policy); + const currentXeroOrganization = findCurrentXeroOrganization(tenants, policy?.connections?.xero?.config?.tenantID); + const shouldShowSynchronizationError = !!synchronizationError; + const shouldShowReinstallConnectorMenuItem = shouldShowSynchronizationError && connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBD; + const shouldShowCardReconciliationOption = Object.values(allCardSettings ?? {})?.some((cardSetting) => isExpensifyCardFullySetUp(policy, cardSetting)); + const shouldShowReconnect = hasAuthError && connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.CERTINIA; + let credentialsMenuTextKey: Parameters[0] = 'workspace.accounting.enterCredentials'; + if (shouldShowReconnect || isQBOTokenExpiringSoon) { + credentialsMenuTextKey = 'workspace.accounting.reconnect'; + } else if (isSageIntacct && !hasAuthError) { + credentialsMenuTextKey = 'workspace.accounting.updateCredentials'; + } + + const overflowMenu: ThreeDotsMenuProps['menuItems'] = [ + ...(shouldShowReinstallConnectorMenuItem + ? [ + { + icon: icons.CircularArrowBackwards, + text: translate('workspace.accounting.reinstall'), + onSelected: () => startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.QBD}), + shouldCallAfterModalHide: true, + disabled: isOffline, + iconRight: icons.NewWindow, + }, + ] + : []), + ...(shouldShowEnterCredentials + ? [ + { + icon: icons.Key, + text: translate(credentialsMenuTextKey), + onSelected: () => { + if (isSageIntacct && policyID) { + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTER_CREDENTIALS.getRoute(policyID)); + return; + } + startIntegrationFlow({name: connectedIntegration, isIntuitEnterpriseSuite: isConnectedToIntuitEnterpriseSuite}); + }, + shouldCallAfterModalHide: true, + disabled: isOffline, + iconRight: icons.NewWindow, + }, + ] + : []), + ...(!hasAuthError + ? [ + { + icon: icons.Sync, + text: translate('workspace.accounting.syncNow'), + onSelected: () => syncConnection(policy, connectedIntegration), + disabled: isOffline, + }, + ] + : []), + { + icon: icons.Trashcan, + text: translate('workspace.accounting.disconnect'), + onSelected: () => { + showConfirmModal({ + title: translate('workspace.accounting.disconnectTitle', connectedIntegrationDisplayName), + prompt: translate('workspace.accounting.disconnectPrompt', connectedIntegrationDisplayName), + confirmText: translate('workspace.accounting.disconnect'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }).then(({action}) => { + if (action !== ModalActions.CONFIRM || !connectedIntegration || !policyID) { + return; + } + onDisconnectConfirmed?.(); + removePolicyConnection(policy, connectedIntegration); + }); + }, + shouldCallAfterModalHide: true, + }, + ]; + + useEffect(() => { + if (!policyID || !policy?.areExpensifyCardsEnabled || !workspaceAccountID) { + return; + } + openPolicyExpensifyCardsPage(policyID, workspaceAccountID); + }, [policyID, policy?.areExpensifyCardsEnabled, workspaceAccountID]); + + const getIntegrationSpecificMenuItems = () => { + const sageIntacctEntityList = policy?.connections?.intacct?.data?.entities ?? []; + const netSuiteSubsidiaryList = policy?.connections?.netsuite?.options?.data?.subsidiaryList ?? []; + const rilletSubsidiaryList = policy?.connections?.rillet?.data?.subsidiaries; + const dualEntryCompanyList = policy?.connections?.dualEntry?.data?.companies; + const campfireSubsidiaryList = policy?.connections?.campfire?.data?.subsidiaries; + const businessCentralCompanyList = policy?.connections?.businessCentral?.data?.companies; + const certiniaConfig = policy?.connections?.financialforce?.config; + const certiniaCompanies = policy?.connections?.financialforce?.data?.companies ?? []; + const certiniaCompanyID = getCertiniaSelectedCompanyID(certiniaConfig); + const certiniaCompanyField = certiniaConfig?.hasPSA ? CONST.CERTINIA_CONFIG.COMPANY_ID : CONST.CERTINIA_CONFIG.COMPANY; + const selectedCertiniaCompany = certiniaCompanies.find((company) => company.id === certiniaCompanyID); + switch (connectedIntegration) { + case CONST.POLICY.CONNECTIONS.NAME.XERO: + return !policy?.connections?.xero?.data?.tenants + ? {} + : { + description: translate('workspace.xero.organization'), + iconRight: icons.ArrowRight, + title: getCurrentXeroOrganizationName(policy), + wrapperStyle: menuItemWrapperStyle, + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting && tenants.length > 1, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + onPress: + canWriteAccounting && tenants.length > 1 + ? () => { + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_XERO_ORGANIZATION.getRoute(policyID, currentXeroOrganization?.id)); + } + : undefined, + pendingAction: settingsPendingAction([CONST.XERO_CONFIG.TENANT_ID], policy?.connections?.xero?.config?.pendingFields), + brickRoadIndicator: areSettingsInErrorFields([CONST.XERO_CONFIG.TENANT_ID], policy?.connections?.xero?.config?.errorFields) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.NETSUITE: + return !policy?.connections?.netsuite?.options?.config?.subsidiary + ? {} + : { + description: translate('workspace.netsuite.subsidiary'), + iconRight: icons.ArrowRight, + title: policy?.connections?.netsuite?.options?.config?.subsidiary ?? '', + wrapperStyle: menuItemWrapperStyle, + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting && netSuiteSubsidiaryList?.length > 1, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: policy?.connections?.netsuite?.options?.config?.pendingFields?.subsidiary, + brickRoadIndicator: policy?.connections?.netsuite?.options?.config?.errorFields?.subsidiary ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: + canWriteAccounting && netSuiteSubsidiaryList?.length > 1 + ? () => { + Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR.getRoute(policyID)); + } + : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT: + return !sageIntacctEntityList.length + ? {} + : { + description: translate('workspace.intacct.entity'), + iconRight: icons.ArrowRight, + title: getCurrentSageIntacctEntityName(policy, translate('workspace.common.topLevel')), + wrapperStyle: menuItemWrapperStyle, + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: policy?.connections?.intacct?.config?.pendingFields?.entity, + brickRoadIndicator: policy?.connections?.intacct?.config?.errorFields?.entity ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: canWriteAccounting ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTITY.getRoute(policyID)) : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.QBO: + return !policy?.connections?.quickbooksOnline?.config?.companyName + ? {} + : { + description: translate(isConnectedToIntuitEnterpriseSuite ? 'workspace.qbo.entity' : 'workspace.qbo.connectedTo'), + iconRight: isConnectedToIntuitEnterpriseSuite ? icons.ArrowRight : undefined, + title: policy?.connections?.quickbooksOnline?.config?.companyName, + wrapperStyle: menuItemWrapperStyle, + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: isConnectedToIntuitEnterpriseSuite && canWriteAccounting, + shouldShowDescriptionOnTop: true, + interactive: isConnectedToIntuitEnterpriseSuite && canWriteAccounting, + pendingAction: policy?.connections?.quickbooksOnline?.config?.pendingFields?.realmId, + brickRoadIndicator: policy?.connections?.quickbooksOnline?.config?.errorFields?.realmId ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: + isConnectedToIntuitEnterpriseSuite && canWriteAccounting && policyID + ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_INTUIT_ENTERPRISE_SUITE_ENTITY_SELECTOR.getRoute(policyID)) + : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.CERTINIA: + return !isCertiniaFFAConnection(certiniaConfig) + ? {} + : { + description: translate('workspace.certinia.company'), + iconRight: icons.ArrowRight, + title: selectedCertiniaCompany?.name ?? certiniaCompanyID ?? translate('common.none'), + wrapperStyle: menuItemWrapperStyle, + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: settingsPendingAction([certiniaCompanyField], certiniaConfig?.pendingFields), + brickRoadIndicator: areSettingsInErrorFields([certiniaCompanyField], certiniaConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: canWriteAccounting ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CERTINIA_COMPANY_SELECTOR.getRoute(policyID)) : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.RILLET: + return !rilletSubsidiaryList?.length + ? {} + : { + description: translate('workspace.rillet.subsidiary'), + iconRight: icons.ArrowRight, + title: rilletSubsidiaryList?.find((subsidiary) => subsidiary.id === policy?.connections?.rillet?.config?.subsidiaryID)?.tradeName ?? '', + wrapperStyle: menuItemWrapperStyle, + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting && rilletSubsidiaryList && rilletSubsidiaryList.length > 1, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: policy?.connections?.rillet?.config.pendingFields?.subsidiaryID, + brickRoadIndicator: policy?.connections?.rillet?.config.errorFields?.subsidiaryID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: + policyID && canWriteAccounting && rilletSubsidiaryList && rilletSubsidiaryList.length > 1 + ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_SUBSIDIARY_SELECTOR.getRoute(policyID)) + : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: + return !dualEntryCompanyList?.length + ? {} + : { + description: translate('workspace.dualEntry.subsidiary'), + iconRight: icons.ArrowRight, + title: dualEntryCompanyList?.find((company) => company.id === policy?.connections?.dualEntry?.config?.subsidiaryID)?.name ?? '', + wrapperStyle: menuItemWrapperStyle, + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting && dualEntryCompanyList && dualEntryCompanyList.length > 1, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: policy?.connections?.dualEntry?.config.pendingFields?.subsidiaryID, + brickRoadIndicator: policy?.connections?.dualEntry?.config.errorFields?.subsidiaryID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: + policyID && canWriteAccounting && dualEntryCompanyList && dualEntryCompanyList.length > 1 + ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_DUALENTRY_SUBSIDIARY_SELECTOR.getRoute(policyID)) + : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: + return !campfireSubsidiaryList?.length + ? {} + : { + description: translate('workspace.campfire.subsidiary'), + iconRight: icons.ArrowRight, + title: campfireSubsidiaryList?.find((subsidiary) => subsidiary.id === policy?.connections?.campfire?.config?.subsidiaryID)?.name ?? '', + wrapperStyle: menuItemWrapperStyle, + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting && campfireSubsidiaryList && campfireSubsidiaryList.length > 1, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: policy?.connections?.campfire?.config.pendingFields?.subsidiaryID, + brickRoadIndicator: policy?.connections?.campfire?.config.errorFields?.subsidiaryID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: + policyID && canWriteAccounting && campfireSubsidiaryList && campfireSubsidiaryList.length > 1 + ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CAMPFIRE_SUBSIDIARY_SELECTOR.getRoute(policyID)) + : undefined, + }; + case CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL: + return !businessCentralCompanyList?.length + ? {} + : { + description: translate('workspace.businessCentral.subsidiary'), + iconRight: icons.ArrowRight, + title: businessCentralCompanyList.find((company) => company.id === policy?.connections?.businessCentral?.config?.companyID)?.displayName ?? '', + wrapperStyle: menuItemWrapperStyle, + titleStyle: styles.fontWeightNormal, + shouldShowRightIcon: canWriteAccounting && businessCentralCompanyList.length > 1, + shouldShowDescriptionOnTop: true, + interactive: canWriteAccounting, + pendingAction: policy?.connections?.businessCentral?.config.pendingFields?.companyID, + brickRoadIndicator: policy?.connections?.businessCentral?.config.errorFields?.companyID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, + onPress: + policyID && canWriteAccounting && businessCentralCompanyList.length > 1 + ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_COMPANY_SELECTOR.getRoute(policyID)) + : undefined, + }; + + default: + return undefined; + } + }; + const integrationSpecificMenuItems = getIntegrationSpecificMenuItems(); + + const getConnectionDetails = () => { + if (!connectedIntegration || !policyID) { + return undefined; + } + const isConnectionVerified = !isConnectionUnverified(policy, connectedIntegration); + const integrationData = getAccountingIntegrationData(connectedIntegration, policyID, translate, existingConnections, { + policy, + canUseNetSuiteUSATax: isBetaEnabled(CONST.BETAS.NETSUITE_USA_TAX), + expensifyIcons: accountingIcons, + cardFeeds, + cardList: cardLists, + isIntuitEnterpriseSuiteOverride: isConnectedToIntuitEnterpriseSuite, + isUnifiedConnectionsBetaEnabled, + }); + let connectionMessage; + if (isSyncInProgress && connectionSyncStage) { + connectionMessage = translate('workspace.accounting.connections.syncStageName', connectionSyncStage, integrationData?.title); + } else if (!isConnectionVerified) { + connectionMessage = translate('workspace.accounting.notSync'); + } else { + connectionMessage = translate('workspace.accounting.lastSync', datetimeToRelative); + } + + const configurationOptions = canWriteAccounting + ? [ + { + icon: icons.Pencil, + iconRight: icons.ArrowRight, + shouldShowRightIcon: true, + title: translate('workspace.accounting.import'), + wrapperStyle: menuItemWrapperStyle, + onPress: integrationData?.onImportPagePress, + brickRoadIndicator: areSettingsInErrorFields(integrationData?.subscribedImportSettings, integrationData?.errorFields) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : undefined, + pendingAction: settingsPendingAction(integrationData?.subscribedImportSettings, integrationData?.pendingFields), + }, + { + icon: icons.Send, + iconRight: icons.ArrowRight, + shouldShowRightIcon: true, + title: translate('workspace.accounting.export'), + wrapperStyle: menuItemWrapperStyle, + onPress: integrationData?.onExportPagePress, + brickRoadIndicator: + areSettingsInErrorFields(integrationData?.subscribedExportSettings, integrationData?.errorFields) || + shouldShowQBOReimbursableExportDestinationAccountError(policy) || + integrationData?.externalSubscribedExportSettingsHasErrorFields + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : undefined, + pendingAction: + settingsPendingAction(integrationData?.subscribedExportSettings, integrationData?.pendingFields) ?? integrationData?.externalSubscribedExportSettingsPendingAction, + }, + ...(shouldShowCardReconciliationOption && integrationData?.onCardReconciliationPagePress + ? [ + { + icon: icons.ExpensifyCard, + iconRight: icons.ArrowRight, + shouldShowRightIcon: true, + title: translate('workspace.accounting.cardReconciliation'), + wrapperStyle: menuItemWrapperStyle, + onPress: integrationData?.onCardReconciliationPagePress, + }, + ] + : []), + { + icon: icons.Gear, + iconRight: icons.ArrowRight, + shouldShowRightIcon: true, + title: translate('workspace.accounting.advanced'), + wrapperStyle: menuItemWrapperStyle, + onPress: integrationData?.onAdvancedPagePress, + brickRoadIndicator: areSettingsInErrorFields(integrationData?.subscribedAdvancedSettings, integrationData?.errorFields) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : undefined, + pendingAction: settingsPendingAction(integrationData?.subscribedAdvancedSettings, integrationData?.pendingFields), + }, + ] + : []; + + const settingsMenuItems: MenuItemData[] = [ + ...(isEmptyObject(integrationSpecificMenuItems) || shouldShowSynchronizationError || !hasAccountingConnection ? [] : [integrationSpecificMenuItems]), + ...(!hasAccountingConnection || !isConnectionVerified ? [] : configurationOptions), + ]; + return {icon: integrationData?.icon, title: integrationData?.title, connectionMessage, settingsMenuItems}; + }; + const connectionDetails = getConnectionDetails(); + + let qboTokenExpiryHint; + if (qboTokenExpiryDate && canWriteAccounting) { + const formattedExpiryDate = DateUtils.formatWithUTCTimeZone(qboTokenExpiryDate.toISOString(), CONST.DATE.MONTH_DAY_YEAR_FORMAT, dateFnsLocale); + qboTokenExpiryHint = ( + <> + {translate( + qboTokenExpiryStatus === CONST.POLICY.CONNECTIONS.QBO_REFRESH_TOKEN_EXPIRY_STATUS.EXPIRED + ? 'workspace.accounting.qboConnectionExpired' + : 'workspace.accounting.qboConnectionExpiring', + {date: formattedExpiryDate}, + )}{' '} + startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.QBO, isIntuitEnterpriseSuite: isConnectedToIntuitEnterpriseSuite})}> + {translate('workspace.accounting.reconnect')} + + + ); + } + + const oldDotPolicyConnectionsURL = policyID ? `${getOldDotURLFromEnvironment(environment)}/${CONST.OLDDOT_URLS.POLICY_CONNECTIONS_URL_ENCODED(policyID)}` : ''; + + return { + accountingIntegrations, + connectedIntegration, + connectedIntegrationDisplayName, + isConnectedToIntuitEnterpriseSuite, + isSyncInProgress, + hasAccountingConnection, + hasSyncError, + hasUnsupportedNDIntegration, + synchronizationError, + canWriteAccounting, + showReadOnlyModal, + overflowMenu, + connectionDetails, + qboTokenExpiryHint, + oldDotPolicyConnectionsURL, + getAvailableIntegrationData, + }; +} + +export default useConnectedAccountingIntegration; diff --git a/src/pages/workspace/connections/ConnectionsAccountingPage.tsx b/src/pages/workspace/connections/ConnectionsAccountingPage.tsx index b21e3ee5c853..af7513824412 100644 --- a/src/pages/workspace/connections/ConnectionsAccountingPage.tsx +++ b/src/pages/workspace/connections/ConnectionsAccountingPage.tsx @@ -3,535 +3,59 @@ import UserAvatar from '@components/Avatar/UserAvatar'; import FormHelpMessage from '@components/FormHelpMessage'; import Header from '@components/Header'; import MenuItem from '@components/MenuItem'; -import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; -import type ThreeDotsMenuProps from '@components/ThreeDotsMenu/types'; -import useCardFeeds from '@hooks/useCardFeeds'; -import useCardsLists from '@hooks/useCardsLists'; -import useConfirmModal from '@hooks/useConfirmModal'; -import useEnvironment from '@hooks/useEnvironment'; -import useExpensifyCardFeeds from '@hooks/useExpensifyCardFeeds'; -import useHasReusablePoliciesConnectedTo from '@hooks/useHasReusablePoliciesConnectedTo'; import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; -import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; -import {getQBORefreshTokenExpiryDate, getQBORefreshTokenExpiryStatus} from '@libs/AccountingUtils'; -import {isAuthenticationError, isConnectionInProgress, isConnectionUnverified, removePolicyConnection, syncConnection} from '@libs/actions/connections'; -import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; -import {isExpensifyCardFullySetUp} from '@libs/CardUtils'; -import DateUtils from '@libs/DateUtils'; -import {getOldDotURLFromEnvironment} from '@libs/Environment/Environment'; -import { - areSettingsInErrorFields, - findCurrentXeroOrganization, - getConnectedIntegration, - getCurrentSageIntacctEntityName, - getCurrentXeroOrganizationName, - getIntegrationLastSuccessfulDate, - getXeroTenants, - hasAccountingConnections, - hasSupportedOnlyOnOldDotIntegration, - settingsPendingAction, - shouldShowSyncError, -} from '@libs/PolicyUtils'; - import Navigation from '@navigation/Navigation'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; -import {AccountingContextProvider, useAccountingActions, useAccountingState} from '@pages/workspace/accounting/AccountingContext'; -import {getCertiniaSelectedCompanyID, isCertiniaFFAConnection} from '@pages/workspace/accounting/certinia/utils'; -import type {MenuItemData, PolicyAccountingPageProps} from '@pages/workspace/accounting/types'; -import {getAccountingIntegrationData, getAccountingIntegrationDisplayName, getSynchronizationErrorMessage, isIntuitEnterpriseSuiteConnection} from '@pages/workspace/accounting/utils'; +import {AccountingContextProvider} from '@pages/workspace/accounting/AccountingContext'; +import type {PolicyAccountingPageProps} from '@pages/workspace/accounting/types'; +import useConnectedAccountingIntegration from '@pages/workspace/accounting/useConnectedAccountingIntegration'; import withPolicyConnections from '@pages/workspace/withPolicyConnections'; import {openOldDotLink} from '@userActions/Link'; -import {openPolicyExpensifyCardsPage} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import React, {useEffect} from 'react'; +import React from 'react'; import {View} from 'react-native'; /** Settings for the workspace's connected accounting integration, opened from the Connections page. */ function ConnectionsAccountingPage({policy}: PolicyAccountingPageProps) { useWorkspaceDocumentTitle(policy?.name, 'workspace.common.accounting'); - const hasReusablePoliciesConnectedToSageIntacct = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT, policy?.id); - const hasReusablePoliciesConnectedToQBD = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.QBD, policy?.id); - const hasReusablePoliciesConnectedToCertinia = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CERTINIA, policy?.id); - const hasReusablePoliciesConnectedToRillet = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.RILLET, policy?.id); - const hasReusablePoliciesConnectedToDualEntry = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.DUALENTRY, policy?.id); - const hasReusablePoliciesConnectedToCampfire = useHasReusablePoliciesConnectedTo(CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE, policy?.id); - const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy?.id}`); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const {translate, datetimeToRelative: getDatetimeToRelative, getLocalDateFromDatetime, dateFnsLocale} = useLocalize(); - const {environment} = useEnvironment(); - const oldDotEnvironmentURL = getOldDotURLFromEnvironment(environment); - const {isOffline} = useNetwork(); - const {isBetaEnabled} = usePermissions(); + const {translate} = useLocalize(); const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); - const {showConfirmModal} = useConfirmModal(); - const {activeIntegration} = useAccountingState(); - const {startIntegrationFlow} = useAccountingActions(); const policyID = policy?.id; - const workspaceAccountID = useWorkspaceAccountID(policyID); - const allCardSettings = useExpensifyCardFeeds(policyID); - const isSyncInProgress = isConnectionInProgress(connectionSyncProgress, policy); - const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'CircularArrowBackwards', 'ExpensifyCard', 'Gear', 'Key', 'NewWindow', 'Pencil', 'Send', 'Sync', 'Trashcan']); - const accountingIcons = useMemoizedLazyExpensifyIcons([ - 'IntacctSquare', - 'IntuitSquare', - 'QBOSquare', - 'XeroSquare', - 'NetSuiteSquare', - 'QBDSquare', - 'CertiniaSquare', - 'RilletSquare', - 'DualEntrySquare', - 'CampfireSquare', - 'BusinessCentralSquare', - ]); - const [cardFeeds] = useCardFeeds(policyID); - const [cardLists] = useCardsLists(); - const connectionSyncStage = connectionSyncProgress?.stageInProgress; - - const canUseBusinessCentralIntegration = isBetaEnabled(CONST.BETAS.BUSINESS_CENTRAL) || !!policy?.connections?.businessCentral; - const accountingIntegrations = CONST.POLICY.CONNECTIONS.ACCOUNTING_CONNECTION_NAMES.filter((name) => { - if (name === CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL) { - return canUseBusinessCentralIntegration; - } - return true; + const { + connectedIntegration, + connectedIntegrationDisplayName, + isSyncInProgress, + hasSyncError, + hasUnsupportedNDIntegration, + synchronizationError, + canWriteAccounting, + overflowMenu, + connectionDetails, + qboTokenExpiryHint, + oldDotPolicyConnectionsURL, + } = useConnectedAccountingIntegration(policy, { + // These settings have nothing to show once the integration is gone + onDisconnectConfirmed: () => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)), }); - const syncingAccountingIntegration = accountingIntegrations.find((integration) => integration === connectionSyncProgress?.connectionName); - const connectedIntegration = getConnectedIntegration(policy, accountingIntegrations) ?? syncingAccountingIntegration; - const isIntuitEnterpriseSuiteSyncInProgress = isSyncInProgress && activeIntegration?.name === CONST.POLICY.CONNECTIONS.NAME.QBO && activeIntegration.isIntuitEnterpriseSuite === true; - const isConnectedToIntuitEnterpriseSuite = - connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBO && (isIntuitEnterpriseSuiteConnection(policy) || isIntuitEnterpriseSuiteSyncInProgress); - const connectedIntegrationDisplayName = connectedIntegration ? getAccountingIntegrationDisplayName(policy, connectedIntegration, translate) : undefined; - const hasAccountingConnection = hasAccountingConnections(policy); - const {canWrite: canWriteAccounting} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.ACCOUNTING); - const synchronizationError = connectedIntegration && getSynchronizationErrorMessage(policy, connectedIntegration, isSyncInProgress, translate, styles); - - const isSageIntacct = connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT; - const hasAuthError = !!connectedIntegration && !!synchronizationError && isAuthenticationError(policy, connectedIntegration); - // A QBO refresh token that is about to expire (or already has, without a sync failing yet) is warned about while the connection still looks healthy - const qboTokenExpiryStatus = - connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBO && !synchronizationError && !isSyncInProgress ? getQBORefreshTokenExpiryStatus(policy) : undefined; - const isQBOTokenExpiringSoon = !!qboTokenExpiryStatus; - const qboTokenExpiryDate = isQBOTokenExpiringSoon ? getQBORefreshTokenExpiryDate(policy) : undefined; - const shouldShowEnterCredentials = !!connectedIntegration && (hasAuthError || isSageIntacct || isQBOTokenExpiringSoon); - - // Get the last successful date of the integration. Then, if `connectionSyncProgress` is the same integration displayed and the state is 'jobDone', get the more recent update time of the two. - const successfulDate = getIntegrationLastSuccessfulDate( - getLocalDateFromDatetime, - connectedIntegration ? policy?.connections?.[connectedIntegration] : undefined, - connectedIntegration === connectionSyncProgress?.connectionName ? connectionSyncProgress : undefined, - ); - const datetimeToRelative = successfulDate ? getDatetimeToRelative(successfulDate) : ''; - - const hasSyncError = shouldShowSyncError(policy, isSyncInProgress, accountingIntegrations); - const hasUnsupportedNDIntegration = !isEmptyObject(policy?.connections) && hasSupportedOnlyOnOldDotIntegration(policy); - - const tenants = getXeroTenants(policy); - const currentXeroOrganization = findCurrentXeroOrganization(tenants, policy?.connections?.xero?.config?.tenantID); - const shouldShowSynchronizationError = !!synchronizationError; - const shouldShowReinstallConnectorMenuItem = shouldShowSynchronizationError && connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.QBD; - const shouldShowCardReconciliationOption = Object.values(allCardSettings ?? {})?.some((cardSetting) => isExpensifyCardFullySetUp(policy, cardSetting)); - const shouldShowReconnect = hasAuthError && connectedIntegration === CONST.POLICY.CONNECTIONS.NAME.CERTINIA; - let credentialsMenuTextKey: Parameters[0] = 'workspace.accounting.enterCredentials'; - if (shouldShowReconnect || isQBOTokenExpiringSoon) { - credentialsMenuTextKey = 'workspace.accounting.reconnect'; - } else if (isSageIntacct && !hasAuthError) { - credentialsMenuTextKey = 'workspace.accounting.updateCredentials'; - } - - const overflowMenu: ThreeDotsMenuProps['menuItems'] = [ - ...(shouldShowReinstallConnectorMenuItem - ? [ - { - icon: icons.CircularArrowBackwards, - text: translate('workspace.accounting.reinstall'), - onSelected: () => startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.QBD}), - shouldCallAfterModalHide: true, - disabled: isOffline, - iconRight: icons.NewWindow, - }, - ] - : []), - ...(shouldShowEnterCredentials - ? [ - { - icon: icons.Key, - text: translate(credentialsMenuTextKey), - onSelected: () => { - if (isSageIntacct && policyID) { - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTER_CREDENTIALS.getRoute(policyID)); - return; - } - startIntegrationFlow({name: connectedIntegration, isIntuitEnterpriseSuite: isConnectedToIntuitEnterpriseSuite}); - }, - shouldCallAfterModalHide: true, - disabled: isOffline, - iconRight: icons.NewWindow, - }, - ] - : []), - ...(!hasAuthError - ? [ - { - icon: icons.Sync, - text: translate('workspace.accounting.syncNow'), - onSelected: () => syncConnection(policy, connectedIntegration), - disabled: isOffline, - }, - ] - : []), - { - icon: icons.Trashcan, - text: translate('workspace.accounting.disconnect'), - onSelected: () => { - showConfirmModal({ - title: translate('workspace.accounting.disconnectTitle', connectedIntegrationDisplayName), - prompt: translate('workspace.accounting.disconnectPrompt', connectedIntegrationDisplayName), - confirmText: translate('workspace.accounting.disconnect'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }).then(({action}) => { - if (action !== ModalActions.CONFIRM || !connectedIntegration || !policyID) { - return; - } - // These settings have nothing to show once the integration is gone - Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)); - removePolicyConnection(policy, connectedIntegration); - }); - }, - shouldCallAfterModalHide: true, - }, - ]; - - useEffect(() => { - if (!policyID || !policy?.areExpensifyCardsEnabled || !workspaceAccountID) { - return; - } - openPolicyExpensifyCardsPage(policyID, workspaceAccountID); - }, [policyID, policy?.areExpensifyCardsEnabled, workspaceAccountID]); - - const getIntegrationSpecificMenuItems = () => { - const sageIntacctEntityList = policy?.connections?.intacct?.data?.entities ?? []; - const netSuiteSubsidiaryList = policy?.connections?.netsuite?.options?.data?.subsidiaryList ?? []; - const rilletSubsidiaryList = policy?.connections?.rillet?.data?.subsidiaries; - const dualEntryCompanyList = policy?.connections?.dualEntry?.data?.companies; - const campfireSubsidiaryList = policy?.connections?.campfire?.data?.subsidiaries; - const businessCentralCompanyList = policy?.connections?.businessCentral?.data?.companies; - const certiniaConfig = policy?.connections?.financialforce?.config; - const certiniaCompanies = policy?.connections?.financialforce?.data?.companies ?? []; - const certiniaCompanyID = getCertiniaSelectedCompanyID(certiniaConfig); - const certiniaCompanyField = certiniaConfig?.hasPSA ? CONST.CERTINIA_CONFIG.COMPANY_ID : CONST.CERTINIA_CONFIG.COMPANY; - const selectedCertiniaCompany = certiniaCompanies.find((company) => company.id === certiniaCompanyID); - switch (connectedIntegration) { - case CONST.POLICY.CONNECTIONS.NAME.XERO: - return !policy?.connections?.xero?.data?.tenants - ? {} - : { - description: translate('workspace.xero.organization'), - iconRight: icons.ArrowRight, - title: getCurrentXeroOrganizationName(policy), - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting && tenants.length > 1, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - onPress: - canWriteAccounting && tenants.length > 1 - ? () => { - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_XERO_ORGANIZATION.getRoute(policyID, currentXeroOrganization?.id)); - } - : undefined, - pendingAction: settingsPendingAction([CONST.XERO_CONFIG.TENANT_ID], policy?.connections?.xero?.config?.pendingFields), - brickRoadIndicator: areSettingsInErrorFields([CONST.XERO_CONFIG.TENANT_ID], policy?.connections?.xero?.config?.errorFields) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.NETSUITE: - return !policy?.connections?.netsuite?.options?.config?.subsidiary - ? {} - : { - description: translate('workspace.netsuite.subsidiary'), - iconRight: icons.ArrowRight, - title: policy?.connections?.netsuite?.options?.config?.subsidiary ?? '', - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting && netSuiteSubsidiaryList?.length > 1, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - pendingAction: policy?.connections?.netsuite?.options?.config?.pendingFields?.subsidiary, - brickRoadIndicator: policy?.connections?.netsuite?.options?.config?.errorFields?.subsidiary ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: - canWriteAccounting && netSuiteSubsidiaryList?.length > 1 - ? () => { - Navigation.navigate(ROUTES.POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR.getRoute(policyID)); - } - : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.SAGE_INTACCT: - return !sageIntacctEntityList.length - ? {} - : { - description: translate('workspace.intacct.entity'), - iconRight: icons.ArrowRight, - title: getCurrentSageIntacctEntityName(policy, translate('workspace.common.topLevel')), - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - pendingAction: policy?.connections?.intacct?.config?.pendingFields?.entity, - brickRoadIndicator: policy?.connections?.intacct?.config?.errorFields?.entity ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: canWriteAccounting ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_SAGE_INTACCT_ENTITY.getRoute(policyID)) : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.QBO: - return !policy?.connections?.quickbooksOnline?.config?.companyName - ? {} - : { - description: translate(isConnectedToIntuitEnterpriseSuite ? 'workspace.qbo.entity' : 'workspace.qbo.connectedTo'), - iconRight: isConnectedToIntuitEnterpriseSuite ? icons.ArrowRight : undefined, - title: policy?.connections?.quickbooksOnline?.config?.companyName, - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: isConnectedToIntuitEnterpriseSuite && canWriteAccounting, - shouldShowDescriptionOnTop: true, - interactive: isConnectedToIntuitEnterpriseSuite && canWriteAccounting, - pendingAction: policy?.connections?.quickbooksOnline?.config?.pendingFields?.realmId, - brickRoadIndicator: policy?.connections?.quickbooksOnline?.config?.errorFields?.realmId ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: - isConnectedToIntuitEnterpriseSuite && canWriteAccounting && policyID - ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_INTUIT_ENTERPRISE_SUITE_ENTITY_SELECTOR.getRoute(policyID)) - : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.CERTINIA: - return !isCertiniaFFAConnection(certiniaConfig) - ? {} - : { - description: translate('workspace.certinia.company'), - iconRight: icons.ArrowRight, - title: selectedCertiniaCompany?.name ?? certiniaCompanyID ?? translate('common.none'), - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - pendingAction: settingsPendingAction([certiniaCompanyField], certiniaConfig?.pendingFields), - brickRoadIndicator: areSettingsInErrorFields([certiniaCompanyField], certiniaConfig?.errorFields) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: canWriteAccounting ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CERTINIA_COMPANY_SELECTOR.getRoute(policyID)) : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.RILLET: - return !rilletSubsidiaryList?.length - ? {} - : { - description: translate('workspace.rillet.subsidiary'), - iconRight: icons.ArrowRight, - title: rilletSubsidiaryList?.find((subsidiary) => subsidiary.id === policy?.connections?.rillet?.config?.subsidiaryID)?.tradeName ?? '', - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting && rilletSubsidiaryList && rilletSubsidiaryList.length > 1, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - pendingAction: policy?.connections?.rillet?.config.pendingFields?.subsidiaryID, - brickRoadIndicator: policy?.connections?.rillet?.config.errorFields?.subsidiaryID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: - policyID && canWriteAccounting && rilletSubsidiaryList && rilletSubsidiaryList.length > 1 - ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_RILLET_SUBSIDIARY_SELECTOR.getRoute(policyID)) - : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.DUALENTRY: - return !dualEntryCompanyList?.length - ? {} - : { - description: translate('workspace.dualEntry.subsidiary'), - iconRight: icons.ArrowRight, - title: dualEntryCompanyList?.find((company) => company.id === policy?.connections?.dualEntry?.config?.subsidiaryID)?.name ?? '', - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting && dualEntryCompanyList && dualEntryCompanyList.length > 1, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - pendingAction: policy?.connections?.dualEntry?.config.pendingFields?.subsidiaryID, - brickRoadIndicator: policy?.connections?.dualEntry?.config.errorFields?.subsidiaryID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: - policyID && canWriteAccounting && dualEntryCompanyList && dualEntryCompanyList.length > 1 - ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_DUALENTRY_SUBSIDIARY_SELECTOR.getRoute(policyID)) - : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.CAMPFIRE: - return !campfireSubsidiaryList?.length - ? {} - : { - description: translate('workspace.campfire.subsidiary'), - iconRight: icons.ArrowRight, - title: campfireSubsidiaryList?.find((subsidiary) => subsidiary.id === policy?.connections?.campfire?.config?.subsidiaryID)?.name ?? '', - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting && campfireSubsidiaryList && campfireSubsidiaryList.length > 1, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - pendingAction: policy?.connections?.campfire?.config.pendingFields?.subsidiaryID, - brickRoadIndicator: policy?.connections?.campfire?.config.errorFields?.subsidiaryID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: - policyID && canWriteAccounting && campfireSubsidiaryList && campfireSubsidiaryList.length > 1 - ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_CAMPFIRE_SUBSIDIARY_SELECTOR.getRoute(policyID)) - : undefined, - }; - case CONST.POLICY.CONNECTIONS.NAME.BUSINESS_CENTRAL: - return !businessCentralCompanyList?.length - ? {} - : { - description: translate('workspace.businessCentral.subsidiary'), - iconRight: icons.ArrowRight, - title: businessCentralCompanyList.find((company) => company.id === policy?.connections?.businessCentral?.config?.companyID)?.displayName ?? '', - titleStyle: styles.fontWeightNormal, - shouldShowRightIcon: canWriteAccounting && businessCentralCompanyList.length > 1, - shouldShowDescriptionOnTop: true, - interactive: canWriteAccounting, - pendingAction: policy?.connections?.businessCentral?.config.pendingFields?.companyID, - brickRoadIndicator: policy?.connections?.businessCentral?.config.errorFields?.companyID ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, - onPress: - policyID && canWriteAccounting && businessCentralCompanyList.length > 1 - ? () => Navigation.navigate(ROUTES.POLICY_ACCOUNTING_BUSINESS_CENTRAL_COMPANY_SELECTOR.getRoute(policyID)) - : undefined, - }; - - default: - return undefined; - } - }; - const integrationSpecificMenuItems = getIntegrationSpecificMenuItems(); - - const getConnectionDetails = () => { - if (!connectedIntegration || !policyID) { - return undefined; - } - const isConnectionVerified = !isConnectionUnverified(policy, connectedIntegration); - const integrationData = getAccountingIntegrationData( - connectedIntegration, - policyID, - translate, - { - sageIntacct: hasReusablePoliciesConnectedToSageIntacct, - qbd: hasReusablePoliciesConnectedToQBD, - certinia: hasReusablePoliciesConnectedToCertinia, - rillet: hasReusablePoliciesConnectedToRillet, - dualEntry: hasReusablePoliciesConnectedToDualEntry, - campfire: hasReusablePoliciesConnectedToCampfire, - }, - { - policy, - canUseNetSuiteUSATax: isBetaEnabled(CONST.BETAS.NETSUITE_USA_TAX), - expensifyIcons: accountingIcons, - cardFeeds, - cardList: cardLists, - isIntuitEnterpriseSuiteOverride: isConnectedToIntuitEnterpriseSuite, - isUnifiedConnectionsBetaEnabled, - }, - ); - let connectionMessage; - if (isSyncInProgress && connectionSyncStage) { - connectionMessage = translate('workspace.accounting.connections.syncStageName', connectionSyncStage, integrationData?.title); - } else if (!isConnectionVerified) { - connectionMessage = translate('workspace.accounting.notSync'); - } else { - connectionMessage = translate('workspace.accounting.lastSync', datetimeToRelative); - } - - const configurationOptions = canWriteAccounting - ? [ - { - icon: icons.Pencil, - iconRight: icons.ArrowRight, - shouldShowRightIcon: true, - title: translate('workspace.accounting.import'), - onPress: integrationData?.onImportPagePress, - brickRoadIndicator: areSettingsInErrorFields(integrationData?.subscribedImportSettings, integrationData?.errorFields) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined, - pendingAction: settingsPendingAction(integrationData?.subscribedImportSettings, integrationData?.pendingFields), - }, - { - icon: icons.Send, - iconRight: icons.ArrowRight, - shouldShowRightIcon: true, - title: translate('workspace.accounting.export'), - onPress: integrationData?.onExportPagePress, - brickRoadIndicator: - areSettingsInErrorFields(integrationData?.subscribedExportSettings, integrationData?.errorFields) || - shouldShowQBOReimbursableExportDestinationAccountError(policy) || - integrationData?.externalSubscribedExportSettingsHasErrorFields - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined, - pendingAction: - settingsPendingAction(integrationData?.subscribedExportSettings, integrationData?.pendingFields) ?? integrationData?.externalSubscribedExportSettingsPendingAction, - }, - ...(shouldShowCardReconciliationOption && integrationData?.onCardReconciliationPagePress - ? [ - { - icon: icons.ExpensifyCard, - iconRight: icons.ArrowRight, - shouldShowRightIcon: true, - title: translate('workspace.accounting.cardReconciliation'), - onPress: integrationData?.onCardReconciliationPagePress, - }, - ] - : []), - { - icon: icons.Gear, - iconRight: icons.ArrowRight, - shouldShowRightIcon: true, - title: translate('workspace.accounting.advanced'), - onPress: integrationData?.onAdvancedPagePress, - brickRoadIndicator: areSettingsInErrorFields(integrationData?.subscribedAdvancedSettings, integrationData?.errorFields) - ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR - : undefined, - pendingAction: settingsPendingAction(integrationData?.subscribedAdvancedSettings, integrationData?.pendingFields), - }, - ] - : []; - - const menuItems: MenuItemData[] = [ - ...(isEmptyObject(integrationSpecificMenuItems) || shouldShowSynchronizationError || !hasAccountingConnection ? [] : [integrationSpecificMenuItems]), - ...(!hasAccountingConnection || !isConnectionVerified ? [] : configurationOptions), - ]; - return {icon: integrationData?.icon, title: integrationData?.title, connectionMessage, menuItems}; - }; - const connectionDetails = getConnectionDetails(); - const connectionsMenuItems = connectionDetails?.menuItems ?? []; - - let qboTokenExpiryHint; - if (qboTokenExpiryDate && canWriteAccounting) { - const formattedExpiryDate = DateUtils.formatWithUTCTimeZone(qboTokenExpiryDate.toISOString(), CONST.DATE.MONTH_DAY_YEAR_FORMAT, dateFnsLocale); - qboTokenExpiryHint = ( - <> - {translate( - qboTokenExpiryStatus === CONST.POLICY.CONNECTIONS.QBO_REFRESH_TOKEN_EXPIRY_STATUS.EXPIRED - ? 'workspace.accounting.qboConnectionExpired' - : 'workspace.accounting.qboConnectionExpiring', - {date: formattedExpiryDate}, - )}{' '} - startIntegrationFlow({name: CONST.POLICY.CONNECTIONS.NAME.QBO, isIntuitEnterpriseSuite: isConnectedToIntuitEnterpriseSuite})}> - {translate('workspace.accounting.reconnect')} - - - ); - } - - const oldDotPolicyConnectionsURL = policyID ? `${oldDotEnvironmentURL}/${CONST.OLDDOT_URLS.POLICY_CONNECTIONS_URL_ENCODED(policyID)}` : ''; + const connectionsMenuItems = connectionDetails?.settingsMenuItems ?? []; return ( - {shouldShowSynchronizationError && ( + {!!synchronizationError && ( - {`${translate('workspace.merge.authenticationError', card.displayName)} `} - {!isOffline && ( - - {translate('workspace.merge.reconnectLink')} - - )} - - ); - } else if (card.hasError) { - const genericError = translate('workspace.merge.syncError', card.displayName); - lastSyncErrorMessage = card.lastSyncErrorMessage ? `${genericError} ("${card.lastSyncErrorMessage}")` : genericError; - } - - const getPrimaryMenuItem = (): ThreeDotsMenuProps['menuItems'][number] => { - if (card.needsReconnect) { - return { - icon: icons.Sync, - text: translate('workspace.merge.reconnect'), - onSelected: handleConnect, - disabled: isOffline, - }; - } - if (card.completeSetupRoute) { - return { - icon: icons.CheckCircle, - text: translate('workspace.merge.completeSetup'), - onSelected: () => { - if (!canWriteMoreFeatures) { - showReadOnlyModal(); - return; - } - if (card.completeSetupRoute) { - Navigation.navigate(card.completeSetupRoute); - } - }, - disabled: isOffline, - }; - } - return { - icon: icons.Sync, - text: translate('workspace.merge.syncNow'), - onSelected: () => { - if (showMergeManualSyncLimitModalIfReached(policy, card.connectionName, translate, showConfirmModal)) { - return; - } - syncConnection(policy, card.connectionName); - }, - disabled: isOffline, - shouldCallAfterModalHide: true, - }; - }; - - const overflowMenu: ThreeDotsMenuProps['menuItems'] = [ - getPrimaryMenuItem(), - { - icon: icons.Trashcan, - text: translate('workspace.merge.disconnect'), - onSelected: () => { - showConfirmModal({ - title: translate('workspace.merge.disconnectTitle', card.displayName), - prompt: translate('workspace.merge.disconnectPrompt', card.displayName), - confirmText: translate('workspace.merge.disconnect'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }).then((result) => { - if (result?.action !== ModalActions.CONFIRM || !policy) { - return; - } - onDisconnect?.(); - removePolicyConnection(policy, card.connectionName); - }); - }, - shouldCallAfterModalHide: true, - }, - ]; - - // While the setup is incomplete only the rows that failed to save are shown, so the admin is steered to the setup flow first. - const visibleConfigRows = card.isConnected && !card.isInitialSyncInProgress ? (card.configRows ?? []).filter((row) => !card.completeSetupRoute || !!row.errors) : []; + const {cardIcon, connectionDescription, lastSyncErrorMessage, overflowMenu, visibleConfigRows} = useMergeProviderCardDetails({ + card, + policy, + handleConnect, + canWriteMoreFeatures, + showReadOnlyModal, + onDisconnect, + }); return ( <> diff --git a/src/pages/workspace/connections/ConnectionsReceiptPartnersPage.tsx b/src/pages/workspace/connections/ConnectionsReceiptPartnersPage.tsx index 765fa1854835..5c6ac04a5c5d 100644 --- a/src/pages/workspace/connections/ConnectionsReceiptPartnersPage.tsx +++ b/src/pages/workspace/connections/ConnectionsReceiptPartnersPage.tsx @@ -4,19 +4,13 @@ import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import Header from '@components/Header'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; -import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; -import useConfirmModal from '@hooks/useConfirmModal'; -import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import usePolicy from '@hooks/usePolicy'; -import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useScreenBoundDynamicRoute from '@hooks/useScreenBoundDynamicRoute'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -27,125 +21,46 @@ import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation import type {SettingsNavigatorParamList} from '@navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import useReceiptPartnersSettings from '@pages/workspace/receiptPartners/useReceiptPartnersSettings'; import getSynchronizationErrorMessage from '@pages/workspace/receiptPartners/utils'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; -import {openExternalLink} from '@userActions/Link'; -import {openPolicyReceiptPartnersPage, removePolicyReceiptPartnersConnection, togglePolicyUberAutoInvite, togglePolicyUberAutoRemove} from '@userActions/Policy/Policy'; - import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {ValueOf} from 'type-fest'; - -import React, {useEffect} from 'react'; +import React from 'react'; type ConnectionsReceiptPartnersPageProps = PlatformStackScreenProps; function ConnectionsReceiptPartnersPage({route}: ConnectionsReceiptPartnersPageProps) { const policyID = route.params.policyID; - const icons = useMemoizedLazyExpensifyIcons(['Key', 'Mail', 'NewWindow', 'Trashcan']); + const icons = useMemoizedLazyExpensifyIcons(['Mail']); const {translate} = useLocalize(); const buildDynamicRoute = useScreenBoundDynamicRoute(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const {showConfirmModal} = useConfirmModal(); - const policy = usePolicy(policyID); const isUnifiedConnectionsBetaEnabled = useIsUnifiedConnectionsBetaEnabled(); + const { + policy, + integrations, + isAutoInvite, + isAutoRemove, + isUberConnected, + shouldShowEnterCredentialsError, + getReceiptPartnersIntegrationData, + canWriteMoreFeatures, + withReadOnlyFallback, + toggleUberAutoInvite, + toggleUberAutoRemove, + getOverflowMenu, + } = useReceiptPartnersSettings(policyID, { + // These settings have nothing to show once Uber is disconnected + onDisconnectConfirmed: () => Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)), + }); useWorkspaceDocumentTitle(policy?.name, 'workspace.common.receiptPartners'); - const {getReceiptPartnersIntegrationData, shouldShowEnterCredentialsError, isUberConnected} = useGetReceiptPartnersIntegrationData(policyID); const isLoading = policy?.isLoading; - const integrations = policy?.receiptPartners; - const isAutoRemove = !!integrations?.uber?.autoRemove; - const isAutoInvite = !!integrations?.uber?.autoInvite; const centralBillingAccountEmail = !!integrations?.uber?.centralBillingAccountEmail; - const {canWrite: canWriteMoreFeatures, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); - - const startIntegrationFlow = ({name}: {name: string}) => { - switch (name) { - case CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER: { - openExternalLink(`${CONST.UBER_CONNECT_URL}?${integrations?.uber?.connectFormData}`); - break; - } - default: { - break; - } - } - }; - - const fetchReceiptPartners = () => { - openPolicyReceiptPartnersPage(policyID); - }; - - const {isOffline} = useNetwork({onReconnect: fetchReceiptPartners}); - - useEffect(() => { - openPolicyReceiptPartnersPage(policyID); - }, [policyID]); - - const toggleWorkspaceUberAutoInvite = () => { - togglePolicyUberAutoInvite(policyID, !isAutoInvite); - }; - - const toggleWorkspaceUberAutoRemove = () => { - togglePolicyUberAutoRemove(policyID, !isAutoRemove); - }; - - const disconnectPartner = (partner: ValueOf) => { - if (!policyID) { - return; - } - removePolicyReceiptPartnersConnection(policyID, partner, integrations?.[partner]); - fetchReceiptPartners(); - }; - - const getOverflowMenu = (integration: string) => { - switch (integration) { - case CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER: - if (shouldShowEnterCredentialsError) { - return [ - { - icon: icons.Key, - text: translate('workspace.accounting.enterCredentials'), - onSelected: () => - startIntegrationFlow({ - name: CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER, - }), - shouldCallAfterModalHide: true, - disabled: isOffline, - iconRight: icons.NewWindow, - }, - ]; - } - - return [ - { - icon: icons.Trashcan, - text: translate('workspace.accounting.disconnect'), - onSelected: () => { - showConfirmModal({ - title: translate('workspace.moreFeatures.receiptPartnersWarningModal.featureEnabledTitle'), - prompt: translate('workspace.moreFeatures.receiptPartnersWarningModal.description'), - confirmText: translate('workspace.accounting.disconnect'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }).then(({action}) => { - if (action !== ModalActions.CONFIRM) { - return; - } - // These settings have nothing to show once Uber is disconnected - Navigation.goBack(ROUTES.WORKSPACE_CONNECTIONS.getRoute(policyID)); - disconnectPartner(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER); - }); - }, - shouldCallAfterModalHide: true, - }, - ]; - default: - return []; - } - }; const uberData = policyID ? getReceiptPartnersIntegrationData(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER) : undefined; @@ -206,7 +121,7 @@ function ConnectionsReceiptPartnersPage({route}: ConnectionsReceiptPartnersPageP titleStyle={styles.pr3} title={translate('workspace.receiptPartners.uber.autoInvite')} switchAccessibilityLabel={translate('workspace.receiptPartners.uber.autoInvite')} - onToggle={toggleWorkspaceUberAutoInvite} + onToggle={toggleUberAutoInvite} isActive={isAutoInvite} disabled={!canWriteMoreFeatures} disabledAction={withReadOnlyFallback()} @@ -219,7 +134,7 @@ function ConnectionsReceiptPartnersPage({route}: ConnectionsReceiptPartnersPageP titleStyle={styles.pr3} title={translate('workspace.receiptPartners.uber.autoRemove')} switchAccessibilityLabel={translate('workspace.receiptPartners.uber.autoRemove')} - onToggle={toggleWorkspaceUberAutoRemove} + onToggle={toggleUberAutoRemove} isActive={isAutoRemove} disabled={!canWriteMoreFeatures} disabledAction={withReadOnlyFallback()} diff --git a/src/pages/workspace/connections/useAccountingConnectionListings.ts b/src/pages/workspace/connections/useAccountingConnectionListings.ts index de88586e2bb2..71bd9da67ed4 100644 --- a/src/pages/workspace/connections/useAccountingConnectionListings.ts +++ b/src/pages/workspace/connections/useAccountingConnectionListings.ts @@ -1,8 +1,8 @@ -import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; /** * Builds the accounting listings for the Connections page and starts the accounting connect flows from them. * Must be rendered inside an AccountingContextProvider. */ +import useIsUnifiedConnectionsBetaEnabled from '@hooks/useIsUnifiedConnectionsBetaEnabled'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; diff --git a/src/pages/workspace/merge/MergeProviderCard.tsx b/src/pages/workspace/merge/MergeProviderCard.tsx index 8e6e93b918f3..7c57c0800d9c 100644 --- a/src/pages/workspace/merge/MergeProviderCard.tsx +++ b/src/pages/workspace/merge/MergeProviderCard.tsx @@ -3,35 +3,27 @@ import Button from '@components/Button'; import ButtonDisabledWhenOffline from '@components/Button/composed/ButtonDisabledWhenOffline'; import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import RenderHTML from '@components/RenderHTML'; -import TextLink from '@components/TextLink'; import ThreeDotsMenu from '@components/ThreeDotsMenu'; -import type ThreeDotsMenuProps from '@components/ThreeDotsMenu/types'; -import useConfirmModal from '@hooks/useConfirmModal'; import useEnvironment from '@hooks/useEnvironment'; -import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; -import {removePolicyConnection, syncConnection} from '@libs/actions/connections'; import {clearMergeConnectionErrorField} from '@libs/actions/connections/merge'; -import {showMergeManualSyncLimitModalIfReached} from '@libs/merge/MergeUtils'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import type Policy from '@src/types/onyx/Policy'; -import type {ReactNode} from 'react'; - import React from 'react'; import {View} from 'react-native'; import type {MergeProviderCardDescriptor} from './types'; +import useMergeProviderCardDetails from './useMergeProviderCardDetails'; + type MergeProviderCardProps = { /** Descriptor object containing the Merge provider's display info, connection state, and sync status. */ card: MergeProviderCardDescriptor; @@ -50,106 +42,16 @@ type MergeProviderCardProps = { }; function MergeProviderCard({card, policy, handleConnect, canWriteMoreFeatures, showReadOnlyModal}: MergeProviderCardProps) { - const {translate, datetimeToRelative} = useLocalize(); + const {translate} = useLocalize(); const styles = useThemeStyles(); const {environmentURL} = useEnvironment(); - const {isOffline} = useNetwork(); - const icons = useMemoizedLazyExpensifyIcons(['Sync', 'Trashcan', 'Building', 'CheckCircle']); - const {showConfirmModal} = useConfirmModal(); - - const fallbackIcon = icons.Building; - const cardIcon = card.icon || fallbackIcon; - - let connectionDescription: string; - if (card.isSyncInProgress) { - connectionDescription = card.syncStageInProgress ? translate('workspace.hr.syncStageName', card.syncStageInProgress) : translate(`workspace.${card.category}.syncing`); - } else if (!card.successfulDate) { - connectionDescription = translate('workspace.merge.notSync'); - } else { - connectionDescription = translate('workspace.merge.lastSync', datetimeToRelative(card.successfulDate)); - } - - let lastSyncErrorMessage: ReactNode | undefined; - if (card.needsReconnect) { - lastSyncErrorMessage = ( - <> - {`${translate('workspace.merge.authenticationError', card.displayName)} `} - {!isOffline && ( - - {translate('workspace.merge.reconnectLink')} - - )} - - ); - } else if (card.hasError) { - const genericError = translate('workspace.merge.syncError', card.displayName); - lastSyncErrorMessage = card.lastSyncErrorMessage ? `${genericError} ("${card.lastSyncErrorMessage}")` : genericError; - } - - const getPrimaryMenuItem = (): ThreeDotsMenuProps['menuItems'][number] => { - if (card.needsReconnect) { - return { - icon: icons.Sync, - text: translate('workspace.merge.reconnect'), - onSelected: handleConnect, - disabled: isOffline, - }; - } - if (card.completeSetupRoute) { - return { - icon: icons.CheckCircle, - text: translate('workspace.merge.completeSetup'), - onSelected: () => { - if (!canWriteMoreFeatures) { - showReadOnlyModal(); - return; - } - if (card.completeSetupRoute) { - Navigation.navigate(card.completeSetupRoute); - } - }, - disabled: isOffline, - }; - } - return { - icon: icons.Sync, - text: translate('workspace.merge.syncNow'), - onSelected: () => { - if (showMergeManualSyncLimitModalIfReached(policy, card.connectionName, translate, showConfirmModal)) { - return; - } - syncConnection(policy, card.connectionName); - }, - disabled: isOffline, - shouldCallAfterModalHide: true, - }; - }; - - const overflowMenu: ThreeDotsMenuProps['menuItems'] = [ - getPrimaryMenuItem(), - { - icon: icons.Trashcan, - text: translate('workspace.merge.disconnect'), - onSelected: () => { - showConfirmModal({ - title: translate('workspace.merge.disconnectTitle', card.displayName), - prompt: translate('workspace.merge.disconnectPrompt', card.displayName), - confirmText: translate('workspace.merge.disconnect'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }).then((result) => { - if (result?.action !== ModalActions.CONFIRM || !policy) { - return; - } - removePolicyConnection(policy, card.connectionName); - }); - }, - shouldCallAfterModalHide: true, - }, - ]; + const {fallbackIcon, cardIcon, connectionDescription, lastSyncErrorMessage, overflowMenu, visibleConfigRows} = useMergeProviderCardDetails({ + card, + policy, + handleConnect, + canWriteMoreFeatures, + showReadOnlyModal, + }); let rightInset: React.ReactNode; if (!card.isConnected) { @@ -180,9 +82,6 @@ function MergeProviderCard({card, policy, handleConnect, canWriteMoreFeatures, s const rightComponent = {rightInset}; - // While the setup is incomplete only the rows that failed to save are shown, so the admin is steered to the setup flow first. - const visibleConfigRows = card.isConnected && !card.isInitialSyncInProgress ? (card.configRows ?? []).filter((row) => !card.completeSetupRoute || !!row.errors) : []; - return ( <> void; + canWriteMoreFeatures: boolean; + showReadOnlyModal: () => void; + + /** Runs once the user confirms disconnecting, before the connection is removed */ + onDisconnect?: () => void; +}; + +/** The status text, sync error and overflow menu of a Merge provider, shared by the HR and Recruiting cards and their Connections panel */ +function useMergeProviderCardDetails({card, policy, handleConnect, canWriteMoreFeatures, showReadOnlyModal, onDisconnect}: UseMergeProviderCardDetailsParams) { + const {translate, datetimeToRelative} = useLocalize(); + const styles = useThemeStyles(); + const {isOffline} = useNetwork(); + const icons = useMemoizedLazyExpensifyIcons(['Sync', 'Trashcan', 'Building', 'CheckCircle']); + const {showConfirmModal} = useConfirmModal(); + + const fallbackIcon = icons.Building; + const cardIcon = card.icon || fallbackIcon; + + let connectionDescription: string; + if (card.isSyncInProgress) { + connectionDescription = card.syncStageInProgress ? translate('workspace.hr.syncStageName', card.syncStageInProgress) : translate(`workspace.${card.category}.syncing`); + } else if (!card.successfulDate) { + connectionDescription = translate('workspace.merge.notSync'); + } else { + connectionDescription = translate('workspace.merge.lastSync', datetimeToRelative(card.successfulDate)); + } + + let lastSyncErrorMessage: ReactNode | undefined; + if (card.needsReconnect) { + lastSyncErrorMessage = ( + <> + {`${translate('workspace.merge.authenticationError', card.displayName)} `} + {!isOffline && ( + + {translate('workspace.merge.reconnectLink')} + + )} + + ); + } else if (card.hasError) { + const genericError = translate('workspace.merge.syncError', card.displayName); + lastSyncErrorMessage = card.lastSyncErrorMessage ? `${genericError} ("${card.lastSyncErrorMessage}")` : genericError; + } + + const getPrimaryMenuItem = (): ThreeDotsMenuProps['menuItems'][number] => { + if (card.needsReconnect) { + return { + icon: icons.Sync, + text: translate('workspace.merge.reconnect'), + onSelected: handleConnect, + disabled: isOffline, + }; + } + if (card.completeSetupRoute) { + return { + icon: icons.CheckCircle, + text: translate('workspace.merge.completeSetup'), + onSelected: () => { + if (!canWriteMoreFeatures) { + showReadOnlyModal(); + return; + } + if (card.completeSetupRoute) { + Navigation.navigate(card.completeSetupRoute); + } + }, + disabled: isOffline, + }; + } + return { + icon: icons.Sync, + text: translate('workspace.merge.syncNow'), + onSelected: () => { + if (showMergeManualSyncLimitModalIfReached(policy, card.connectionName, translate, showConfirmModal)) { + return; + } + syncConnection(policy, card.connectionName); + }, + disabled: isOffline, + shouldCallAfterModalHide: true, + }; + }; + + const overflowMenu: ThreeDotsMenuProps['menuItems'] = [ + getPrimaryMenuItem(), + { + icon: icons.Trashcan, + text: translate('workspace.merge.disconnect'), + onSelected: () => { + showConfirmModal({ + title: translate('workspace.merge.disconnectTitle', card.displayName), + prompt: translate('workspace.merge.disconnectPrompt', card.displayName), + confirmText: translate('workspace.merge.disconnect'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }).then((result) => { + if (result?.action !== ModalActions.CONFIRM || !policy) { + return; + } + onDisconnect?.(); + removePolicyConnection(policy, card.connectionName); + }); + }, + shouldCallAfterModalHide: true, + }, + ]; + + // While the setup is incomplete only the rows that failed to save are shown, so the admin is steered to the setup flow first. + const visibleConfigRows = card.isConnected && !card.isInitialSyncInProgress ? (card.configRows ?? []).filter((row) => !card.completeSetupRoute || !!row.errors) : []; + + return {fallbackIcon, cardIcon, connectionDescription, lastSyncErrorMessage, overflowMenu, visibleConfigRows}; +} + +export default useMergeProviderCardDetails; diff --git a/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx b/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx index 88a0b1529adf..cae70af7bd10 100644 --- a/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx +++ b/src/pages/workspace/receiptPartners/WorkspaceReceiptPartnersPage.tsx @@ -4,20 +4,14 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import MenuItemField from '@components/MenuItem/presets/MenuItemField'; import MenuItemSectionRoot from '@components/MenuItem/presets/MenuItemSectionRoot'; -import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Section from '@components/Section'; import ThreeDotsMenu from '@components/ThreeDotsMenu'; -import useConfirmModal from '@hooks/useConfirmModal'; -import useGetReceiptPartnersIntegrationData from '@hooks/useGetReceiptPartnersIntegrationData'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import usePolicy from '@hooks/usePolicy'; -import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScreenBoundDynamicRoute from '@hooks/useScreenBoundDynamicRoute'; @@ -33,9 +27,6 @@ import type {MenuItemData} from '@pages/workspace/accounting/types'; import withUnifiedConnectionsBeta from '@pages/workspace/connections/withUnifiedConnectionsBeta'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; -import {openExternalLink} from '@userActions/Link'; -import {openPolicyReceiptPartnersPage, removePolicyReceiptPartnersConnection, togglePolicyUberAutoInvite, togglePolicyUberAutoRemove} from '@userActions/Policy/Policy'; - import CONST from '@src/CONST'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -43,61 +34,45 @@ import type {AnchorPosition} from '@src/styles'; import type {ComponentRef} from 'react'; -import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import React, {useEffect, useRef} from 'react'; import {View} from 'react-native'; +import useReceiptPartnersSettings from './useReceiptPartnersSettings'; import getSynchronizationErrorMessage from './utils'; type WorkspaceReceiptPartnersPageProps = PlatformStackScreenProps; function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps) { const policyID = route.params.policyID; - const icons = useMemoizedLazyExpensifyIcons(['Key', 'Mail', 'NewWindow', 'Trashcan']); + const icons = useMemoizedLazyExpensifyIcons(['Mail']); const {translate} = useLocalize(); const buildDynamicRoute = useScreenBoundDynamicRoute(); const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const {showConfirmModal} = useConfirmModal(); - const receiptPartnerNames = CONST.POLICY.RECEIPT_PARTNERS.NAME; - const receiptPartnerIntegrations = Object.values(receiptPartnerNames); + const receiptPartnerIntegrations = Object.values(CONST.POLICY.RECEIPT_PARTNERS.NAME); const threeDotsMenuContainerRef = useRef>(null); - const policy = usePolicy(policyID); + const { + policy, + integrations, + isAutoInvite, + isAutoRemove, + isUberConnected, + shouldShowEnterCredentialsError, + getReceiptPartnersIntegrationData, + isOffline, + canWriteMoreFeatures, + showReadOnlyModal, + withReadOnlyFallback, + startIntegrationFlow, + toggleUberAutoInvite, + toggleUberAutoRemove, + getOverflowMenu, + } = useReceiptPartnersSettings(policyID); useWorkspaceDocumentTitle(policy?.name, 'workspace.common.receiptPartners'); - const {getReceiptPartnersIntegrationData, shouldShowEnterCredentialsError, isUberConnected} = useGetReceiptPartnersIntegrationData(policyID); const isLoading = policy?.isLoading; - const integrations = policy?.receiptPartners; - const isAutoRemove = !!integrations?.uber?.autoRemove; - const isAutoInvite = !!integrations?.uber?.autoInvite; const centralBillingAccountEmail = !!integrations?.uber?.centralBillingAccountEmail; // Track focus and connection change to route to the invite flow once after successful connection const prevIsUberConnected = usePrevious(isUberConnected); - const {canWrite: canWriteMoreFeatures, showReadOnlyModal, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); - - const startIntegrationFlow = useCallback( - ({name}: {name: string}) => { - switch (name) { - case CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER: { - openExternalLink(`${CONST.UBER_CONNECT_URL}?${integrations?.uber?.connectFormData}`); - break; - } - default: { - break; - } - } - }, - [integrations?.uber?.connectFormData], - ); - - const fetchReceiptPartners = useCallback(() => { - openPolicyReceiptPartnersPage(policyID); - }, [policyID]); - - const {isOffline} = useNetwork({onReconnect: fetchReceiptPartners}); - - useEffect(() => { - fetchReceiptPartners(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // When Uber connection status flips from false -> true, navigate to the invite flow once useEffect(() => { @@ -107,7 +82,7 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps Navigation.navigate(buildDynamicRoute(DYNAMIC_ROUTES.WORKSPACE_RECEIPT_PARTNERS_INVITE.getRoute(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER))); }, [prevIsUberConnected, isUberConnected, policyID, canWriteMoreFeatures, buildDynamicRoute]); - const calculateAndSetThreeDotsMenuPosition = useCallback(() => { + const calculateAndSetThreeDotsMenuPosition = () => { if (shouldUseNarrowLayout) { return Promise.resolve({horizontal: 0, vertical: 0}); } @@ -119,76 +94,9 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps }); }); }); - }, [shouldUseNarrowLayout]); - - const toggleWorkspaceUberAutoInvite = useCallback(() => { - togglePolicyUberAutoInvite(policyID, !isAutoInvite); - }, [isAutoInvite, policyID]); - - const toggleWorkspaceUberAutoRemove = useCallback(() => { - togglePolicyUberAutoRemove(policyID, !isAutoRemove); - }, [isAutoRemove, policyID]); + }; - const disconnectPartner = useCallback( - (partner: (typeof receiptPartnerNames)[keyof typeof receiptPartnerNames]) => { - if (!policyID) { - return; - } - removePolicyReceiptPartnersConnection(policyID, partner, integrations?.[partner]); - fetchReceiptPartners(); - }, - [policyID, integrations, fetchReceiptPartners], - ); - - const getOverflowMenu = useCallback( - (integration: string) => { - switch (integration) { - case CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER: - if (shouldShowEnterCredentialsError) { - return [ - { - icon: icons.Key, - text: translate('workspace.accounting.enterCredentials'), - onSelected: () => - startIntegrationFlow({ - name: CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER, - }), - shouldCallAfterModalHide: true, - disabled: isOffline, - iconRight: icons.NewWindow, - }, - ]; - } - - return [ - { - icon: icons.Trashcan, - text: translate('workspace.accounting.disconnect'), - onSelected: () => { - showConfirmModal({ - title: translate('workspace.moreFeatures.receiptPartnersWarningModal.featureEnabledTitle'), - prompt: translate('workspace.moreFeatures.receiptPartnersWarningModal.description'), - confirmText: translate('workspace.accounting.disconnect'), - cancelText: translate('common.cancel'), - buttonVariant: CONST.BUTTON_VARIANT.DANGER, - }).then(({action}) => { - if (action !== ModalActions.CONFIRM) { - return; - } - disconnectPartner(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER); - }); - }, - shouldCallAfterModalHide: true, - }, - ]; - default: - return []; - } - }, - [icons.Key, icons.NewWindow, icons.Trashcan, shouldShowEnterCredentialsError, translate, isOffline, startIntegrationFlow, showConfirmModal, disconnectPartner], - ); - - const connectionsMenuItems: MenuItemData[] = useMemo(() => { + const getConnectionsMenuItems = (): MenuItemData[] => { if (policyID) { return receiptPartnerIntegrations .map((integration) => { @@ -271,24 +179,8 @@ function WorkspaceReceiptPartnersPage({route}: WorkspaceReceiptPartnersPageProps } return []; - }, [ - policyID, - receiptPartnerIntegrations, - getReceiptPartnersIntegrationData, - getOverflowMenu, - canWriteMoreFeatures, - shouldShowEnterCredentialsError, - translate, - styles, - shouldUseNarrowLayout, - isUberConnected, - calculateAndSetThreeDotsMenuPosition, - policy?.receiptPartners?.uber, - policy?.isLoadingReceiptPartners, - isOffline, - startIntegrationFlow, - showReadOnlyModal, - ]); + }; + const connectionsMenuItems = getConnectionsMenuItems(); return ( void; +}; + +/** State and actions for the workspace's receipt partner connections, shared by the Receipt partners page and its Connections panel */ +function useReceiptPartnersSettings(policyID: string, {onDisconnectConfirmed}: UseReceiptPartnersSettingsOptions = {}) { + const icons = useMemoizedLazyExpensifyIcons(['Key', 'NewWindow', 'Trashcan']); + const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + const policy = usePolicy(policyID); + const {getReceiptPartnersIntegrationData, shouldShowEnterCredentialsError, isUberConnected} = useGetReceiptPartnersIntegrationData(policyID); + const integrations = policy?.receiptPartners; + const isAutoRemove = !!integrations?.uber?.autoRemove; + const isAutoInvite = !!integrations?.uber?.autoInvite; + const {canWrite: canWriteMoreFeatures, showReadOnlyModal, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.MORE_FEATURES); + + const fetchReceiptPartners = () => { + openPolicyReceiptPartnersPage(policyID); + }; + + const {isOffline} = useNetwork({onReconnect: fetchReceiptPartners}); + + useEffect(() => { + openPolicyReceiptPartnersPage(policyID); + }, [policyID]); + + const startIntegrationFlow = ({name}: {name: string}) => { + switch (name) { + case CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER: { + openExternalLink(`${CONST.UBER_CONNECT_URL}?${integrations?.uber?.connectFormData}`); + break; + } + default: { + break; + } + } + }; + + const toggleUberAutoInvite = () => { + togglePolicyUberAutoInvite(policyID, !isAutoInvite); + }; + + const toggleUberAutoRemove = () => { + togglePolicyUberAutoRemove(policyID, !isAutoRemove); + }; + + const disconnectPartner = (partner: ValueOf) => { + if (!policyID) { + return; + } + removePolicyReceiptPartnersConnection(policyID, partner, integrations?.[partner]); + fetchReceiptPartners(); + }; + + const getOverflowMenu = (integration: string): PopoverMenuItem[] => { + switch (integration) { + case CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER: + if (shouldShowEnterCredentialsError) { + return [ + { + icon: icons.Key, + text: translate('workspace.accounting.enterCredentials'), + onSelected: () => + startIntegrationFlow({ + name: CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER, + }), + shouldCallAfterModalHide: true, + disabled: isOffline, + iconRight: icons.NewWindow, + }, + ]; + } + + return [ + { + icon: icons.Trashcan, + text: translate('workspace.accounting.disconnect'), + onSelected: () => { + showConfirmModal({ + title: translate('workspace.moreFeatures.receiptPartnersWarningModal.featureEnabledTitle'), + prompt: translate('workspace.moreFeatures.receiptPartnersWarningModal.description'), + confirmText: translate('workspace.accounting.disconnect'), + cancelText: translate('common.cancel'), + buttonVariant: CONST.BUTTON_VARIANT.DANGER, + }).then(({action}) => { + if (action !== ModalActions.CONFIRM) { + return; + } + onDisconnectConfirmed?.(); + disconnectPartner(CONST.POLICY.RECEIPT_PARTNERS.NAME.UBER); + }); + }, + shouldCallAfterModalHide: true, + }, + ]; + default: + return []; + } + }; + + return { + policy, + integrations, + isAutoInvite, + isAutoRemove, + isUberConnected, + shouldShowEnterCredentialsError, + getReceiptPartnersIntegrationData, + isOffline, + canWriteMoreFeatures, + showReadOnlyModal, + withReadOnlyFallback, + startIntegrationFlow, + toggleUberAutoInvite, + toggleUberAutoRemove, + getOverflowMenu, + }; +} + +export default useReceiptPartnersSettings; diff --git a/tests/actions/PolicyTest.ts b/tests/actions/PolicyTest.ts index e7feb1f64a75..25f6e2ec4441 100644 --- a/tests/actions/PolicyTest.ts +++ b/tests/actions/PolicyTest.ts @@ -5571,6 +5571,38 @@ describe('actions/Policy', () => { }); }); + describe('enablePolicyFeatureForConnection', () => { + it('should turn the feature on when it is off, since the Connections page has no toggle for it', async () => { + // Given a workspace with HR off + const apiWriteSpy = jest.spyOn(APIModule, 'writeWithNoDuplicatesEnableFeatureConflicts').mockImplementation(() => Promise.resolve()); + const fakePolicy: PolicyType = {...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), isHREnabled: false, connections: undefined}; + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy); + await waitForBatchedUpdates(); + + // When an HR provider is connected from the Connections page + Policy.enablePolicyFeatureForConnection(fakePolicy, CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED); + await waitForBatchedUpdates(); + + // Then HR is turned on, so the connection's settings pages are reachable + expect(apiWriteSpy).toHaveBeenCalledWith(WRITE_COMMANDS.ENABLE_POLICY_HR, expect.objectContaining({policyID: fakePolicy.id, enabled: true}), expect.anything()); + apiWriteSpy.mockRestore(); + }); + + it('should not call the API when the feature is already on', async () => { + // Given a workspace with HR already on + const apiWriteSpy = jest.spyOn(APIModule, 'writeWithNoDuplicatesEnableFeatureConflicts').mockImplementation(() => Promise.resolve()); + const fakePolicy: PolicyType = {...createRandomPolicy(0, CONST.POLICY.TYPE.CORPORATE), isHREnabled: true}; + + // When an HR provider is connected from the Connections page + Policy.enablePolicyFeatureForConnection(fakePolicy, CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED); + await waitForBatchedUpdates(); + + // Then nothing is sent, since there is nothing to turn on + expect(apiWriteSpy).not.toHaveBeenCalled(); + apiWriteSpy.mockRestore(); + }); + }); + describe('enablePolicyTaxes', () => { it('should enable policy taxes optimistically and succeed', async () => { // Given a policy with taxes disabled diff --git a/tests/unit/ConnectionsAccountingPageQBOTokenExpiryTest.tsx b/tests/unit/ConnectionsAccountingPageQBOTokenExpiryTest.tsx deleted file mode 100644 index f40e28e739b1..000000000000 --- a/tests/unit/ConnectionsAccountingPageQBOTokenExpiryTest.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import {fireEvent, render, screen} from '@testing-library/react-native'; - -import ComposeProviders from '@components/ComposeProviders'; -import {LocaleContextProvider} from '@components/LocaleContextProvider'; -import OnyxListItemProvider from '@components/OnyxListItemProvider'; -import type {PopoverMenuItem} from '@components/PopoverMenu'; -import type TextComponent from '@components/Text'; - -import DateUtils from '@libs/DateUtils'; - -import ConnectionsAccountingPage from '@pages/workspace/connections/ConnectionsAccountingPage'; - -import CONST from '@src/CONST'; -import IntlStore from '@src/languages/IntlStore'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type Policy from '@src/types/onyx/Policy'; - -import type * as ReactNavigation from '@react-navigation/native'; - -import {addDays, getUnixTime, subDays} from 'date-fns'; -import React from 'react'; -import Onyx from 'react-native-onyx'; - -import createRandomPolicy from '../utils/collections/policies'; -import createMock from '../utils/createMock'; -import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; - -const POLICY_ID = 'policy-1'; -const OVERFLOW_MENU_ITEM_PREFIX = 'overflow:'; - -const mockStartIntegrationFlow = jest.fn(); -let mockCanWriteAccounting = true; - -jest.mock('@react-navigation/native', () => { - const actualNavigation = jest.requireActual('@react-navigation/native'); - return { - ...actualNavigation, - useFocusEffect: () => {}, - useRoute: () => ({params: {}}), - useNavigation: () => ({}), - useIsFocused: () => true, - }; -}); - -jest.mock('@libs/Navigation/Navigation', () => ({ - __esModule: true, - default: { - setParams: jest.fn(), - navigate: jest.fn(), - goBack: jest.fn(), - isNavigationReady: () => Promise.resolve(), - }, -})); - -jest.mock('@pages/workspace/accounting/AccountingContext', () => ({ - __esModule: true, - AccountingContextProvider: ({children}: {children: React.ReactNode}) => children, - useAccountingActions: () => ({startIntegrationFlow: mockStartIntegrationFlow}), - useAccountingState: () => ({activeIntegration: undefined, popoverAnchorRefs: {current: {}}}), -})); - -jest.mock('@pages/workspace/withPolicyConnections', () => ({ - __esModule: true, - default: (Component: React.ComponentType<{policy: Policy}>) => Component, -})); - -jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => ({ - __esModule: true, - default: ({children}: {children: React.ReactNode}) => children, -})); - -jest.mock('@components/ScreenWrapper', () => ({ - __esModule: true, - default: ({children}: {children: React.ReactNode}) => children, -})); - -jest.mock('@components/ScrollView', () => ({ - __esModule: true, - default: ({children}: {children: React.ReactNode}) => children, -})); - -jest.mock('@components/MenuItemList', () => ({__esModule: true, default: () => null})); -jest.mock('@components/Section', () => ({__esModule: true, default: ({children}: {children: React.ReactNode}) => children})); -jest.mock('@components/CollapsibleSection', () => ({__esModule: true, default: () => null})); -jest.mock('@components/ActivityIndicator', () => ({__esModule: true, default: () => null})); - -// The header's three-dot menu only renders its entries inside a popover once pressed. Rendering their labels inline lets the tests -// read which credentials entry the page offers without driving the popover. -jest.mock('@components/Header/primitives/HeaderThreeDotsMenu', () => { - const {default: MockText} = jest.requireActual<{default: typeof TextComponent}>('@components/Text'); - return { - __esModule: true, - default: ({items}: {items: PopoverMenuItem[]}) => items.map((menuItem) => {`${OVERFLOW_MENU_ITEM_PREFIX}${menuItem.text}`}), - }; -}); - -jest.mock('@hooks/usePolicyFeatureWriteAccess', () => ({ - __esModule: true, - default: () => ({canWrite: mockCanWriteAccounting, showReadOnlyModal: () => {}}), -})); - -// The real `withPolicyConnections` HOC reads `policy` from Onyx and strips it from the component's public props. It is -// mocked to an identity wrapper above, so the component under test takes `policy` directly. -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only: the HOC that would inject `policy` is mocked out, so it is passed as a prop here -const ConnectionsAccountingPageUnderTest = ConnectionsAccountingPage as unknown as React.ComponentType<{policy: Policy}>; - -/** - * A healthy QBO connection (synced, no error) whose refresh token expires at the given time. `data` is populated so the - * connection does not read as a first sync still in progress. - */ -function buildQBOPolicy(refreshTokenExpiresAt: Date, isAuthenticationError = false): Policy { - return { - ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE, 'Test workspace'), - id: POLICY_ID, - connections: createMock({ - [CONST.POLICY.CONNECTIONS.NAME.QBO]: { - config: { - credentials: { - companyID: '12345', - refreshTokenExpiresAt: getUnixTime(refreshTokenExpiresAt), - }, - }, - data: {}, - lastSync: { - isSuccessful: !isAuthenticationError, - isAuthenticationError, - isConnected: true, - successfulDate: subDays(new Date(), 1).toISOString(), - source: 'NEWEXPENSIFY', - }, - }, - }), - }; -} - -function formatExpiryDate(expiryDate: Date): string { - return DateUtils.formatWithUTCTimeZone(expiryDate.toISOString(), CONST.DATE.MONTH_DAY_YEAR_FORMAT, undefined); -} - -async function renderPage(policy: Policy) { - render( - - - , - ); - await waitForBatchedUpdates(); -} - -describe('ConnectionsAccountingPage QBO refresh token expiry warning', () => { - beforeAll(() => { - Onyx.init({keys: ONYXKEYS}); - IntlStore.load(CONST.LOCALES.EN); - return waitForBatchedUpdates(); - }); - - beforeEach(() => { - jest.clearAllMocks(); - mockCanWriteAccounting = true; - }); - - it('should warn with the expiry date and offer to reconnect when the token expires within the warning window', async () => { - // Given a healthy QBO connection whose refresh token expires in a few days - const expiryDate = addDays(new Date(), 3); - await renderPage(buildQBOPolicy(expiryDate)); - - // Then the connection row explains when the connection will expire - expect(screen.getByText(`Your QuickBooks Online connection expires on ${formatExpiryDate(expiryDate)}.`, {exact: false})).toBeOnTheScreen(); - - // And the overflow menu gains a credentials entry that reads Reconnect rather than Enter your credentials - expect(screen.getByText(`${OVERFLOW_MENU_ITEM_PREFIX}Reconnect`)).toBeOnTheScreen(); - expect(screen.queryByText(`${OVERFLOW_MENU_ITEM_PREFIX}Enter your credentials`)).not.toBeOnTheScreen(); - - // When the user presses the Reconnect link in the warning - fireEvent.press(screen.getByRole('link', {name: 'Reconnect'}), {preventDefault: () => {}}); - - // Then the QBO connect flow starts so a fresh token is issued before the current one lapses - expect(mockStartIntegrationFlow).toHaveBeenCalledTimes(1); - expect(mockStartIntegrationFlow).toHaveBeenCalledWith({name: CONST.POLICY.CONNECTIONS.NAME.QBO, isIntuitEnterpriseSuite: false}); - }); - - it('should say the connection expired when the token is past its expiry but no sync has failed yet', async () => { - // Given a QBO connection whose refresh token lapsed yesterday while the last sync still reads as successful - const expiryDate = subDays(new Date(), 1); - await renderPage(buildQBOPolicy(expiryDate)); - - // Then the row says the connection already expired and still offers to reconnect - expect(screen.getByText(`Your QuickBooks Online connection expired on ${formatExpiryDate(expiryDate)}.`, {exact: false})).toBeOnTheScreen(); - expect(screen.getByRole('link', {name: 'Reconnect'})).toBeOnTheScreen(); - }); - - it('should not warn while the token is still far from expiring', async () => { - // Given a healthy QBO connection whose refresh token is valid well past the warning window - await renderPage(buildQBOPolicy(addDays(new Date(), CONST.POLICY.CONNECTIONS.QBO_REFRESH_TOKEN_EXPIRY_WARNING_DAYS + 30))); - - // Then no expiry warning is shown and the overflow menu offers no credentials entry, as a healthy QBO connection - // has nothing to re-enter - expect(screen.queryByText('Your QuickBooks Online connection', {exact: false})).not.toBeOnTheScreen(); - expect(screen.queryByRole('link', {name: 'Reconnect'})).not.toBeOnTheScreen(); - expect(screen.queryByText(`${OVERFLOW_MENU_ITEM_PREFIX}Reconnect`)).not.toBeOnTheScreen(); - expect(screen.queryByText(`${OVERFLOW_MENU_ITEM_PREFIX}Enter your credentials`)).not.toBeOnTheScreen(); - }); - - it('should leave the authentication error in charge once a sync has already failed on the expired token', async () => { - // Given a QBO connection whose token lapsed and whose last sync already failed to authenticate - await renderPage(buildQBOPolicy(subDays(new Date(), 1), true)); - - // Then the standard authentication error is shown instead of a second, redundant expiry warning - expect(screen.queryByText('Your QuickBooks Online connection', {exact: false})).not.toBeOnTheScreen(); - expect(screen.getByText('due to an authentication error', {exact: false})).toBeOnTheScreen(); - }); - - it('should not warn a member who can only read the accounting settings', async () => { - // Given a QBO connection whose refresh token expires in a few days, viewed by a member without write access to accounting - mockCanWriteAccounting = false; - await renderPage(buildQBOPolicy(addDays(new Date(), 3))); - - // Then no expiry warning or Reconnect link is shown, since reconnecting is a write action this member cannot take - expect(screen.queryByText('Your QuickBooks Online connection', {exact: false})).not.toBeOnTheScreen(); - expect(screen.queryByRole('link', {name: 'Reconnect'})).not.toBeOnTheScreen(); - expect(screen.queryByText(`${OVERFLOW_MENU_ITEM_PREFIX}Reconnect`)).not.toBeOnTheScreen(); - }); -}); diff --git a/tests/unit/ConnectionsPanelGatesTest.tsx b/tests/unit/ConnectionsPanelGatesTest.tsx new file mode 100644 index 000000000000..5e792a19783e --- /dev/null +++ b/tests/unit/ConnectionsPanelGatesTest.tsx @@ -0,0 +1,169 @@ +import {render, screen} from '@testing-library/react-native'; + +import type TextComponent from '@components/Text'; + +import ConnectionsAccountingPage from '@pages/workspace/connections/ConnectionsAccountingPage'; +import ConnectionsMergePageBase from '@pages/workspace/connections/ConnectionsMergePageBase'; +import ConnectionsReceiptPartnersPage from '@pages/workspace/connections/ConnectionsReceiptPartnersPage'; +import type {MergeProviderCardDescriptor} from '@pages/workspace/merge/types'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; + +import React from 'react'; + +import createRandomPolicy from '../utils/collections/policies'; + +const NOT_FOUND = 'Not found'; +const POLICY_ID = 'policy-1'; + +let mockIsUnifiedConnectionsBetaEnabled = true; +let mockConnectedIntegration: string | undefined; +let mockIsUberConnected = false; + +// The gates under test are the props each panel passes here, so the wrapper only shows whether the panel is blocked +jest.mock('@pages/workspace/AccessOrNotFoundWrapper', () => { + const {default: MockText} = jest.requireActual<{default: typeof TextComponent}>('@components/Text'); + return { + __esModule: true, + default: ({children, shouldBeBlocked}: {children: React.ReactNode; shouldBeBlocked?: boolean}) => (shouldBeBlocked ? Not found : children), + }; +}); +jest.mock('@components/ScreenWrapper', () => ({__esModule: true, default: () => null})); +jest.mock('@components/FullscreenLoadingIndicator', () => ({__esModule: true, default: () => null})); +jest.mock('@hooks/useIsUnifiedConnectionsBetaEnabled', () => () => mockIsUnifiedConnectionsBetaEnabled); +jest.mock('@hooks/useWorkspaceDocumentTitle', () => () => {}); +jest.mock('@hooks/useScreenBoundDynamicRoute', () => () => jest.fn()); +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); +jest.mock('@hooks/useNetwork', () => () => ({isOffline: false})); +jest.mock('@hooks/usePolicy', () => () => undefined); +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => () => ({canWrite: true, showReadOnlyModal: jest.fn()})); +jest.mock('@libs/actions/PolicyConnections', () => ({openPolicyHRPage: jest.fn(), openPolicyRecruitingPage: jest.fn()})); +jest.mock('@pages/workspace/withPolicyConnections', () => ({ + __esModule: true, + default: (Component: React.ComponentType<{policy: Policy}>) => Component, +})); +jest.mock('@pages/workspace/accounting/AccountingContext', () => ({ + AccountingContextProvider: ({children}: {children: React.ReactNode}) => children, +})); +jest.mock('@pages/workspace/accounting/useConnectedAccountingIntegration', () => ({ + __esModule: true, + default: () => ({connectedIntegration: mockConnectedIntegration, hasUnsupportedNDIntegration: false}), +})); +jest.mock('@pages/workspace/receiptPartners/useReceiptPartnersSettings', () => ({ + __esModule: true, + default: () => ({ + policy: {isLoading: false}, + isUberConnected: mockIsUberConnected, + shouldShowEnterCredentialsError: false, + getReceiptPartnersIntegrationData: () => undefined, + canWriteMoreFeatures: true, + withReadOnlyFallback: () => undefined, + toggleUberAutoInvite: jest.fn(), + toggleUberAutoRemove: jest.fn(), + getOverflowMenu: () => [], + }), +})); + +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only: the HOC that would inject `policy` is mocked out, so it is passed as a prop here +const ConnectionsAccountingPageUnderTest = ConnectionsAccountingPage as unknown as React.ComponentType<{policy: Policy}>; +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only: the panel only reads the policy ID from its route +const ConnectionsReceiptPartnersPageUnderTest = ConnectionsReceiptPartnersPage as unknown as React.ComponentType<{route: {params: {policyID: string}}}>; + +const policy: Policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), id: POLICY_ID}; + +function buildHRCard(isConnected: boolean): MergeProviderCardDescriptor { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only: the gate only reads isConnected + return {connectionName: 'gusto', displayName: 'Gusto', category: CONST.POLICY.CONNECTIONS.CATEGORY.HR, isConnected} as MergeProviderCardDescriptor; +} + +describe('Connections panel gates', () => { + beforeEach(() => { + mockIsUnifiedConnectionsBetaEnabled = true; + mockConnectedIntegration = undefined; + mockIsUberConnected = false; + }); + + describe('ConnectionsAccountingPage', () => { + it('should block the panel for a user without the beta, even with an integration connected', () => { + // Given a connected workspace viewed by a user without the unified Connections beta + mockIsUnifiedConnectionsBetaEnabled = false; + mockConnectedIntegration = CONST.POLICY.CONNECTIONS.NAME.QBO; + + // When the panel opens from a link + render(); + + // Then it shows Not Found, since this user still has the Accounting page + expect(screen.getByText(NOT_FOUND)).toBeOnTheScreen(); + }); + + it('should block the panel when no accounting integration is connected', () => { + // Given a beta user on a workspace with nothing connected + render(); + + // Then the panel shows Not Found, since it only holds a connected integration's settings + expect(screen.getByText(NOT_FOUND)).toBeOnTheScreen(); + }); + + it('should open the panel for a beta user with an integration connected', () => { + // Given a beta user on a workspace connected to QuickBooks Online + mockConnectedIntegration = CONST.POLICY.CONNECTIONS.NAME.QBO; + + // When the panel opens + render(); + + // Then its settings render + expect(screen.queryByText(NOT_FOUND)).not.toBeOnTheScreen(); + }); + }); + + describe('ConnectionsMergePageBase', () => { + it('should block the panel when no provider of the category is connected', () => { + // Given a beta user on a workspace with no HR provider connected + render( + , + ); + + // Then the panel shows Not Found + expect(screen.getByText(NOT_FOUND)).toBeOnTheScreen(); + }); + + it('should open the panel once a provider is connected', () => { + // Given a beta user on a workspace with Gusto connected + render( + , + ); + + // Then its settings render + expect(screen.queryByText(NOT_FOUND)).not.toBeOnTheScreen(); + }); + }); + + describe('ConnectionsReceiptPartnersPage', () => { + it('should block the panel when Uber is not connected', () => { + // Given a beta user on a workspace without Uber + render(); + + // Then the panel shows Not Found + expect(screen.getByText(NOT_FOUND)).toBeOnTheScreen(); + }); + + it('should block the panel for a user without the beta', () => { + // Given Uber is connected but the user doesn't have the beta + mockIsUnifiedConnectionsBetaEnabled = false; + mockIsUberConnected = true; + render(); + + // Then the panel shows Not Found, since this user still has the Receipt partners page + expect(screen.getByText(NOT_FOUND)).toBeOnTheScreen(); + }); + }); +}); diff --git a/tests/unit/PolicyAccountingPageQBOTokenExpiryTest.tsx b/tests/unit/PolicyAccountingPageQBOTokenExpiryTest.tsx index 94038a342332..77b52432932d 100644 --- a/tests/unit/PolicyAccountingPageQBOTokenExpiryTest.tsx +++ b/tests/unit/PolicyAccountingPageQBOTokenExpiryTest.tsx @@ -9,6 +9,7 @@ import type TextComponent from '@components/Text'; import DateUtils from '@libs/DateUtils'; import PolicyAccountingPage from '@pages/workspace/accounting/PolicyAccountingPage'; +import ConnectionsAccountingPage from '@pages/workspace/connections/ConnectionsAccountingPage'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; @@ -85,7 +86,7 @@ jest.mock('@components/Section', () => ({__esModule: true, default: ({children}: jest.mock('@components/CollapsibleSection', () => ({__esModule: true, default: () => null})); jest.mock('@components/ActivityIndicator', () => ({__esModule: true, default: () => null})); -// The real menu only renders its entries inside a popover once pressed. Rendering their labels inline lets the tests +// The real menus only render their entries inside a popover once pressed. Rendering their labels inline lets the tests // read which credentials entry the page offers without driving the popover. jest.mock('@components/ThreeDotsMenu', () => { const {default: MockText} = jest.requireActual<{default: typeof TextComponent}>('@components/Text'); @@ -94,6 +95,13 @@ jest.mock('@components/ThreeDotsMenu', () => { default: ({menuItems}: {menuItems: PopoverMenuItem[]}) => menuItems.map((menuItem) => {`${OVERFLOW_MENU_ITEM_PREFIX}${menuItem.text}`}), }; }); +jest.mock('@components/Header/primitives/HeaderThreeDotsMenu', () => { + const {default: MockText} = jest.requireActual<{default: typeof TextComponent}>('@components/Text'); + return { + __esModule: true, + default: ({items}: {items: PopoverMenuItem[]}) => items.map((menuItem) => {`${OVERFLOW_MENU_ITEM_PREFIX}${menuItem.text}`}), + }; +}); jest.mock('@hooks/usePolicyFeatureWriteAccess', () => ({ __esModule: true, @@ -102,8 +110,13 @@ jest.mock('@hooks/usePolicyFeatureWriteAccess', () => ({ // The real `withPolicyConnections` HOC reads `policy` from Onyx and strips it from the component's public props. It is // mocked to an identity wrapper above, so the component under test takes `policy` directly. -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only: the HOC that would inject `policy` is mocked out, so it is passed as a prop here -const PolicyAccountingPageUnderTest = PolicyAccountingPage as unknown as React.ComponentType<{policy: Policy}>; +// The Accounting page and the Connections accounting panel share the connected integration's logic, so both are covered. +const PAGES_UNDER_TEST = [ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only: the HOC that would inject `policy` is mocked out, so it is passed as a prop here + {pageName: 'PolicyAccountingPage', PageUnderTest: PolicyAccountingPage as unknown as React.ComponentType<{policy: Policy}>}, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only: the HOC that would inject `policy` is mocked out, so it is passed as a prop here + {pageName: 'ConnectionsAccountingPage', PageUnderTest: ConnectionsAccountingPage as unknown as React.ComponentType<{policy: Policy}>}, +]; /** * A healthy QBO connection (synced, no error) whose refresh token expires at the given time. `data` is populated so the @@ -138,16 +151,16 @@ function formatExpiryDate(expiryDate: Date): string { return DateUtils.formatWithUTCTimeZone(expiryDate.toISOString(), CONST.DATE.MONTH_DAY_YEAR_FORMAT, undefined); } -async function renderPage(policy: Policy) { +async function renderPage(PageUnderTest: React.ComponentType<{policy: Policy}>, policy: Policy) { render( - + , ); await waitForBatchedUpdates(); } -describe('PolicyAccountingPage QBO refresh token expiry warning', () => { +describe.each(PAGES_UNDER_TEST)('$pageName QBO refresh token expiry warning', ({PageUnderTest}) => { beforeAll(() => { Onyx.init({keys: ONYXKEYS}); IntlStore.load(CONST.LOCALES.EN); @@ -162,7 +175,7 @@ describe('PolicyAccountingPage QBO refresh token expiry warning', () => { it('should warn with the expiry date and offer to reconnect when the token expires within the warning window', async () => { // Given a healthy QBO connection whose refresh token expires in a few days const expiryDate = addDays(new Date(), 3); - await renderPage(buildQBOPolicy(expiryDate)); + await renderPage(PageUnderTest, buildQBOPolicy(expiryDate)); // Then the connection row explains when the connection will expire expect(screen.getByText(`Your QuickBooks Online connection expires on ${formatExpiryDate(expiryDate)}.`, {exact: false})).toBeOnTheScreen(); @@ -182,7 +195,7 @@ describe('PolicyAccountingPage QBO refresh token expiry warning', () => { it('should say the connection expired when the token is past its expiry but no sync has failed yet', async () => { // Given a QBO connection whose refresh token lapsed yesterday while the last sync still reads as successful const expiryDate = subDays(new Date(), 1); - await renderPage(buildQBOPolicy(expiryDate)); + await renderPage(PageUnderTest, buildQBOPolicy(expiryDate)); // Then the row says the connection already expired and still offers to reconnect expect(screen.getByText(`Your QuickBooks Online connection expired on ${formatExpiryDate(expiryDate)}.`, {exact: false})).toBeOnTheScreen(); @@ -191,7 +204,7 @@ describe('PolicyAccountingPage QBO refresh token expiry warning', () => { it('should not warn while the token is still far from expiring', async () => { // Given a healthy QBO connection whose refresh token is valid well past the warning window - await renderPage(buildQBOPolicy(addDays(new Date(), CONST.POLICY.CONNECTIONS.QBO_REFRESH_TOKEN_EXPIRY_WARNING_DAYS + 30))); + await renderPage(PageUnderTest, buildQBOPolicy(addDays(new Date(), CONST.POLICY.CONNECTIONS.QBO_REFRESH_TOKEN_EXPIRY_WARNING_DAYS + 30))); // Then no expiry warning is shown and the overflow menu offers no credentials entry, as a healthy QBO connection // has nothing to re-enter @@ -203,7 +216,7 @@ describe('PolicyAccountingPage QBO refresh token expiry warning', () => { it('should leave the authentication error in charge once a sync has already failed on the expired token', async () => { // Given a QBO connection whose token lapsed and whose last sync already failed to authenticate - await renderPage(buildQBOPolicy(subDays(new Date(), 1), true)); + await renderPage(PageUnderTest, buildQBOPolicy(subDays(new Date(), 1), true)); // Then the standard authentication error is shown instead of a second, redundant expiry warning expect(screen.queryByText('Your QuickBooks Online connection', {exact: false})).not.toBeOnTheScreen(); @@ -213,7 +226,7 @@ describe('PolicyAccountingPage QBO refresh token expiry warning', () => { it('should not warn a member who can only read the accounting settings', async () => { // Given a QBO connection whose refresh token expires in a few days, viewed by a member without write access to accounting mockCanWriteAccounting = false; - await renderPage(buildQBOPolicy(addDays(new Date(), 3))); + await renderPage(PageUnderTest, buildQBOPolicy(addDays(new Date(), 3))); // Then no expiry warning or Reconnect link is shown, since reconnecting is a write action this member cannot take expect(screen.queryByText('Your QuickBooks Online connection', {exact: false})).not.toBeOnTheScreen(); diff --git a/tests/unit/useAccountingConnectionListingsTest.ts b/tests/unit/useAccountingConnectionListingsTest.ts new file mode 100644 index 000000000000..9e67e7a85e35 --- /dev/null +++ b/tests/unit/useAccountingConnectionListingsTest.ts @@ -0,0 +1,102 @@ +import {renderHook} from '@testing-library/react-native'; + +import useAccountingConnectionListings from '@pages/workspace/connections/useAccountingConnectionListings'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; +import type {ConnectionName} from '@src/types/onyx/Policy'; + +import createRandomPolicy from '../utils/collections/policies'; + +const mockStartIntegrationFlow = jest.fn(); +const mockShowReadOnlyModal = jest.fn(); +let mockCanWrite = true; +let mockConnectedIntegration: ConnectionName | undefined; + +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key, datetimeToRelative: () => '', getLocalDateFromDatetime: jest.fn()})); +jest.mock('@hooks/usePermissions', () => () => ({isBetaEnabled: () => false})); +jest.mock('@hooks/useIsUnifiedConnectionsBetaEnabled', () => () => true); +jest.mock('@hooks/useOnyx', () => () => [undefined]); +jest.mock('@hooks/useThemeStyles', () => () => ({})); +jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: () => ({})})); +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => () => ({canWrite: mockCanWrite, showReadOnlyModal: mockShowReadOnlyModal})); +jest.mock('@libs/actions/connections', () => ({isConnectionInProgress: () => false, isConnectionUnverified: () => false})); +jest.mock('@libs/actions/connections/QuickbooksOnline', () => ({shouldShowQBOReimbursableExportDestinationAccountError: () => false})); +jest.mock('@libs/Navigation/Navigation', () => ({__esModule: true, default: {navigate: jest.fn()}})); +jest.mock('@libs/PolicyUtils', () => ({getConnectedIntegration: () => mockConnectedIntegration, getIntegrationLastSuccessfulDate: () => undefined})); +jest.mock('@pages/workspace/accounting/AccountingContext', () => ({ + useAccountingActions: () => ({startIntegrationFlow: mockStartIntegrationFlow}), + useAccountingState: () => ({activeIntegration: undefined, popoverAnchorRefs: {current: {}}}), +})); +jest.mock('@pages/workspace/accounting/utils', () => ({ + getAccountingIntegrationData: (name: string) => ({title: name}), + getSynchronizationErrorMessage: () => undefined, + isIntuitEnterpriseSuiteConnection: () => false, +})); + +const policy: Policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), id: 'policy-1'}; + +function findListing(key: string) { + const {result} = renderHook(() => useAccountingConnectionListings(policy)); + return result.current.find((listing) => listing.key === key); +} + +describe('useAccountingConnectionListings', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCanWrite = true; + mockConnectedIntegration = undefined; + }); + + it('should start the connect flow for a workspace with no accounting integration', () => { + // Given a workspace with nothing connected + const xero = findListing(CONST.POLICY.CONNECTIONS.NAME.XERO); + + // When the admin connects Xero + xero?.onConnect(); + + // Then Xero's flow starts with nothing to disconnect + expect(mockStartIntegrationFlow).toHaveBeenCalledWith({name: CONST.POLICY.CONNECTIONS.NAME.XERO, isIntuitEnterpriseSuite: undefined}); + }); + + it('should replace the connected integration when the admin connects another one', () => { + // Given a workspace already connected to QuickBooks Online, since only one accounting integration can be connected + mockConnectedIntegration = CONST.POLICY.CONNECTIONS.NAME.QBO; + const xero = findListing(CONST.POLICY.CONNECTIONS.NAME.XERO); + + // When the admin connects Xero + xero?.onConnect(); + + // Then the flow is told to disconnect QuickBooks Online first, which asks the admin before replacing it + expect(mockStartIntegrationFlow).toHaveBeenCalledWith({ + name: CONST.POLICY.CONNECTIONS.NAME.XERO, + isIntuitEnterpriseSuite: undefined, + integrationToDisconnect: CONST.POLICY.CONNECTIONS.NAME.QBO, + shouldDisconnectIntegrationBeforeConnecting: true, + }); + }); + + it('should only show the read-only modal to a member who cannot edit accounting', () => { + // Given a member without write access to accounting + mockCanWrite = false; + const xero = findListing(CONST.POLICY.CONNECTIONS.NAME.XERO); + + // When they try to connect Xero + xero?.onConnect(); + + // Then they are told they can't, and no flow starts + expect(mockShowReadOnlyModal).toHaveBeenCalledTimes(1); + expect(mockStartIntegrationFlow).not.toHaveBeenCalled(); + }); + + it('should show the connected integration with its status', () => { + // Given a workspace connected to QuickBooks Online + mockConnectedIntegration = CONST.POLICY.CONNECTIONS.NAME.QBO; + + // When the listings are built + const qbo = findListing(CONST.POLICY.CONNECTIONS.NAME.QBO); + + // Then QuickBooks Online carries a healthy status, so it shows in the connected section with Configure + expect(qbo?.status?.isBroken).toBe(false); + }); +}); diff --git a/tests/unit/useMCPConnectionListingsTest.ts b/tests/unit/useMCPConnectionListingsTest.ts new file mode 100644 index 000000000000..7a4a019bdf3d --- /dev/null +++ b/tests/unit/useMCPConnectionListingsTest.ts @@ -0,0 +1,56 @@ +import {renderHook} from '@testing-library/react-native'; + +import useMCPConnectionListings from '@pages/workspace/connections/useMCPConnectionListings'; + +import {openExternalLink} from '@userActions/Link'; +import {enablePolicyFeatureForConnection} from '@userActions/Policy/Policy'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; + +import createRandomPolicy from '../utils/collections/policies'; + +const mockShowReadOnlyModal = jest.fn(); +let mockCanWrite = true; + +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); +jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: () => ({})})); +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => () => ({canWrite: mockCanWrite, showReadOnlyModal: mockShowReadOnlyModal})); +jest.mock('@userActions/Link', () => ({openExternalLink: jest.fn()})); +jest.mock('@userActions/Policy/Policy', () => ({enablePolicyFeatureForConnection: jest.fn()})); + +const policy: Policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), id: 'policy-1'}; + +describe('useMCPConnectionListings', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCanWrite = true; + }); + + it('should turn MCP on and open the assistant when an admin connects one', () => { + // Given an admin who can edit the workspace + const {result} = renderHook(() => useMCPConnectionListings(policy)); + const claude = result.current.find((listing) => listing.key === 'claude'); + + // When they connect Claude + claude?.onConnect(); + + // Then MCP is turned on and Claude's connect page opens, since Connections has no MCP toggle + expect(enablePolicyFeatureForConnection).toHaveBeenCalledWith(policy, CONST.POLICY.MORE_FEATURES.IS_MCP_ENABLED); + expect(openExternalLink).toHaveBeenCalledWith(CONST.CLAUDE_CONNECT_URL); + }); + + it('should only show the read-only modal to a member who cannot edit the workspace', () => { + // Given a member without write access to the workspace features + mockCanWrite = false; + const {result} = renderHook(() => useMCPConnectionListings(policy)); + + // When they try to connect an assistant + result.current.at(0)?.onConnect(); + + // Then they are told they can't, and nothing is turned on or opened + expect(mockShowReadOnlyModal).toHaveBeenCalledTimes(1); + expect(enablePolicyFeatureForConnection).not.toHaveBeenCalled(); + expect(openExternalLink).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/useMergeConnectionListingsTest.ts b/tests/unit/useMergeConnectionListingsTest.ts new file mode 100644 index 000000000000..653a95419651 --- /dev/null +++ b/tests/unit/useMergeConnectionListingsTest.ts @@ -0,0 +1,114 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import {ModalActions} from '@components/Modal/Global/ModalContext'; + +import {removePolicyConnection} from '@libs/actions/connections'; +import Navigation from '@libs/Navigation/Navigation'; + +import useMergeConnectionListings from '@pages/workspace/connections/useMergeConnectionListings'; +import type {MergeProviderCardDescriptor} from '@pages/workspace/merge/types'; + +import {enablePolicyFeatureForConnection} from '@userActions/Policy/Policy'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; + +import createRandomPolicy from '../utils/collections/policies'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +const mockShowConfirmModal = jest.fn(); +let mockIsControlPolicy = true; +let mockHRCards: Array> = []; + +jest.mock('@hooks/useLocalize', () => () => ({ + translate: (key: string) => key, + getLocalDateFromDatetime: jest.fn(), + datetimeToRelative: () => '', + formatPhoneNumber: (phone: string) => phone, +})); +jest.mock('@hooks/usePermissions', () => () => ({isBetaEnabled: () => false})); +jest.mock('@hooks/useConfirmModal', () => () => ({showConfirmModal: mockShowConfirmModal})); +jest.mock('@hooks/useOnyx', () => () => [undefined]); +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => () => ({canWrite: true, showReadOnlyModal: jest.fn()})); +jest.mock('@hooks/useLazyAsset', () => ({useMemoizedLazyExpensifyIcons: () => ({})})); +jest.mock('@libs/actions/connections', () => ({removePolicyConnection: jest.fn()})); +jest.mock('@libs/Navigation/Navigation', () => ({__esModule: true, default: {navigate: jest.fn()}})); +jest.mock('@libs/PolicyUtils', () => ({isControlPolicy: () => mockIsControlPolicy})); +jest.mock('@pages/workspace/hr/utils', () => ({getHRCards: () => mockHRCards})); +jest.mock('@pages/workspace/recruiting/utils', () => ({getRecruitingCards: () => []})); +jest.mock('@userActions/Policy/Policy', () => ({enablePolicyFeatureForConnection: jest.fn()})); + +const policy: Policy = {...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), id: 'policy-1'}; + +function buildCard(connectionName: MergeProviderCardDescriptor['connectionName'], isConnected: boolean): Partial { + return {connectionName, displayName: connectionName, category: CONST.POLICY.CONNECTIONS.CATEGORY.HR, isConnected, setupLink: `https://merge.dev/${connectionName}`}; +} + +function renderListings() { + const onStartSetup = jest.fn(); + const {result} = renderHook(() => useMergeConnectionListings(policy, onStartSetup)); + return {listings: result.current, onStartSetup}; +} + +describe('useMergeConnectionListings', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsControlPolicy = true; + mockHRCards = [buildCard(CONST.POLICY.CONNECTIONS.NAME.GUSTO, false), buildCard(CONST.POLICY.CONNECTIONS.NAME.ZENEFITS, false)]; + }); + + it('should send a workspace that is not on Control to the upgrade page', () => { + // Given a workspace that is not on the Control plan, which HR requires + mockIsControlPolicy = false; + const {listings, onStartSetup} = renderListings(); + + // When the admin connects an HR provider + listings.at(0)?.onConnect(); + + // Then the upgrade page opens instead of the provider's setup + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + expect(onStartSetup).not.toHaveBeenCalled(); + }); + + it('should turn HR on and start the setup when no provider is connected yet', () => { + // Given a Control workspace with no HR provider connected + const {listings, onStartSetup} = renderListings(); + + // When the admin connects Gusto + listings.find((listing) => listing.title === CONST.POLICY.CONNECTIONS.NAME.GUSTO)?.onConnect(); + + // Then HR is turned on and Gusto's setup starts, with nothing to replace + expect(enablePolicyFeatureForConnection).toHaveBeenCalledWith(policy, CONST.POLICY.MORE_FEATURES.IS_HR_ENABLED); + expect(onStartSetup).toHaveBeenCalledWith(`https://merge.dev/${CONST.POLICY.CONNECTIONS.NAME.GUSTO}`, CONST.POLICY.CONNECTIONS.CATEGORY.HR); + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + }); + + it('should ask before replacing the connected provider, and leave it alone when the admin cancels', async () => { + // Given a workspace with Gusto connected, since only one HR provider can be connected at a time + mockHRCards = [buildCard(CONST.POLICY.CONNECTIONS.NAME.GUSTO, true), buildCard(CONST.POLICY.CONNECTIONS.NAME.ZENEFITS, false)]; + mockShowConfirmModal.mockResolvedValue({action: ModalActions.CLOSE}); + const {listings, onStartSetup} = renderListings(); + + // When the admin connects another provider and cancels the Replace prompt + await act(async () => { + listings.find((listing) => listing.title === CONST.POLICY.CONNECTIONS.NAME.ZENEFITS)?.onConnect(); + await waitForBatchedUpdates(); + }); + + // Then Gusto stays connected and no setup starts + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + expect(removePolicyConnection).not.toHaveBeenCalled(); + expect(onStartSetup).not.toHaveBeenCalled(); + }); + + it('should mark a provider that needs reconnecting as broken', () => { + // Given a connected provider whose authentication expired + mockHRCards = [{...buildCard(CONST.POLICY.CONNECTIONS.NAME.GUSTO, true), needsReconnect: true}]; + + // When the listings are built + const {listings} = renderListings(); + + // Then the card reads as broken, so the admin sees a Fix button + expect(listings.at(0)?.status?.isBroken).toBe(true); + }); +}); diff --git a/tests/unit/useReceiptPartnerConnectionListingsTest.ts b/tests/unit/useReceiptPartnerConnectionListingsTest.ts new file mode 100644 index 000000000000..b8db5cebbc8e --- /dev/null +++ b/tests/unit/useReceiptPartnerConnectionListingsTest.ts @@ -0,0 +1,87 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import Navigation from '@libs/Navigation/Navigation'; + +import useReceiptPartnerConnectionListings from '@pages/workspace/connections/useReceiptPartnerConnectionListings'; + +import {openExternalLink} from '@userActions/Link'; +import {enablePolicyFeatureForConnection} from '@userActions/Policy/Policy'; + +import CONST from '@src/CONST'; +import type {Policy} from '@src/types/onyx'; + +import createRandomPolicy from '../utils/collections/policies'; + +const mockUberData = {title: 'Uber for Business', description: 'Connected', icon: undefined, errorFields: undefined as Record | undefined}; +let mockUberState = {isUberConnected: false, shouldShowEnterCredentialsError: false, errorFields: undefined as Record | undefined}; + +jest.mock('@hooks/useGetReceiptPartnersIntegrationData', () => ({ + __esModule: true, + default: () => ({ + getReceiptPartnersIntegrationData: () => ({...mockUberData, errorFields: mockUberState.errorFields}), + shouldShowEnterCredentialsError: mockUberState.shouldShowEnterCredentialsError, + isUberConnected: mockUberState.isUberConnected, + }), +})); +jest.mock('@hooks/useLocalize', () => () => ({translate: (key: string) => key})); +jest.mock('@hooks/useNetwork', () => () => ({isOffline: false})); +jest.mock('@hooks/usePolicyFeatureWriteAccess', () => () => ({canWrite: true, showReadOnlyModal: jest.fn()})); +jest.mock('@libs/Navigation/Navigation', () => ({__esModule: true, default: {navigate: jest.fn()}})); +jest.mock('@userActions/Link', () => ({openExternalLink: jest.fn()})); +jest.mock('@userActions/Policy/Policy', () => ({enablePolicyFeatureForConnection: jest.fn()})); + +const policy: Policy = { + ...createRandomPolicy(1, CONST.POLICY.TYPE.CORPORATE), + id: 'policy-1', + receiptPartners: {enabled: false, uber: {connectFormData: 'form=data'}}, +}; + +describe('useReceiptPartnerConnectionListings', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUberState = {isUberConnected: false, shouldShowEnterCredentialsError: false, errorFields: undefined}; + }); + + it('should mark a connected Uber as broken when its last update failed', () => { + // Given a connected Uber whose disconnect failed, which leaves errorFields on the connection + mockUberState = {isUberConnected: true, shouldShowEnterCredentialsError: false, errorFields: {connection: {error: 'Failed'}}}; + + // When the listing is built + const {result} = renderHook(() => useReceiptPartnerConnectionListings(policy)); + + // Then the card reads as broken, so the admin sees the failure instead of a healthy status + expect(result.current.at(0)?.status?.isBroken).toBe(true); + }); + + it('should turn Receipt partners on and open the Uber connect page when "+" is pressed', () => { + // Given a workspace with Uber not connected + const {result} = renderHook(() => useReceiptPartnerConnectionListings(policy)); + + // When the admin presses "+" on Uber + act(() => result.current.at(0)?.onConnect()); + + // Then the feature is turned on and the Uber connect page opens, since Connections has no toggle for the feature + expect(enablePolicyFeatureForConnection).toHaveBeenCalledWith(policy, CONST.POLICY.MORE_FEATURES.ARE_RECEIPT_PARTNERS_ENABLED); + expect(openExternalLink).toHaveBeenCalledWith(`${CONST.UBER_CONNECT_URL}?form=data`); + }); + + it('should only open the invite flow for a connection started from the page', () => { + // Given Uber's connection only shows up once its data loads, without the admin starting it here + const {rerender, result} = renderHook(() => useReceiptPartnerConnectionListings(policy)); + mockUberState = {...mockUberState, isUberConnected: true}; + rerender({}); + + // Then the invite flow stays closed, since the admin didn't just connect + expect(Navigation.navigate).not.toHaveBeenCalled(); + + // When the admin then connects Uber from the page and the connection shows up + mockUberState = {...mockUberState, isUberConnected: false}; + rerender({}); + act(() => result.current.at(0)?.onConnect()); + mockUberState = {...mockUberState, isUberConnected: true}; + rerender({}); + + // Then the invite flow opens once, so the admin can invite employees to the new connection + expect(Navigation.navigate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/withUnifiedConnectionsBetaTest.tsx b/tests/unit/withUnifiedConnectionsBetaTest.tsx new file mode 100644 index 000000000000..55082759b98a --- /dev/null +++ b/tests/unit/withUnifiedConnectionsBetaTest.tsx @@ -0,0 +1,53 @@ +import {render, screen} from '@testing-library/react-native'; + +import Text from '@components/Text'; + +import withUnifiedConnectionsBeta from '@pages/workspace/connections/withUnifiedConnectionsBeta'; + +import React from 'react'; + +let mockIsUnifiedConnectionsBetaEnabled = false; +jest.mock('@hooks/useIsUnifiedConnectionsBetaEnabled', () => ({ + __esModule: true, + default: () => mockIsUnifiedConnectionsBetaEnabled, +})); + +jest.mock('@pages/workspace/connections/WorkspaceConnectionsPage', () => { + const {default: MockText} = jest.requireActual<{default: typeof Text}>('@components/Text'); + return { + __esModule: true, + default: () => Connections page, + }; +}); + +function OldIntegrationPage({label}: {label: string}) { + return {label}; +} + +const OldIntegrationPageWithBeta = withUnifiedConnectionsBeta(OldIntegrationPage); + +describe('withUnifiedConnectionsBeta', () => { + it('should render the wrapped page while the beta is off', () => { + // Given a user without the unified Connections beta + mockIsUnifiedConnectionsBetaEnabled = false; + + // When an old integration page renders + render(); + + // Then the old page shows as before, since only beta users moved to Connections + expect(screen.getByText('Accounting page')).toBeOnTheScreen(); + expect(screen.queryByText('Connections page')).not.toBeOnTheScreen(); + }); + + it('should render the Connections page in its place while the beta is on', () => { + // Given a user with the unified Connections beta + mockIsUnifiedConnectionsBetaEnabled = true; + + // When an old link opens an integration page + render(); + + // Then Connections shows instead, since the old page is no longer in the workspace menu for this user + expect(screen.getByText('Connections page')).toBeOnTheScreen(); + expect(screen.queryByText('Accounting page')).not.toBeOnTheScreen(); + }); +});