diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 6793545332ca2..c96a06e5abf29 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7426,6 +7426,7 @@ const CONST = { TOP_SPENDERS: 'topSpenders', TOP_CATEGORIES: 'topCategories', TOP_MERCHANTS: 'topMerchants', + SPEND_OVER_TIME: 'spendOverTime', }, GROUP_PREFIX: 'group_', ANIMATION: { diff --git a/src/hooks/useSearchTypeMenu.tsx b/src/hooks/useSearchTypeMenu.tsx index f456e19ffcb1f..1d8ba329344ee 100644 --- a/src/hooks/useSearchTypeMenu.tsx +++ b/src/hooks/useSearchTypeMenu.tsx @@ -52,6 +52,7 @@ export default function useSearchTypeMenu(queryJSON: SearchQueryJSON) { const expensifyIcons = useMemoizedLazyExpensifyIcons([ 'Basket', 'Bookmark', + 'CalendarSolid', 'Checkmark', 'Pencil', 'Receipt', diff --git a/src/languages/de.ts b/src/languages/de.ts index b31a47f5826c1..404ab3de11666 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7266,6 +7266,7 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und selectAllMatchingItems: 'Alle passenden Einträge auswählen', allMatchingItemsSelected: 'Alle passenden Elemente ausgewählt', }, + spendOverTime: 'Ausgaben im Zeitverlauf', }, genericErrorPage: { title: 'Ups, da ist etwas schiefgelaufen!', diff --git a/src/languages/en.ts b/src/languages/en.ts index fb6de39be6a0f..9cc905f225e8c 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7082,6 +7082,7 @@ const translations = { savedSearchesMenuItemTitle: 'Saved', topCategories: 'Top categories', topMerchants: 'Top merchants', + spendOverTime: 'Spend over time', groupedExpenses: 'grouped expenses', bulkActions: { approve: 'Approve', diff --git a/src/languages/es.ts b/src/languages/es.ts index db684d1047851..28982866311e6 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6867,6 +6867,7 @@ ${amount} para ${merchant} - ${date}`, savedSearchesMenuItemTitle: 'Guardadas', topCategories: 'Categorías principales', topMerchants: 'Principales comerciantes', + spendOverTime: 'Evolución de gastos', searchName: 'Nombre de la búsqueda', deleteSavedSearch: 'Eliminar búsqueda guardada', deleteSavedSearchConfirm: '¿Estás seguro de que quieres eliminar esta búsqueda?', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 3267c3d838ded..404cb1042430c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7281,6 +7281,7 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip selectAllMatchingItems: 'Sélectionnez tous les éléments correspondants', allMatchingItemsSelected: 'Tous les éléments correspondants sont sélectionnés', }, + spendOverTime: 'Dépenses dans le temps', }, genericErrorPage: { title: 'Oups, quelque chose s’est mal passé !', diff --git a/src/languages/it.ts b/src/languages/it.ts index c0d1336cfcc3f..7c882ad798ace 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7247,6 +7247,7 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo selectAllMatchingItems: 'Seleziona tutti gli elementi corrispondenti', allMatchingItemsSelected: 'Tutti gli elementi corrispondenti sono stati selezionati', }, + spendOverTime: 'Spesa nel tempo', }, genericErrorPage: { title: 'Oops, qualcosa è andato storto!', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 108dc07dc68cd..407bd991303eb 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7188,6 +7188,7 @@ ${reportName} selectAllMatchingItems: '一致する項目をすべて選択', allMatchingItemsSelected: '一致する項目をすべて選択済み', }, + spendOverTime: '時間経過による支出', }, genericErrorPage: { title: 'おっと、問題が発生しました!', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index c9c21f06f09ab..06552a0007f57 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7236,6 +7236,7 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar selectAllMatchingItems: 'Selecteer alle overeenkomende items', allMatchingItemsSelected: 'Alle overeenkomende items geselecteerd', }, + spendOverTime: 'Uitgaven in de tijd', }, genericErrorPage: { title: 'Oeps, er is iets misgegaan!', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 898112cc11cda..baf1c60314aa4 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7218,6 +7218,7 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i selectAllMatchingItems: 'Zaznacz wszystkie pasujące elementy', allMatchingItemsSelected: 'Zaznaczono wszystkie pasujące elementy', }, + spendOverTime: 'Wydatki w czasie', }, genericErrorPage: { title: 'Ups, coś poszło nie tak!', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c7517dcdb9fa4..393220a2bfff5 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7218,6 +7218,7 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e selectAllMatchingItems: 'Selecione todos os itens correspondentes', allMatchingItemsSelected: 'Todos os itens correspondentes selecionados', }, + spendOverTime: 'Gastos ao longo do tempo', }, genericErrorPage: { title: 'Opa, algo deu errado!', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 943a73ae02f35..31d3a1e8df732 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7083,6 +7083,7 @@ ${reportName} selectAllMatchingItems: '选择所有匹配的项目', allMatchingItemsSelected: '已选中所有匹配项', }, + spendOverTime: '随时间支出', }, genericErrorPage: { title: '哎呀,出错了!', diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index d66591de4654c..409a858a356f4 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -434,7 +434,12 @@ type SearchTypeMenuItem = { key: SearchKey; translationPath: TranslationPaths; type: SearchDataTypes; - icon?: IconAsset | Extract; + icon?: + | IconAsset + | Extract< + ExpensifyIconName, + 'Receipt' | 'ChatBubbles' | 'MoneyBag' | 'CreditCard' | 'MoneyHourglass' | 'CreditCardHourglass' | 'Bank' | 'User' | 'Folder' | 'Basket' | 'CalendarSolid' + >; searchQuery: string; searchQueryJSON: SearchQueryJSON | undefined; hash: number; @@ -806,6 +811,33 @@ function getSuggestedSearches( CONST.SEARCH.GROUP_BY.MERCHANT, CONST.SEARCH.TOP_SEARCH_LIMIT, ), + [CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME]: { + key: CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME, + translationPath: 'search.spendOverTime', + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + icon: 'CalendarSolid', + searchQuery: buildQueryStringFromFilterFormValues( + { + type: CONST.SEARCH.DATA_TYPES.EXPENSE, + groupBy: CONST.SEARCH.GROUP_BY.MONTH, + dateOn: CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE, + view: CONST.SEARCH.VIEW.LINE, + }, + { + sortBy: CONST.SEARCH.TABLE_COLUMNS.GROUP_MONTH, + sortOrder: CONST.SEARCH.SORT_ORDER.ASC, + }, + ), + 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; + }, + }, }; } @@ -830,6 +862,7 @@ function getSuggestedSearchesVisibility( let shouldShowTopSpendersSuggestion = false; let shouldShowTopCategoriesSuggestion = false; let shouldShowTopMerchantsSuggestion = false; + let shouldShowSpendOverTimeSuggestion = false; const hasCardFeed = Object.values(cardFeedsByPolicy ?? {}).some((feeds) => feeds.length > 0); @@ -868,6 +901,7 @@ function getSuggestedSearchesVisibility( const isEligibleForTopSpendersSuggestion = isPaidPolicy && (isAdmin || isAuditor || isApprover); const isEligibleForTopCategoriesSuggestion = isPaidPolicy && policy.areCategoriesEnabled === true; const isEligibleForTopMerchantsSuggestion = isPaidPolicy; + const isEligibleForSpendOverTimeSuggestion = isPaidPolicy && (isAdmin || isAuditor || isApprover); shouldShowSubmitSuggestion ||= isEligibleForSubmitSuggestion; shouldShowPaySuggestion ||= isEligibleForPaySuggestion; @@ -880,6 +914,7 @@ function getSuggestedSearchesVisibility( shouldShowTopSpendersSuggestion ||= isEligibleForTopSpendersSuggestion; shouldShowTopCategoriesSuggestion ||= isEligibleForTopCategoriesSuggestion; shouldShowTopMerchantsSuggestion ||= isEligibleForTopMerchantsSuggestion; + shouldShowSpendOverTimeSuggestion ||= isEligibleForSpendOverTimeSuggestion; // We don't need to check the rest of the policies if we already determined that all suggestions should be displayed return ( @@ -912,6 +947,7 @@ function getSuggestedSearchesVisibility( [CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS]: shouldShowTopSpendersSuggestion, [CONST.SEARCH.SEARCH_KEYS.TOP_CATEGORIES]: shouldShowTopCategoriesSuggestion, [CONST.SEARCH.SEARCH_KEYS.TOP_MERCHANTS]: shouldShowTopMerchantsSuggestion, + [CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME]: shouldShowSpendOverTimeSuggestion, }; } @@ -3683,7 +3719,12 @@ function createTypeMenuSections( menuItems: [], }; - const insightsSearchKeys = [CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS, CONST.SEARCH.SEARCH_KEYS.TOP_CATEGORIES, CONST.SEARCH.SEARCH_KEYS.TOP_MERCHANTS]; + const insightsSearchKeys = [ + CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME, + CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS, + CONST.SEARCH.SEARCH_KEYS.TOP_CATEGORIES, + CONST.SEARCH.SEARCH_KEYS.TOP_MERCHANTS, + ]; for (const key of insightsSearchKeys) { if (!suggestedSearchesVisibility[key]) { diff --git a/src/pages/Search/SearchTypeMenu.tsx b/src/pages/Search/SearchTypeMenu.tsx index b93a67e00e4b6..4cdc3588ecc33 100644 --- a/src/pages/Search/SearchTypeMenu.tsx +++ b/src/pages/Search/SearchTypeMenu.tsx @@ -63,6 +63,7 @@ function SearchTypeMenu({queryJSON}: SearchTypeMenuProps) { const expensifyIcons = useMemoizedLazyExpensifyIcons([ 'Basket', 'Bookmark', + 'CalendarSolid', 'Pencil', 'Receipt', 'ChatBubbles', diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 97e8843ec7b34..6ec4d41895d6f 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -5237,6 +5237,168 @@ describe('SearchUIUtils', () => { const response = SearchUIUtils.getSuggestedSearchesVisibility(adminEmail, {}, policies, undefined); expect(response.topCategories).toBe(false); }); + + test('Should show Spend Over Time for Admin role in paid policy', () => { + const policyKey = `policy_${policyID}`; + + const policies: OnyxCollection = { + [policyKey]: { + id: policyID, + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + } as OnyxTypes.Policy, + }; + + const response = SearchUIUtils.getSuggestedSearchesVisibility(adminEmail, {}, policies, undefined); + expect(response.spendOverTime).toBe(true); + }); + + test('Should show Spend Over Time for Auditor role in paid policy', () => { + const policyKey = `policy_${policyID}`; + + const policies: OnyxCollection = { + [policyKey]: { + id: policyID, + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.AUDITOR, + } as OnyxTypes.Policy, + }; + + const response = SearchUIUtils.getSuggestedSearchesVisibility('auditor@policy.com', {}, policies, undefined); + expect(response.spendOverTime).toBe(true); + }); + + test('Should show Spend Over Time for Approver role in paid policy', () => { + const policyKey = `policy_${policyID}`; + + const policies: OnyxCollection = { + [policyKey]: { + id: policyID, + type: CONST.POLICY.TYPE.TEAM, + approver: approverEmail, + } as OnyxTypes.Policy, + }; + + const response = SearchUIUtils.getSuggestedSearchesVisibility(approverEmail, {}, policies, undefined); + expect(response.spendOverTime).toBe(true); + }); + + test('Should hide Spend Over Time for User role in paid policy', () => { + const policyKey = `policy_${policyID}`; + + const policies: OnyxCollection = { + [policyKey]: { + id: policyID, + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.USER, + } as OnyxTypes.Policy, + }; + + const response = SearchUIUtils.getSuggestedSearchesVisibility('user@policy.com', {}, policies, undefined); + expect(response.spendOverTime).toBe(false); + }); + + test('Should hide Spend Over Time for free policies even with Admin role', () => { + const policyKey = `policy_${policyID}`; + + const policies: OnyxCollection = { + [policyKey]: { + id: policyID, + type: CONST.POLICY.TYPE.PERSONAL, + role: CONST.POLICY.ROLE.ADMIN, + } as OnyxTypes.Policy, + }; + + const response = SearchUIUtils.getSuggestedSearchesVisibility(adminEmail, {}, policies, undefined); + expect(response.spendOverTime).toBe(false); + }); + + test('Should show Spend Over Time if at least one policy has Admin/Auditor/Approver role', () => { + const policies: OnyxCollection = { + policyOne: { + id: 'policyOne', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.USER, + } as OnyxTypes.Policy, + policyTwo: { + id: 'policyTwo', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.ADMIN, + } as OnyxTypes.Policy, + }; + + const response = SearchUIUtils.getSuggestedSearchesVisibility(adminEmail, {}, policies, undefined); + expect(response.spendOverTime).toBe(true); + }); + + test('Should hide Spend Over Time if all policies have User role', () => { + const policies: OnyxCollection = { + policyOne: { + id: 'policyOne', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.USER, + } as OnyxTypes.Policy, + policyTwo: { + id: 'policyTwo', + type: CONST.POLICY.TYPE.TEAM, + role: CONST.POLICY.ROLE.USER, + } as OnyxTypes.Policy, + }; + + const response = SearchUIUtils.getSuggestedSearchesVisibility('user@policy.com', {}, policies, undefined); + expect(response.spendOverTime).toBe(false); + }); + + test('Should return Spend Over Time search with correct properties', () => { + const suggestedSearches = SearchUIUtils.getSuggestedSearches(adminAccountID, undefined, undefined); + const spendOverTimeSearch = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME]; + + expect(spendOverTimeSearch).toBeDefined(); + expect(spendOverTimeSearch.key).toBe(CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME); + expect(spendOverTimeSearch.translationPath).toBe('search.spendOverTime'); + expect(spendOverTimeSearch.type).toBe(CONST.SEARCH.DATA_TYPES.EXPENSE); + expect(spendOverTimeSearch.icon).toBe('CalendarSolid'); + }); + + test('Should return Spend Over Time search query with correct parameters', () => { + const suggestedSearches = SearchUIUtils.getSuggestedSearches(adminAccountID, undefined, undefined); + const spendOverTimeSearch = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME]; + const searchQueryJSON = spendOverTimeSearch.searchQueryJSON; + + expect(searchQueryJSON).toBeDefined(); + expect(searchQueryJSON?.type).toBe(CONST.SEARCH.DATA_TYPES.EXPENSE); + expect(searchQueryJSON?.groupBy).toBe(CONST.SEARCH.GROUP_BY.MONTH); + + // Check that date filter with year-to-date preset exists in flatFilters + const dateFilter = searchQueryJSON?.flatFilters?.find((filter) => filter.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.DATE); + expect(dateFilter).toBeDefined(); + expect(dateFilter?.filters?.some((f) => f.value === CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE)).toBe(true); + + expect(searchQueryJSON?.view).toBe(CONST.SEARCH.VIEW.LINE); + expect(searchQueryJSON?.sortBy).toBe(CONST.SEARCH.TABLE_COLUMNS.GROUP_MONTH); + expect(searchQueryJSON?.sortOrder).toBe(CONST.SEARCH.SORT_ORDER.ASC); + }); + + test('Should return Spend Over Time search with valid hash', () => { + const suggestedSearches = SearchUIUtils.getSuggestedSearches(adminAccountID, undefined, undefined); + const spendOverTimeSearch = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME]; + + expect(spendOverTimeSearch.hash).toBeGreaterThan(0); + expect(spendOverTimeSearch.similarSearchHash).toBeGreaterThan(0); + }); + + test('Should return Spend Over Time search query string with correct format', () => { + const suggestedSearches = SearchUIUtils.getSuggestedSearches(adminAccountID, undefined, undefined); + const spendOverTimeSearch = suggestedSearches[CONST.SEARCH.SEARCH_KEYS.SPEND_OVER_TIME]; + const searchQuery = spendOverTimeSearch.searchQuery; + + expect(searchQuery).toContain(`type:${CONST.SEARCH.DATA_TYPES.EXPENSE}`); + expect(searchQuery).toContain(`groupBy:${CONST.SEARCH.GROUP_BY.MONTH}`); + expect(searchQuery).toContain(`date:${CONST.SEARCH.DATE_PRESETS.YEAR_TO_DATE}`); + expect(searchQuery).toContain(`view:${CONST.SEARCH.VIEW.LINE}`); + expect(searchQuery).toContain(`sortBy:${CONST.SEARCH.TABLE_COLUMNS.GROUP_MONTH}`); + expect(searchQuery).toContain(`sortOrder:${CONST.SEARCH.SORT_ORDER.ASC}`); + }); }); describe('Test getColumnsToShow', () => {