diff --git a/src/CONST/index.ts b/src/CONST/index.ts index d48d8c45a0e5..7fd95768016e 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6566,6 +6566,7 @@ const CONST = { STATEMENTS: 'statements', UNAPPROVED_CASH: 'unapprovedCash', UNAPPROVED_CARD: 'unapprovedCard', + RECONCILIATION: 'reconciliation', }, GROUP_PREFIX: 'group_', }, diff --git a/src/components/Search/SearchPageHeader/SearchFiltersBar.tsx b/src/components/Search/SearchPageHeader/SearchFiltersBar.tsx index fcb99c9936e0..9951e33f3332 100644 --- a/src/components/Search/SearchPageHeader/SearchFiltersBar.tsx +++ b/src/components/Search/SearchPageHeader/SearchFiltersBar.tsx @@ -400,7 +400,7 @@ function SearchFiltersBar({queryJSON, headerButtonsOptions, isMobileSelectionMod const shouldDisplayGroupCurrencyFilter = shouldDisplayGroupByFilter && hasMultipleOutputCurrency; const shouldDisplayFeedFilter = feedOptions.length > 1 && !!filterFormValues.feed; const shouldDisplayPostedFilter = !!filterFormValues.feed && (!!filterFormValues.postedOn || !!filterFormValues.postedAfter || !!filterFormValues.postedBefore); - const shouldDisplayWithdrawalTypeFilter = groupBy?.value === CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID && !!filterFormValues.withdrawalType; + const shouldDisplayWithdrawalTypeFilter = !!filterFormValues.withdrawalType; const shouldDisplayWithdrawnFilter = !!filterFormValues.withdrawnOn || !!filterFormValues.withdrawnAfter || !!filterFormValues.withdrawnBefore; const filterList = [ diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index 02d2dbd054df..bc3246d39cab 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -243,7 +243,12 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS return false; } - const eligibleSearchKeys: Partial = [CONST.SEARCH.SEARCH_KEYS.STATEMENTS, CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CASH, CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CARD]; + const eligibleSearchKeys: Partial = [ + CONST.SEARCH.SEARCH_KEYS.STATEMENTS, + CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CASH, + CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CARD, + CONST.SEARCH.SEARCH_KEYS.RECONCILIATION, + ]; return eligibleSearchKeys.includes(searchKey); }, [offset, searchKey]); @@ -756,7 +761,7 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS return ( ): OnyxEntry => employeeList: policy.employeeList, reimbursementChoice: policy.reimbursementChoice, areCompanyCardsEnabled: policy.areCompanyCardsEnabled, + areExpensifyCardsEnabled: policy.areExpensifyCardsEnabled, + achAccount: policy.achAccount, }; /** diff --git a/src/languages/de.ts b/src/languages/de.ts index 16ec96f335cd..0f8de349d0c4 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6047,6 +6047,7 @@ const translations = { statements: 'Erklärungen', unapprovedCash: 'Nicht genehmigtes Bargeld', unapprovedCard: 'Nicht genehmigte Karte', + reconciliation: 'Abgleich', saveSearch: 'Suche speichern', deleteSavedSearch: 'Gespeicherte Suche löschen', deleteSavedSearchConfirm: 'Möchten Sie diese Suche wirklich löschen?', diff --git a/src/languages/en.ts b/src/languages/en.ts index 725c3e91df8c..01470198ffd2 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6021,6 +6021,7 @@ const translations = { statements: 'Statements', unapprovedCash: 'Unapproved cash', unapprovedCard: 'Unapproved card', + reconciliation: 'Reconciliation', saveSearch: 'Save search', deleteSavedSearch: 'Delete saved search', deleteSavedSearchConfirm: 'Are you sure you want to delete this search?', diff --git a/src/languages/es.ts b/src/languages/es.ts index 2bc6e2cec41c..6a2808d0b67f 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6042,6 +6042,7 @@ const translations = { statements: 'Extractos', unapprovedCash: 'Efectivo no aprobado', unapprovedCard: 'Tarjeta no aprobada', + reconciliation: 'Conciliación', saveSearch: 'Guardar búsqueda', savedSearchesMenuItemTitle: 'Guardadas', searchName: 'Nombre de la búsqueda', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 07a6b4369f6a..080f0896cdd3 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5241,7 +5241,7 @@ const translations = { autoSync: 'Synchronisation automatique', autoSyncDescription: 'Synchronisez NetSuite et Expensify automatiquement, chaque jour. Exportez le rapport finalisé en temps réel.', reimbursedReports: 'Synchroniser les rapports remboursés', - cardReconciliation: 'Rapprochement de carte', + cardReconciliation: 'Rapprochement', reconciliationAccount: 'Compte de réconciliation', continuousReconciliation: 'Réconciliation Continue', saveHoursOnReconciliation: @@ -6060,6 +6060,7 @@ const translations = { statements: 'Relevés', unapprovedCash: 'Espèces non approuvées', unapprovedCard: 'Carte non approuvée', + reconciliation: 'Reconciliation', saveSearch: 'Enregistrer la recherche', deleteSavedSearch: 'Supprimer la recherche enregistrée', deleteSavedSearchConfirm: 'Êtes-vous sûr de vouloir supprimer cette recherche ?', diff --git a/src/languages/it.ts b/src/languages/it.ts index 10bccda4d9a6..7b8f78fc1c94 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6062,6 +6062,7 @@ const translations = { statements: 'Dichiarazioni', unapprovedCash: 'Contanti non approvati', unapprovedCard: 'Carta non approvata', + reconciliation: 'Riconciliazione', saveSearch: 'Salva ricerca', deleteSavedSearch: 'Elimina ricerca salvata', deleteSavedSearchConfirm: 'Sei sicuro di voler eliminare questa ricerca?', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 7b4b7ff3a0f3..5d060a29d0ba 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6019,6 +6019,7 @@ const translations = { statements: 'ステートメント', unapprovedCash: '未承認現金', unapprovedCard: '未承認のカード', + reconciliation: '照合', saveSearch: '検索を保存', deleteSavedSearch: '保存された検索を削除', deleteSavedSearchConfirm: 'この検索を削除してもよろしいですか?', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 7be6e171bb7f..9971d87de60a 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6054,6 +6054,7 @@ const translations = { statements: 'Verklaringen', unapprovedCash: 'Niet goedgekeurd contant geld', unapprovedCard: 'Niet-goedgekeurde kaart', + reconciliation: 'Afstemming', saveSearch: 'Zoekopdracht opslaan', deleteSavedSearch: 'Verwijder opgeslagen zoekopdracht', deleteSavedSearchConfirm: 'Weet je zeker dat je deze zoekopdracht wilt verwijderen?', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 5079936c87c6..b81fc13438ce 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6040,6 +6040,7 @@ const translations = { statements: 'Oświadczenia', unapprovedCash: 'Niezatwierdzone środki pieniężne', unapprovedCard: 'Niezatwierdzona karta', + reconciliation: 'Uzgodnienie', saveSearch: 'Zapisz wyszukiwanie', deleteSavedSearch: 'Usuń zapisaną wyszukiwarkę', deleteSavedSearchConfirm: 'Czy na pewno chcesz usunąć to wyszukiwanie?', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 865f5015755f..ec7f88042e31 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6055,6 +6055,7 @@ const translations = { statements: 'Declarações', unapprovedCash: 'Dinheiro não aprovado', unapprovedCard: 'Cartão não aprovado', + reconciliation: 'Conciliação', saveSearch: 'Salvar pesquisa', deleteSavedSearch: 'Excluir pesquisa salva', deleteSavedSearchConfirm: 'Tem certeza de que deseja excluir esta pesquisa?', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 443b85b7c296..95aa9db5b26c 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -5937,6 +5937,7 @@ const translations = { statements: '发言', unapprovedCash: '未经批准的现金', unapprovedCard: '未批准的卡', + reconciliation: '对账', saveSearch: '保存搜索', deleteSavedSearch: '删除已保存的搜索', deleteSavedSearchConfirm: '您确定要删除此搜索吗?', diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index d4e39dbc9c26..8178b8d84daf 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -300,11 +300,11 @@ function getQueryHashes(query: SearchQueryJSON): {primaryHash: number; recentSea orderedQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.STATUS}:${Array.isArray(query.status) ? query.status.join(',') : query.status}`; orderedQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY}:${query.groupBy}`; - let similarSearchHashInput = orderedQuery; + const filterSet = new Set(orderedQuery); query.flatFilters .map((filter) => { - similarSearchHashInput += filter.key; + filterSet.add(filter.key); const filters = cloneDeep(filter.filters); filters.sort((a, b) => customCollator.compare(a.value.toString(), b.value.toString())); @@ -313,7 +313,7 @@ function getQueryHashes(query: SearchQueryJSON): {primaryHash: number; recentSea .sort() .forEach((filterString) => (orderedQuery += ` ${filterString}`)); - const similarSearchHash = hashText(similarSearchHashInput, 2 ** 32); + const similarSearchHash = hashText(Array.from(filterSet).join(''), 2 ** 32); const recentSearchHash = hashText(orderedQuery, 2 ** 32); orderedQuery += ` ${CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_BY}:${query.sortBy}`; diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 96dadb99dcb0..33ef6645accb 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -445,6 +445,26 @@ function getSuggestedSearches(defaultFeedID: string | undefined, accountID: numb return this.searchQueryJSON?.similarSearchHash ?? CONST.DEFAULT_NUMBER_ID; }, }, + [CONST.SEARCH.SEARCH_KEYS.RECONCILIATION]: { + key: CONST.SEARCH.SEARCH_KEYS.RECONCILIATION, + translationPath: 'search.reconciliation', + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + icon: Expensicons.Bank, + searchQuery: buildQueryStringFromFilterFormValues({ + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + withdrawalType: CONST.SEARCH.WITHDRAWAL_TYPE.REIMBURSEMENT, + withdrawnOn: CONST.SEARCH.DATE_PRESETS.LAST_MONTH, + }), + get searchQueryJSON() { + return buildSearchQueryJSON(this.searchQuery); + }, + get hash() { + return this.searchQueryJSON?.hash ?? CONST.DEFAULT_NUMBER_ID; + }, + get similarSearchHash() { + return this.searchQueryJSON?.similarSearchHash ?? CONST.DEFAULT_NUMBER_ID; + }, + }, }; } @@ -474,6 +494,9 @@ function getSuggestedSearchesVisibility( const isApprover = policy.approver === currentUserEmail; const isApprovalEnabled = policy.approvalMode ? policy.approvalMode !== CONST.POLICY.APPROVAL_MODE.OPTIONAL : false; const isPaymentEnabled = arePaymentsEnabled(policy); + const hasVBBA = !!policy.achAccount?.bankAccountID && policy.achAccount.state === CONST.BANK_ACCOUNT.STATE.OPEN; + const hasReimburser = !!policy.achAccount?.reimburser; + const isECardEnabled = !!policy.areExpensifyCardsEnabled; const isSubmittedTo = Object.values(policy.employeeList ?? {}).some((employee) => { return employee.submitsTo === currentUserEmail || employee.forwardsTo === currentUserEmail; }); @@ -485,7 +508,7 @@ function getSuggestedSearchesVisibility( const isEligibleForStatementsSuggestion = isPaidPolicy && !!policy.areCompanyCardsEnabled && cardFeedsByPolicy[policy.id]?.length > 0; const isEligibleForUnapprovedCashSuggestion = isPaidPolicy && isAdmin && isApprovalEnabled && isPaymentEnabled; const isEligibleForUnapprovedCardSuggestion = isPaidPolicy && isAdmin && isApprovalEnabled && cardFeedsByPolicy[policy.id]?.length > 0; - const isEligibleForReconciliationSuggestion = isPaidPolicy && false; // s77rt TODO + const isEligibleForReconciliationSuggestion = isPaidPolicy && isAdmin && ((isPaymentEnabled && hasVBBA && hasReimburser) || isECardEnabled); shouldShowSubmitSuggestion ||= isEligibleForSubmitSuggestion; shouldShowPaySuggestion ||= isEligibleForPaySuggestion; @@ -520,6 +543,7 @@ function getSuggestedSearchesVisibility( [CONST.SEARCH.SEARCH_KEYS.STATEMENTS]: shouldShowStatementsSuggestion, [CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CASH]: shouldShowUnapprovedCashSuggestion, [CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CARD]: shouldShowUnapprovedCardSuggestion, + [CONST.SEARCH.SEARCH_KEYS.RECONCILIATION]: shouldShowReconciliationSuggestion, }; } @@ -1895,6 +1919,16 @@ function createTypeMenuSections( }, }); } + if (suggestedSearchesVisibility[CONST.SEARCH.SEARCH_KEYS.RECONCILIATION]) { + accountingSection.menuItems.push({ + ...suggestedSearches[CONST.SEARCH.SEARCH_KEYS.RECONCILIATION], + emptyState: { + headerMedia: DotLottieAnimations.Fireworks, + title: 'search.searchResults.emptyStatementsResults.title', + subtitle: 'search.searchResults.emptyStatementsResults.subtitle', + }, + }); + } if (accountingSection.menuItems.length > 0) { typeMenuSections.push(accountingSection); diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx index 8839ffaa6e1d..5749efcecb8f 100644 --- a/src/pages/Search/EmptySearchView.tsx +++ b/src/pages/Search/EmptySearchView.tsx @@ -44,7 +44,7 @@ import type {Policy} from '@src/types/onyx'; import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; type EmptySearchViewProps = { - hash: number; + similarSearchHash: number; groupBy?: ValueOf; type: SearchDataTypes; hasResults: boolean; @@ -74,7 +74,7 @@ const tripsFeatures: FeatureListItem[] = [ }, ]; -function EmptySearchView({hash, type, groupBy, hasResults}: EmptySearchViewProps) { +function EmptySearchView({similarSearchHash, type, groupBy, hasResults}: EmptySearchViewProps) { const theme = useTheme(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); @@ -183,7 +183,7 @@ function EmptySearchView({hash, type, groupBy, hasResults}: EmptySearchViewProps // Begin by going through all of our To-do searches, and returning their empty state // if it exists for (const menuItem of typeMenuItems) { - if (menuItem.hash === hash && menuItem.emptyState) { + if (menuItem.similarSearchHash === similarSearchHash && menuItem.emptyState) { return { headerMedia: menuItem.emptyState.headerMedia, title: translate(menuItem.emptyState.title), @@ -353,7 +353,7 @@ function EmptySearchView({hash, type, groupBy, hasResults}: EmptySearchViewProps groupBy, type, typeMenuItems, - hash, + similarSearchHash, translate, StyleUtils, theme.todoBG, diff --git a/tests/ui/components/EmptySearchViewTest.tsx b/tests/ui/components/EmptySearchViewTest.tsx index 3ad5a1bc396f..7fd0eb437805 100644 --- a/tests/ui/components/EmptySearchViewTest.tsx +++ b/tests/ui/components/EmptySearchViewTest.tsx @@ -52,7 +52,7 @@ describe('EmptySearchView', () => { render( @@ -76,7 +76,7 @@ describe('EmptySearchView', () => { render( @@ -118,7 +118,7 @@ describe('EmptySearchView', () => { render( { render( { render( @@ -204,7 +204,7 @@ describe('EmptySearchView', () => { render( diff --git a/tests/unit/Search/SearchQueryUtilsTest.ts b/tests/unit/Search/SearchQueryUtilsTest.ts index ac15be24d6bf..4f01dd986161 100644 --- a/tests/unit/Search/SearchQueryUtilsTest.ts +++ b/tests/unit/Search/SearchQueryUtilsTest.ts @@ -332,6 +332,13 @@ describe('SearchQueryUtils', () => { expect(queryJSONa?.similarSearchHash).toEqual(queryJSONb?.similarSearchHash); }); + it('should return same similarSearchHash for queries with a date range', () => { + const queryJSONa = buildSearchQueryJSON('type:expense withdrawal-type:reimbursement withdrawn:last-month'); + const queryJSONb = buildSearchQueryJSON('type:expense withdrawal-type:reimbursement withdrawn>2025-01-01 withdrawn<2025-01-03'); + + expect(queryJSONa?.similarSearchHash).toEqual(queryJSONb?.similarSearchHash); + }); + it('should return different similarSearchHash for two queries that have different types', () => { const queryJSONa = buildSearchQueryJSON('sortBy:date sortOrder:desc type:expense feed:"oauth.americanexpressfdx.com 1001"'); const queryJSONb = buildSearchQueryJSON('sortBy:date sortOrder:desc type:trip feed:"oauth.americanexpressfdx.com 1001"'); diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 69fbbf3c0f3e..88f44418007a 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -13,6 +13,7 @@ import type { import * as Expensicons from '@src/components/Icon/Expensicons'; import CONST from '@src/CONST'; import IntlStore from '@src/languages/IntlStore'; +import type {CardFeedForDisplay} from '@src/libs/CardFeedUtils'; import * as SearchUIUtils from '@src/libs/SearchUIUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -1844,6 +1845,305 @@ describe('SearchUIUtils', () => { ); }); + it('should show todo section with submit, approve, pay, and export items when user has appropriate permissions', () => { + const mockPolicies = { + policy1: { + id: 'policy1', + name: 'Test Policy', + owner: adminEmail, + outputCurrency: 'USD', + isPolicyExpenseChatEnabled: true, + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + approver: adminEmail, + exporter: adminEmail, + achAccount: { + bankAccountID: 1, + reimburser: adminEmail, + state: CONST.BANK_ACCOUNT.STATE.OPEN, + accountNumber: '1234567890', + routingNumber: '1234567890', + addressName: 'Test Address', + bankName: 'Test Bank', + }, + areExpensifyCardsEnabled: true, + areCompanyCardsEnabled: true, + employeeList: { + [adminEmail]: { + email: adminEmail, + role: CONST.POLICY.ROLE.ADMIN, + submitsTo: approverEmail, + }, + [approverEmail]: { + email: approverEmail, + role: CONST.POLICY.ROLE.USER, + submitsTo: adminEmail, + }, + }, + }, + }; + + const mockCardFeedsByPolicy: Record = { + policy1: [ + { + id: 'card1', + feed: 'Expensify Card' as const, + fundID: 'fund1', + name: 'Test Card Feed', + }, + ], + }; + + const mockSavedSearches = {}; + + const sections = SearchUIUtils.createTypeMenuSections(adminEmail, adminAccountID, mockCardFeedsByPolicy, undefined, mockPolicies, mockSavedSearches, false); + + const todoSection = sections.find((section) => section.translationPath === 'common.todo'); + expect(todoSection).toBeDefined(); + expect(todoSection?.menuItems.length).toBeGreaterThan(0); + + const menuItemKeys = todoSection?.menuItems.map((item) => item.key) ?? []; + expect(menuItemKeys).toContain(CONST.SEARCH.SEARCH_KEYS.SUBMIT); + expect(menuItemKeys).toContain(CONST.SEARCH.SEARCH_KEYS.APPROVE); + expect(menuItemKeys).toContain(CONST.SEARCH.SEARCH_KEYS.EXPORT); + }); + + it('should show accounting section with statements, unapproved cash, unapproved card, and reconciliation items', () => { + const mockPolicies = { + policy1: { + id: 'policy1', + name: 'Test Policy', + owner: adminEmail, + outputCurrency: 'USD', + isPolicyExpenseChatEnabled: true, + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + areExpensifyCardsEnabled: true, + areCompanyCardsEnabled: true, + achAccount: { + bankAccountID: 1234, + reimburser: adminEmail, + state: CONST.BANK_ACCOUNT.STATE.OPEN, + accountNumber: '1234567890', + routingNumber: '1234567890', + addressName: 'Test Address', + bankName: 'Test Bank', + }, + }, + }; + + const mockCardFeedsByPolicy: Record = { + policy1: [ + { + id: 'card1', + feed: 'Expensify Card' as const, + fundID: 'fund1', + name: 'Test Card Feed', + }, + ], + }; + + const mockSavedSearches = {}; + + const sections = SearchUIUtils.createTypeMenuSections(adminEmail, adminAccountID, mockCardFeedsByPolicy, undefined, mockPolicies, mockSavedSearches, false); + + const accountingSection = sections.find((section) => section.translationPath === 'workspace.common.accounting'); + expect(accountingSection).toBeDefined(); + expect(accountingSection?.menuItems.length).toBeGreaterThan(0); + + const menuItemKeys = accountingSection?.menuItems.map((item) => item.key) ?? []; + expect(menuItemKeys).toContain(CONST.SEARCH.SEARCH_KEYS.STATEMENTS); + expect(menuItemKeys).toContain(CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CASH); + expect(menuItemKeys).toContain(CONST.SEARCH.SEARCH_KEYS.UNAPPROVED_CARD); + expect(menuItemKeys).toContain(CONST.SEARCH.SEARCH_KEYS.RECONCILIATION); + }); + + it('should show saved section when there are saved searches', () => { + const mockSavedSearches = { + search1: { + id: 'search1', + name: 'My Saved Search', + query: 'type:expense', + pendingAction: undefined, + }, + search2: { + id: 'search2', + name: 'Another Search', + query: 'type:report', + pendingAction: undefined, + }, + }; + + const sections = SearchUIUtils.createTypeMenuSections(adminEmail, adminAccountID, {}, undefined, {}, mockSavedSearches, false); + + const savedSection = sections.find((section) => section.translationPath === 'search.savedSearchesMenuItemTitle'); + expect(savedSection).toBeDefined(); + }); + + it('should not show saved section when there are no saved searches', () => { + const mockSavedSearches = {}; + + const sections = SearchUIUtils.createTypeMenuSections(adminEmail, adminAccountID, {}, undefined, {}, mockSavedSearches, false); + + const savedSection = sections.find((section) => section.translationPath === 'search.savedSearchesMenuItemTitle'); + expect(savedSection).toBeUndefined(); + }); + + it('should not show saved section when all saved searches are pending deletion and not offline', () => { + const mockSavedSearches = { + search1: { + id: 'search1', + name: 'Deleted Search', + query: 'type:expense', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }, + }; + + const sections = SearchUIUtils.createTypeMenuSections( + adminEmail, + adminAccountID, + {}, + undefined, + {}, + mockSavedSearches, + false, // not offline + ); + + const savedSection = sections.find((section) => section.translationPath === 'search.savedSearchesMenuItemTitle'); + expect(savedSection).toBeUndefined(); + }); + + it('should show saved section when searches are pending deletion but user is offline', () => { + const mockSavedSearches = { + search1: { + id: 'search1', + name: 'Deleted Search', + query: 'type:expense', + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + }, + }; + + const sections = SearchUIUtils.createTypeMenuSections( + adminEmail, + adminAccountID, + {}, + undefined, + {}, + mockSavedSearches, + true, // offline + ); + + const savedSection = sections.find((section) => section.translationPath === 'search.savedSearchesMenuItemTitle'); + expect(savedSection).toBeDefined(); + }); + + it('should not show todo section when user has no appropriate permissions', () => { + const mockPolicies = { + policy1: { + id: 'policy1', + name: 'Personal Policy', + owner: adminEmail, + outputCurrency: 'USD', + isPolicyExpenseChatEnabled: false, + role: CONST.POLICY.ROLE.USER, + type: CONST.POLICY.TYPE.PERSONAL, // personal policy, not team + approvalMode: CONST.POLICY.APPROVAL_MODE.OPTIONAL, + }, + }; + + const sections = SearchUIUtils.createTypeMenuSections(adminEmail, adminAccountID, {}, undefined, mockPolicies, {}, false); + + const todoSection = sections.find((section) => section.translationPath === 'common.todo'); + expect(todoSection).toBeUndefined(); + }); + + it('should not show accounting section when user has no admin permissions or card feeds', () => { + const mockPolicies = { + policy1: { + id: 'policy1', + name: 'Team Policy', + owner: adminEmail, + outputCurrency: 'USD', + isPolicyExpenseChatEnabled: true, + role: CONST.POLICY.ROLE.USER, // not admin + type: CONST.POLICY.TYPE.TEAM, + areCompanyCardsEnabled: false, + }, + }; + + const sections = SearchUIUtils.createTypeMenuSections( + adminEmail, + adminAccountID, + {}, // no card feeds + undefined, + mockPolicies, + {}, + false, + ); + + const accountingSection = sections.find((section) => section.translationPath === 'workspace.common.accounting'); + expect(accountingSection).toBeUndefined(); + }); + + it('should show reconciliation for ACH-only scenario (payments enabled, active VBBA, reimburser set, areExpensifyCardsEnabled = false)', () => { + const mockPolicies = { + policy1: { + id: 'policy1', + name: 'ACH Only Policy', + owner: adminEmail, + outputCurrency: 'USD', + isPolicyExpenseChatEnabled: true, + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + areExpensifyCardsEnabled: false, + achAccount: { + bankAccountID: 1234, + reimburser: adminEmail, + state: CONST.BANK_ACCOUNT.STATE.OPEN, + accountNumber: '1234567890', + routingNumber: '1234567890', + addressName: 'Test Address', + bankName: 'Test Bank', + }, + }, + }; + + const sections = SearchUIUtils.createTypeMenuSections(adminEmail, adminAccountID, {}, undefined, mockPolicies, {}, false); + + const accountingSection = sections.find((section) => section.translationPath === 'workspace.common.accounting'); + expect(accountingSection).toBeDefined(); + + const menuItemKeys = accountingSection?.menuItems.map((item) => item.key) ?? []; + expect(menuItemKeys).toContain(CONST.SEARCH.SEARCH_KEYS.RECONCILIATION); + }); + + it('should not show reconciliation for card-only scenario without card feeds (areExpensifyCardsEnabled = true but no card feeds)', () => { + const mockPolicies = { + policy1: { + id: 'policy1', + name: 'Card Only Policy', + owner: adminEmail, + outputCurrency: 'USD', + isPolicyExpenseChatEnabled: true, + role: CONST.POLICY.ROLE.ADMIN, + type: CONST.POLICY.TYPE.TEAM, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + areExpensifyCardsEnabled: true, + }, + }; + + const mockCardFeedsByPolicy: Record = {}; + const sections = SearchUIUtils.createTypeMenuSections(adminEmail, adminAccountID, mockCardFeedsByPolicy, undefined, mockPolicies, {}, false); + const accountingSection = sections.find((section) => section.translationPath === 'workspace.common.accounting'); + + expect(accountingSection).toBeDefined(); + const menuItemKeys = accountingSection?.menuItems.map((item) => item.key) ?? []; + expect(menuItemKeys).toContain(CONST.SEARCH.SEARCH_KEYS.RECONCILIATION); + }); + it('should generate correct routes', () => { const menuItems = SearchUIUtils.createTypeMenuSections(undefined, undefined, {}, undefined, {}, {}, false) .map((section) => section.menuItems)