diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 821f33fcb6b4..bb3d0544b7a4 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -8254,6 +8254,11 @@ const CONST = {
search: true,
reportView: true,
},
+ DECLINED: {
+ column: this.TABLE_COLUMNS.DECLINED,
+ search: true,
+ reportView: false,
+ },
MCC: {column: this.TABLE_COLUMNS.MCC, search: true, reportView: true},
TAX_CODE: {
column: this.TABLE_COLUMNS.TAX_CODE,
@@ -8336,6 +8341,7 @@ const CONST = {
EXPORTED: this.TABLE_COLUMNS.EXPORTED,
STATUS: this.TABLE_COLUMNS.STATUS,
PAID_STATUS: this.TABLE_COLUMNS.PAID_STATUS,
+ DECLINED: this.TABLE_COLUMNS.DECLINED,
TITLE: this.TABLE_COLUMNS.TITLE,
FROM: this.TABLE_COLUMNS.FROM,
TO: this.TABLE_COLUMNS.TO,
@@ -8550,6 +8556,7 @@ const CONST = {
REIMBURSABLE: 'reimbursable',
BILLABLE: 'billable',
HELD: 'held',
+ DECLINED: 'declined',
TAX_RATE: 'taxrate',
TOTAL_AMOUNT: 'amount',
ATTENDEES: 'attendees',
@@ -8835,6 +8842,7 @@ const CONST = {
[this.TABLE_COLUMNS.REIMBURSABLE]: 'reimbursable',
[this.TABLE_COLUMNS.BILLABLE]: 'billable',
[this.TABLE_COLUMNS.HELD]: 'held',
+ [this.TABLE_COLUMNS.DECLINED]: 'declined',
[this.TABLE_COLUMNS.TAX_RATE]: 'tax-rate',
[this.TABLE_COLUMNS.TOTAL_AMOUNT]: 'amount',
[this.TABLE_COLUMNS.TOTAL]: 'total',
diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx
index b516f8fc4269..648436352fbf 100644
--- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx
+++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx
@@ -10,6 +10,7 @@ import TextCell from '@components/Search/SearchList/ListItem/TextCell';
import TotalCell from '@components/Search/SearchList/ListItem/TotalCell';
import UserInfoCell from '@components/Search/SearchList/ListItem/UserInfoCell';
import WorkspaceCell from '@components/Search/SearchList/ListItem/WorkspaceCell';
+import YesNoCell from '@components/Search/SearchList/ListItem/YesNoCell';
import {useRowSelection} from '@components/Search/SearchSelectionProvider';
import {useListItemContext, useListItemHovered} from '@components/SelectionList/ListItemContext';
@@ -158,6 +159,11 @@ function ExpenseReportListItemRowWide({
/>
),
+ [CONST.SEARCH.TABLE_COLUMNS.DECLINED]: (
+
+
+
+ ),
[CONST.SEARCH.TABLE_COLUMNS.TITLE]: (
+ {value ? translate('common.yes') : translate('common.no')}
+
+ );
+}
+
+export default YesNoCell;
diff --git a/src/components/Search/SearchList/ListItem/types.ts b/src/components/Search/SearchList/ListItem/types.ts
index 25a0db59e6f8..8d81f6e2a859 100644
--- a/src/components/Search/SearchList/ListItem/types.ts
+++ b/src/components/Search/SearchList/ListItem/types.ts
@@ -30,8 +30,10 @@ import type {
SearchMerchantGroup,
SearchMonthGroup,
SearchQuarterGroup,
+ SearchReport,
SearchTagGroup,
SearchTask,
+ SearchTransaction,
SearchTransactionAction,
SearchWeekGroup,
SearchWithdrawalIDGroup,
@@ -71,7 +73,7 @@ type ExpenseReportListItemProps = ListItemProps &
};
type TransactionListItemType = ListItem &
- Transaction & {
+ SearchTransaction & {
/** Report to which the transaction belongs */
report: Report | undefined;
@@ -227,7 +229,7 @@ type TransactionGroupListItemType = ListItem & {
type ExpenseReportListItemType = TransactionReportGroupListItemType;
-type TransactionReportGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} & Report & {
+type TransactionReportGroupListItemType = TransactionGroupListItemType & {groupedBy: typeof CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT} & SearchReport & {
/** The personal details of the user requesting money */
from: PersonalDetails;
diff --git a/src/components/Search/SearchTableHeader.tsx b/src/components/Search/SearchTableHeader.tsx
index 82029790529d..55a737cf1e52 100644
--- a/src/components/Search/SearchTableHeader.tsx
+++ b/src/components/Search/SearchTableHeader.tsx
@@ -140,6 +140,10 @@ const getExpenseHeaders = (groupBy?: SearchGroupBy): SearchColumnConfig[] => [
columnName: CONST.SEARCH.TABLE_COLUMNS.HELD,
translationKey: 'search.filters.held',
},
+ {
+ columnName: CONST.SEARCH.TABLE_COLUMNS.DECLINED,
+ translationKey: 'search.filters.rejected',
+ },
{
columnName: CONST.SEARCH.TABLE_COLUMNS.MCC,
translationKey: 'common.mcc',
@@ -296,6 +300,10 @@ const getExpenseReportHeaders = (profileIcon?: IconAsset): SearchColumnConfig[]
columnName: CONST.SEARCH.TABLE_COLUMNS.PAID_STATUS,
translationKey: 'common.paidStatus',
},
+ {
+ columnName: CONST.SEARCH.TABLE_COLUMNS.DECLINED,
+ translationKey: 'search.filters.rejected',
+ },
{
columnName: CONST.SEARCH.TABLE_COLUMNS.TITLE,
translationKey: 'common.title',
diff --git a/src/components/Search/SortableTableHeader.tsx b/src/components/Search/SortableTableHeader.tsx
index 868623144a45..46e24c729fa7 100644
--- a/src/components/Search/SortableTableHeader.tsx
+++ b/src/components/Search/SortableTableHeader.tsx
@@ -84,7 +84,10 @@ function SortableTableHeader({
const sortByColumnName = sortColumnName ?? columnName;
const isActive = sortBy === sortByColumnName;
const isYesNoColumn =
- columnName === CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE || columnName === CONST.SEARCH.TABLE_COLUMNS.BILLABLE || columnName === CONST.SEARCH.TABLE_COLUMNS.HELD;
+ columnName === CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE ||
+ columnName === CONST.SEARCH.TABLE_COLUMNS.BILLABLE ||
+ columnName === CONST.SEARCH.TABLE_COLUMNS.HELD ||
+ columnName === CONST.SEARCH.TABLE_COLUMNS.DECLINED;
const isConversionAmountColumn =
columnName === CONST.SEARCH.TABLE_COLUMNS.GROUP_AMOUNT_DEBITED ||
columnName === CONST.SEARCH.TABLE_COLUMNS.GROUP_AMOUNT_REIMBURSED ||
diff --git a/src/components/TransactionItemRow/TransactionItemRowWide.tsx b/src/components/TransactionItemRow/TransactionItemRowWide.tsx
index 629dfcefb562..2d8188417b67 100644
--- a/src/components/TransactionItemRow/TransactionItemRowWide.tsx
+++ b/src/components/TransactionItemRow/TransactionItemRowWide.tsx
@@ -12,8 +12,8 @@ import TextCell from '@components/Search/SearchList/ListItem/TextCell';
import AmountCell from '@components/Search/SearchList/ListItem/TotalCell';
import UserInfoCell from '@components/Search/SearchList/ListItem/UserInfoCell';
import WorkspaceCell from '@components/Search/SearchList/ListItem/WorkspaceCell';
+import YesNoCell from '@components/Search/SearchList/ListItem/YesNoCell';
import type {SearchColumnType} from '@components/Search/types';
-import Text from '@components/Text';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
@@ -185,12 +185,7 @@ function TransactionItemRowWide({
key={columnName}
style={[getSearchColumnStyles(columnName)]}
>
-
- {value ? translate('common.yes') : translate('common.no')}
-
+
);
@@ -374,6 +369,8 @@ function TransactionItemRowWide({
return renderYesNoCell(column, !!transactionItem.billable);
case CONST.SEARCH.TABLE_COLUMNS.HELD:
return renderYesNoCell(column, isOnHold(transactionItem));
+ case CONST.SEARCH.TABLE_COLUMNS.DECLINED:
+ return renderYesNoCell(column, !!transactionItem.declined);
case CONST.SEARCH.TABLE_COLUMNS.ACTION:
return (
{
+ // Ranked as 'yes'/'no' to match the backend, with a report that never got the field ranked as not rejected.
+ const aValue = a.declined ? CONST.SEARCH.BOOLEAN.YES : CONST.SEARCH.BOOLEAN.NO;
+ const bValue = b.declined ? CONST.SEARCH.BOOLEAN.YES : CONST.SEARCH.BOOLEAN.NO;
+ return compareValues(aValue, bValue, sortOrder, sortBy, localeCompare);
+ });
+ }
+
const sortingProperty =
sortBy === CONST.SEARCH.SORT_BY_COLUMNS.CATEGORY_GL_CODE || sortBy === CONST.SEARCH.SORT_BY_COLUMNS.TAG_GL_CODE ? undefined : expenseReportColumnNamesToSortingProperty[sortBy];
@@ -4551,6 +4560,8 @@ function getSearchColumnTranslationKey(column: SearchSortBy, type?: SearchDataTy
return 'common.billable';
case CONST.SEARCH.TABLE_COLUMNS.HELD:
return 'search.filters.held';
+ case CONST.SEARCH.TABLE_COLUMNS.DECLINED:
+ return 'search.filters.rejected';
case CONST.SEARCH.TABLE_COLUMNS.MCC:
return 'common.mcc';
case CONST.SEARCH.TABLE_COLUMNS.TAX_CODE:
@@ -6776,6 +6787,7 @@ function getColumnsToShow({
[CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE]: false,
[CONST.SEARCH.TABLE_COLUMNS.BILLABLE]: false,
[CONST.SEARCH.TABLE_COLUMNS.HELD]: false,
+ [CONST.SEARCH.TABLE_COLUMNS.DECLINED]: false,
[CONST.SEARCH.TABLE_COLUMNS.MCC]: false,
[CONST.SEARCH.TABLE_COLUMNS.TAX_CODE]: false,
[CONST.SEARCH.TABLE_COLUMNS.TAX_RATE]: false,
@@ -7183,6 +7195,7 @@ function getTableMinWidth(
column === CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE ||
column === CONST.SEARCH.TABLE_COLUMNS.BILLABLE ||
column === CONST.SEARCH.TABLE_COLUMNS.HELD ||
+ column === CONST.SEARCH.TABLE_COLUMNS.DECLINED ||
column === CONST.SEARCH.TABLE_COLUMNS.MCC ||
column === CONST.SEARCH.TABLE_COLUMNS.TAX_CODE
) {
diff --git a/src/libs/getTransactionFromTransactionListItem.ts b/src/libs/getTransactionFromTransactionListItem.ts
index 7be736d4dfa2..aaa7256232cb 100644
--- a/src/libs/getTransactionFromTransactionListItem.ts
+++ b/src/libs/getTransactionFromTransactionListItem.ts
@@ -42,6 +42,7 @@ const SEARCH_TRANSACTION_ROW_ONLY_FIELDS: Record ({
case CONST.SEARCH.TABLE_COLUMNS.REIMBURSABLE:
case CONST.SEARCH.TABLE_COLUMNS.BILLABLE:
case CONST.SEARCH.TABLE_COLUMNS.HELD:
+ case CONST.SEARCH.TABLE_COLUMNS.DECLINED:
columnWidth = {...getWidthStyle(variables.w80)};
break;
case CONST.SEARCH.TABLE_COLUMNS.MCC:
diff --git a/src/types/onyx/SearchResults.ts b/src/types/onyx/SearchResults.ts
index f92f71490bc6..4e186aac9a4e 100644
--- a/src/types/onyx/SearchResults.ts
+++ b/src/types/onyx/SearchResults.ts
@@ -285,12 +285,24 @@ type SearchQuarterGroup = SearchGroupBase & {
quarter: number;
};
+/** Model of an expense in the search results */
+type SearchTransaction = Transaction & {
+ /** Whether the expense was ever rejected, only returned when the Rejected column is shown or sorted by */
+ declined?: boolean;
+};
+
+/** Model of a report in the search results */
+type SearchReport = Report & {
+ /** Whether the expense report was ever rejected, only returned when the Rejected column is shown or sorted by */
+ declined?: boolean;
+};
+
/** SearchResultDataType */
-type SearchResultDataType = PrefixedRecord &
+type SearchResultDataType = PrefixedRecord &
Partial | undefined>> &
PrefixedRecord> &
Partial> &
- PrefixedRecord &
+ PrefixedRecord &
PrefixedRecord &
PrefixedRecord &
PrefixedRecord &
@@ -326,6 +338,8 @@ export default SearchResults;
export type {
ListItemDataType,
SearchTask,
+ SearchTransaction,
+ SearchReport,
SearchTransactionAction,
SearchDataTypes,
SearchResultsInfo,
diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts
index 938e5656a8b9..0e1f73b0c4dd 100644
--- a/tests/unit/Search/SearchUIUtilsTest.ts
+++ b/tests/unit/Search/SearchUIUtilsTest.ts
@@ -8636,6 +8636,63 @@ describe('SearchUIUtils', () => {
]);
});
+ it('should sort expenses by the Rejected column, ranking expenses without the field as not rejected', () => {
+ // Given a rejected expense, a never rejected one, and one returned without the field, with `inserted` set so
+ // the tie breaker between the equal rows is predictable
+ const baseTransaction = transactionsListItems.at(0);
+ if (!baseTransaction) {
+ throw new Error('Missing base transaction fixture');
+ }
+ const transactions: TransactionListItemType[] = [
+ {...baseTransaction, transactionID: 'rejected', keyForList: 'rejected', inserted: '2024-01-01', declined: true},
+ {...baseTransaction, transactionID: 'not-rejected', keyForList: 'not-rejected', inserted: '2024-02-02', declined: false},
+ {...baseTransaction, transactionID: 'no-field', keyForList: 'no-field', inserted: '2024-03-03'},
+ ];
+
+ // When the expenses are sorted by Rejected in each direction
+ const sortTransactions = (sortOrder: SortOrder) =>
+ SearchUIUtils.getSortedSections(
+ CONST.SEARCH.DATA_TYPES.EXPENSE,
+ [...transactions],
+ localeCompare,
+ translateLocal,
+ CONST.SEARCH.TABLE_COLUMNS.DECLINED,
+ sortOrder,
+ undefined,
+ ).map((item) => ('transactionID' in item ? item.transactionID : undefined));
+
+ // Then the expense without the field ranks with the not rejected one, the way its cell shows "No", so the local
+ // sort agrees with the backend's 'yes'/'no' ranking
+ expect(sortTransactions(CONST.SEARCH.SORT_ORDER.ASC)).toEqual(['not-rejected', 'no-field', 'rejected']);
+ expect(sortTransactions(CONST.SEARCH.SORT_ORDER.DESC)).toEqual(['rejected', 'no-field', 'not-rejected']);
+ });
+
+ it('should sort expense reports by the Rejected column, ranking reports without the field as not rejected', () => {
+ // Given a rejected expense report, a never rejected one, and one returned without the field
+ const createReport = (keyForList: string, declined?: boolean) =>
+ createMock({
+ groupedBy: 'expense-report',
+ keyForList,
+ reportID: keyForList,
+ created: '2025-08-12 17:11:22',
+ currency: 'USD',
+ transactions: [],
+ ...(declined === undefined ? {} : {declined}),
+ });
+ const reports = [createReport('report_rejected', true), createReport('report_not_rejected', false), createReport('report_no_field')];
+
+ // When the reports are sorted by Rejected in each direction
+ const sortReports = (sortOrder: SortOrder) =>
+ SearchUIUtils.getSortedSections(CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, [...reports], localeCompare, translateLocal, CONST.SEARCH.TABLE_COLUMNS.DECLINED, sortOrder).map(
+ (report) => report.keyForList,
+ );
+
+ // Then the report without the field ranks with the not rejected one, the way its cell shows "No", and equal
+ // reports keep the order the backend returned them in
+ expect(sortReports(CONST.SEARCH.SORT_ORDER.ASC)).toStrictEqual(['report_not_rejected', 'report_no_field', 'report_rejected']);
+ expect(sortReports(CONST.SEARCH.SORT_ORDER.DESC)).toStrictEqual(['report_rejected', 'report_not_rejected', 'report_no_field']);
+ });
+
it('should return getSortedReportData result when type is expense-report', () => {
expect(SearchUIUtils.getSortedSections(CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, transactionReportGroupListItems, localeCompare, translateLocal, 'date', 'asc')).toStrictEqual(
transactionReportGroupListItems,
@@ -12088,6 +12145,40 @@ describe('SearchUIUtils', () => {
expect(result).toContain(CONST.SEARCH.TABLE_COLUMNS.TOTAL);
});
+ test('Should keep the Rejected column hidden on expense searches until the user picks it', () => {
+ // Given an expense the backend marked as rejected
+ const rejectedTransaction = {...createRandomTransaction(4), declined: true};
+ const pickedColumns = [CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.DECLINED, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT];
+
+ // When the columns are resolved with and without the user picking Rejected
+ const defaultColumns = SearchUIUtils.getColumnsToShow({currentAccountID: 1, data: [rejectedTransaction], visibleColumns: [], type: CONST.SEARCH.DATA_TYPES.EXPENSE});
+ const pickedResult = SearchUIUtils.getColumnsToShow({currentAccountID: 1, data: [rejectedTransaction], visibleColumns: pickedColumns, type: CONST.SEARCH.DATA_TYPES.EXPENSE});
+
+ // Then Rejected is offered in the Search column picker but only shown once picked, and it is not offered in the
+ // single-report view because the report view's transactions never carry the field
+ expect(SearchUIUtils.getCustomColumns(CONST.SEARCH.DATA_TYPES.EXPENSE)).toContain(CONST.SEARCH.TABLE_COLUMNS.DECLINED);
+ expect(SearchUIUtils.getCustomColumnDefault(CONST.SEARCH.DATA_TYPES.EXPENSE)).not.toContain(CONST.SEARCH.TABLE_COLUMNS.DECLINED);
+ expect(Object.values(CONST.SEARCH.REPORT_DETAILS_CUSTOM_COLUMNS)).not.toContain(CONST.SEARCH.TABLE_COLUMNS.DECLINED);
+ expect(defaultColumns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.DECLINED);
+ expect(pickedResult).toContain(CONST.SEARCH.TABLE_COLUMNS.DECLINED);
+ });
+
+ test('Should keep the Rejected column hidden on expense report searches until the user picks it', () => {
+ // Given the column lists for expense report searches
+ const visibleColumns = [CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.DECLINED, CONST.SEARCH.TABLE_COLUMNS.TOTAL];
+
+ // When the columns are resolved with and without the user picking Rejected
+ const defaultColumns = SearchUIUtils.getColumnsToShow({currentAccountID: 1, data: [], visibleColumns: [], type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT});
+ const pickedResult = SearchUIUtils.getColumnsToShow({currentAccountID: 1, data: [], visibleColumns, type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT});
+
+ // Then Rejected is offered in the expense report column picker, stays out of the defaults, and is shown in the
+ // picked position once chosen, so it is sent in the query columns the backend reads to return the field
+ expect(SearchUIUtils.getCustomColumns(CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT)).toContain(CONST.SEARCH.TABLE_COLUMNS.DECLINED);
+ expect(SearchUIUtils.getCustomColumnDefault(CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT)).not.toContain(CONST.SEARCH.TABLE_COLUMNS.DECLINED);
+ expect(defaultColumns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.DECLINED);
+ expect(pickedResult).toEqual(visibleColumns);
+ });
+
test('Should not force Avatar for group-by:from when toggled off', () => {
const visibleColumns = [CONST.SEARCH.TABLE_COLUMNS.GROUP_FROM, CONST.SEARCH.TABLE_COLUMNS.GROUP_TOTAL];
@@ -13502,6 +13593,17 @@ describe('SearchUIUtils', () => {
const translationKey = SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.WITHDRAWAL_ID);
expect(translationKey).toBe('common.withdrawalID');
});
+
+ it('should label the DECLINED column with the Rejected filter label', () => {
+ // Given the Rejected column, whose header is also sent as the export column label
+ // When its translation key is resolved for expenses and expense reports
+ const expenseKey = SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.DECLINED, CONST.SEARCH.DATA_TYPES.EXPENSE);
+ const expenseReportKey = SearchUIUtils.getSearchColumnTranslationKey(CONST.SEARCH.TABLE_COLUMNS.DECLINED, CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT);
+
+ // Then it reuses the declined: filter's "Rejected" label, so the column, the filter, and the export header match
+ expect(expenseKey).toBe('search.filters.rejected');
+ expect(expenseReportKey).toBe('search.filters.rejected');
+ });
});
describe('createAndOpenSearchTransactionThread', () => {
diff --git a/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap b/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap
index d4443f54c67c..a2baf61ab80c 100644
--- a/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap
+++ b/tests/unit/Search/__snapshots__/ColumnAvailabilityTest.ts.snap
@@ -63,6 +63,7 @@ exports[`Column availability single source of truth the derived picker lists mat
"reimbursable",
"billable",
"held",
+ "declined",
"mcc",
"taxCode",
"taxrate",