Add Top Spenders default report
Part of the Insights project
Main issue: https://github.com/Expensify/Expensify/issues/570577 Doc section: https://docs.google.com/document/d/1zMWsgWBXY5Lx4V1M8LVTovOaTX-q34LempKIKWH8rwI/edit?tab=t.0#heading=h.mvmf31h2iib Project: <Project Link>
Feature Description
Add a new default report called Top Spenders to the Reports page.
In order to do this, we will:
- Add a new section to the Reports page LHN called
Insightsthat lives above explore a. This section will only be shown when there is at least one Insights report to display - Within the
Insightssection add aTop Spendersreport a. The report should only be shown if the user is an admin, auditor, or approver on a workspace b. The query for this report istype:expense group-by:from date:last-month sort-by:groupTotal sort-order:desc
When there is nothing to display, we'll use the same empty state as Reports > Expenses
Issue Owner
Current Issue Owner: @
Triggered auto assignment to Contributor-plus team member for initial proposal review - @mananjadhav (External)
Triggered auto assignment to @jliexpensify (NewFeature), see https://stackoverflowteams.com/c/expensify/questions/14418#:~:text=BugZero%20process%20steps%20for%20feature%20requests for more details. Please add this Feature request to a GH project, as outlined in the SO. Remember to draft any necessary HelpDot updates by following the instructions here.
:warning: It looks like this issue is labelled as a New Feature but not tied to any GitHub Project. Keep in mind that all new features should be tied to GitHub Projects in order to properly track external CAP software time :warning:
Auto-assign attempt failed, all eligible assignees are OOO.
Is this open for external contributors?
@samranahm yes it is.
@puneetlath I'm open to take this one, since the implementation is already mentioned.
🚨 Edited by proposal-police: This proposal was edited at 2025-12-22 22:18:51 UTC.
Proposal
Please re-state the problem that we are trying to solve in this issue.
Add Top Spenders default report
What is the root cause of that problem?
New feat
What changes do you think we should make in order to solve the problem?
We should add a new Insights section in SearchUIUtils
// Insights section
{
const insightsSection: SearchTypeMenuSection = {
translationPath: 'common.insights',
menuItems: [],
};
if (suggestedSearchesVisibility[CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS]) {
insightsSection.menuItems.push({
...suggestedSearches[CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS],
emptyState: {
title: 'search.searchResults.emptyExpenseResults.title',
subtitle: 'search.searchResults.emptyExpenseResults.subtitleWithOnlyCreateButton',
buttons: [
{
buttonText: 'iou.createExpense',
buttonAction: () =>
interceptAnonymousUser(() => {
if (shouldRedirectToExpensifyClassic) {
setIsOpenConfirmNavigateExpensifyClassicModalOpen(true);
return;
}
startMoneyRequest(CONST.IOU.TYPE.CREATE, generateReportID(), CONST.IOU.REQUEST_TYPE.SCAN, false, undefined, draftTransactions);
}),
success: true,
},
],
},
});
}
if (insightsSection.menuItems.length > 0) {
typeMenuSections.push(insightsSection);
}
}
and add top spender logic in getSuggestedSearches
[CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS]: {
key: CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS,
translationPath: 'search.topSpenders',
type: CONST.SEARCH.DATA_TYPES.EXPENSE,
icon: 'MoneyBag',
searchQuery: 'type:expense group-by:from date:last-month sort-by:groupTotal sort-order:desc',
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;
},
},
What alternative solutions did you explore? (Optional)
Reminder: Please use plain English, be brief and avoid jargon. Feel free to use images, charts or pseudo-code if necessary. Do not post large multi-line diffs or write walls of text. Do not create PRs unless you have been hired for this job.
⚠️ @samranahm Thanks for your proposal. Please update it to follow the proposal template, as proposals are only reviewed if they follow that format (note the mandatory sections).
Proposal
Please re-state the problem that we are trying to solve in this issue.
The Reports page currently lacks quick access to spending insights that help workspace admins, auditors, and approvers identify and monitor top spenders. Users need an easy way to see who is spending the most on their workspace without manually creating complex search queries or exports.
What is the root cause of that problem?
The Reports page LHN currently only has "To-do", "Accounting", "Saved", and "Explore" sections. There is no dedicated section for spending insights or analytics, and no default report that shows top spenders.
Current Structure:
The Reports page menu sections are created in App/src/libs/SearchUIUtils.ts within the createTypeMenuSections() function (lines 2688-2860). This function creates menu sections based on:
- User's role and permissions (determined by
getSuggestedSearchesVisibility()) - Available search types (defined in
getSuggestedSearches()) - Policy settings and features
Currently, there is no:
TOP_SPENDERSkey inCONST.SEARCH.SEARCH_KEYS- Top Spenders search definition in
getSuggestedSearches() - Visibility logic for top spenders in
getSuggestedSearchesVisibility() - Insights section in
createTypeMenuSections() - Translation keys for "Insights" and "Top spenders"
What changes do you think we should make in order to solve the problem?
1. Add TOP_SPENDERS Constant
File: App/src/CONST/index.ts
Add the TOP_SPENDERS key to the SEARCH_KEYS object (around line 7104):
SEARCH_KEYS: {
EXPENSES: 'expenses',
REPORTS: 'reports',
CHATS: 'chats',
SUBMIT: 'submit',
APPROVE: 'approve',
PAY: 'pay',
EXPORT: 'export',
STATEMENTS: 'statements',
UNAPPROVED_CASH: 'unapprovedCash',
UNAPPROVED_CARD: 'unapprovedCard',
RECONCILIATION: 'reconciliation',
TOP_SPENDERS: 'topSpenders', // Add this line
},
2. Define Top Spenders Search
File: App/src/libs/SearchUIUtils.ts
Add the Top Spenders entry in the getSuggestedSearches() function (around line 587, after the RECONCILIATION entry):
[CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS]: {
key: CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS,
translationPath: 'search.topSpenders',
type: CONST.SEARCH.DATA_TYPES.EXPENSE,
icon: 'MoneyBag',
searchQuery: buildQueryStringFromFilterFormValues({
type: CONST.SEARCH.DATA_TYPES.EXPENSE,
groupBy: CONST.SEARCH.GROUP_BY.FROM,
date: CONST.SEARCH.DATE_PRESETS.LAST_MONTH,
sortBy: CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL,
sortOrder: CONST.SEARCH.SORT_ORDER.DESC,
}),
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;
},
},
3. Add Visibility Logic
File: App/src/libs/SearchUIUtils.ts
In the getSuggestedSearchesVisibility() function:
a) Add variable declaration (around line 623):
let shouldShowSubmitSuggestion = false;
let shouldShowPaySuggestion = false;
let shouldShowApproveSuggestion = false;
let shouldShowExportSuggestion = false;
let shouldShowStatementsSuggestion = false;
let shouldShowUnapprovedCashSuggestion = false;
let shouldShowUnapprovedCardSuggestion = false;
let shouldShowReconciliationSuggestion = false;
let shouldShowTopSpendersSuggestion = false; // Add this line
b) Add eligibility check in the policies loop (around line 657, after RECONCILIATION check):
const isAuditor = policy.role === CONST.POLICY.ROLE.AUDITOR;
const isEligibleForTopSpendersSuggestion = isPaidPolicy && (isAdmin || isAuditor || isApprover || isSubmittedTo);
c) Update the flag (around line 673):
shouldShowSubmitSuggestion ||= isEligibleForSubmitSuggestion;
shouldShowPaySuggestion ||= isEligibleForPaySuggestion;
shouldShowApproveSuggestion ||= isEligibleForApproveSuggestion;
shouldShowExportSuggestion ||= isEligibleForExportSuggestion;
shouldShowStatementsSuggestion ||= isEligibleForStatementsSuggestion;
shouldShowUnapprovedCashSuggestion ||= isEligibleForUnapprovedCashSuggestion;
shouldShowUnapprovedCardSuggestion ||= isEligibleForUnapprovedCardSuggestion;
shouldShowReconciliationSuggestion ||= isEligibleForReconciliationSuggestion;
shouldShowTopSpendersSuggestion ||= isEligibleForTopSpendersSuggestion; // Add this line
d) Update early return check (around line 676):
return (
shouldShowSubmitSuggestion &&
shouldShowPaySuggestion &&
shouldShowApproveSuggestion &&
shouldShowExportSuggestion &&
shouldShowStatementsSuggestion &&
shouldShowUnapprovedCashSuggestion &&
shouldShowUnapprovedCardSuggestion &&
shouldShowReconciliationSuggestion &&
shouldShowTopSpendersSuggestion // Add this line
);
e) Add to return object (around line 688):
return {
[CONST.SEARCH.SEARCH_KEYS.EXPENSES]: true,
[CONST.SEARCH.SEARCH_KEYS.REPORTS]: true,
[CONST.SEARCH.SEARCH_KEYS.CHATS]: true,
[CONST.SEARCH.SEARCH_KEYS.SUBMIT]: shouldShowSubmitSuggestion,
[CONST.SEARCH.SEARCH_KEYS.PAY]: shouldShowPaySuggestion,
[CONST.SEARCH.SEARCH_KEYS.APPROVE]: shouldShowApproveSuggestion,
[CONST.SEARCH.SEARCH_KEYS.EXPORT]: shouldShowExportSuggestion,
[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,
[CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS]: shouldShowTopSpendersSuggestion, // Add this line
};
4. Add Insights Section to Menu
File: App/src/libs/SearchUIUtils.ts
In the createTypeMenuSections() function, add the Insights section before the Explore section (around line 2866, after the Saved section and before the Explore section):
// Insights section
{
const insightsSection: SearchTypeMenuSection = {
translationPath: 'common.insights',
menuItems: [],
};
if (suggestedSearchesVisibility[CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS]) {
insightsSection.menuItems.push({
...suggestedSearches[CONST.SEARCH.SEARCH_KEYS.TOP_SPENDERS],
emptyState: {
title: 'search.searchResults.emptyResults.title',
subtitle: 'search.searchResults.emptyResults.subtitle',
},
});
}
if (insightsSection.menuItems.length > 0) {
typeMenuSections.push(insightsSection);
}
}
Why this placement?
- Comes after "Saved" (user-created searches)
- Comes before "Explore" (general browsing)
- Follows the pattern of conditional section display (only shows when there are items to display)
5. Add Translation Keys
File: App/src/languages/en.ts
a) Add "insights" key (around line 631, in the common section):
explore: 'Explore',
insights: 'Insights', // Add this line
todo: 'To-do',
b) Add "topSpenders" key (around line 6591, in the search section):
statements: 'Statements',
unapprovedCash: 'Unapproved cash',
unapprovedCard: 'Unapproved card',
reconciliation: 'Reconciliation',
topSpenders: 'Top spenders', // Add this line
saveSearch: 'Save search',
What alternative solutions did you explore?
None
Demo
@abbasifaizan70 's proposal is more comprehensive and covers everything that is required including the access checks, etc.
🎀 👀 🎀 C+ reviewed.
Current assignee @puneetlath is eligible for the choreEngineerContributorManagement assigner, not assigning anyone new.
@mananjadhav Thanks for your review but all the required details were already mentioned in my proposal except some obvious implementation that each author definitely cover during PR. I respect your decision but would you mind to take another look since the selected proposal is just duplicate of mine.
@mananjadhav @puneetlath The PR is ready for review. I used my OpenAI API keys for translation using the translation script.
Thanks @abbasifaizan70. I am away today I'll take a look tomorrow.
@samranahm Thanks for the comment. I feel the details related to access should be covered.
@mananjadhav I got it, thanks for clarifying.