diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 084e4c5d6669..6d1d3d7e0e16 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -6600,6 +6600,8 @@ const CONST = {
DATE: this.TABLE_COLUMNS.DATE,
SUBMITTED: this.TABLE_COLUMNS.SUBMITTED,
APPROVED: this.TABLE_COLUMNS.APPROVED,
+ FIRST_APPROVER: this.TABLE_COLUMNS.FIRST_APPROVER,
+ FIRST_APPROVED: this.TABLE_COLUMNS.FIRST_APPROVED,
EXPORTED: this.TABLE_COLUMNS.EXPORTED,
STATUS: this.TABLE_COLUMNS.STATUS,
TITLE: this.TABLE_COLUMNS.TITLE,
@@ -6792,6 +6794,8 @@ const CONST = {
DATE: 'date',
SUBMITTED: 'submitted',
APPROVED: 'approved',
+ FIRST_APPROVER: 'firstapprover',
+ FIRST_APPROVED: 'firstapproved',
POSTED: 'posted',
EXPORTED: 'exported',
MERCHANT: 'merchant',
diff --git a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx
index e8f88871d6f3..16a76bd7e0e3 100644
--- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx
+++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx
@@ -98,6 +98,27 @@ function ExpenseReportListItemRowWide({
/>
),
+ [CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER]: (
+
+ {!!item.firstApproverAccountID && (
+
+ )}
+
+ ),
+ [CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED]: (
+
+
+
+ ),
[CONST.SEARCH.TABLE_COLUMNS.EXPORTED]: (
;
shouldShowMerchant: boolean;
lastExportedActionByReportID: Map;
+ firstApprovedActionByReportID: Map;
moneyRequestReportActionsByTransactionID: Map;
holdReportActionsByTransactionID: Map;
allHoldReportActions: Map;
@@ -1776,6 +1779,7 @@ function createPreprocessingContext(): PreprocessingContext {
violations: {},
shouldShowMerchant: false,
lastExportedActionByReportID: new Map(),
+ firstApprovedActionByReportID: new Map(),
moneyRequestReportActionsByTransactionID: new Map(),
holdReportActionsByTransactionID: new Map(),
allHoldReportActions: new Map(),
@@ -1806,6 +1810,10 @@ function processReportActionEntry(ctx: PreprocessingContext, key: string, action
let latestExportTime = -Infinity;
let latestExportAction: OnyxTypes.ReportAction | undefined;
+ // The first approver is the actor on the earliest APPROVED/FORWARDED action.
+ let firstApprovalTime = Infinity;
+ let firstApprovalAction: OnyxTypes.ReportAction | undefined;
+
for (const action of Object.values(actions)) {
if (action.actionName === CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_CSV || action.actionName === CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION) {
const currentTime = new Date(action.created).getTime();
@@ -1815,6 +1823,14 @@ function processReportActionEntry(ctx: PreprocessingContext, key: string, action
}
}
+ if (action.actionName === CONST.REPORT.ACTIONS.TYPE.APPROVED || action.actionName === CONST.REPORT.ACTIONS.TYPE.FORWARDED) {
+ const currentTime = new Date(action.created).getTime();
+ if (currentTime < firstApprovalTime) {
+ firstApprovalTime = currentTime;
+ firstApprovalAction = action;
+ }
+ }
+
if (isMoneyRequestAction(action)) {
const originalMessage = getOriginalMessage(action);
const transactionID = originalMessage?.IOUTransactionID;
@@ -1833,6 +1849,10 @@ function processReportActionEntry(ctx: PreprocessingContext, key: string, action
if (latestExportAction) {
ctx.lastExportedActionByReportID.set(reportID, latestExportAction);
}
+
+ if (firstApprovalAction) {
+ ctx.firstApprovedActionByReportID.set(reportID, firstApprovalAction);
+ }
}
/**
@@ -2790,6 +2810,7 @@ function getReportSections({
violations: allViolations,
shouldShowMerchant,
lastExportedActionByReportID,
+ firstApprovedActionByReportID,
moneyRequestReportActionsByTransactionID,
holdReportActionsByTransactionID,
transactionsByReportID,
@@ -2856,8 +2877,15 @@ function getReportSections({
emptyPersonalDetails;
const toDetails = !shouldShowBlankTo && reportItem.managerID ? mergedPersonalDetails?.[reportItem.managerID] : emptyPersonalDetails;
+ // First approver/approved come from the earliest APPROVED/FORWARDED report action; blank when the report has no approval.
+ const firstApprovedAction = firstApprovedActionByReportID.get(reportItem.reportID);
+ const firstApproverAccountID = firstApprovedAction?.actorAccountID;
+ const firstApproverDetails = firstApproverAccountID ? mergedPersonalDetails?.[firstApproverAccountID] : undefined;
+ const firstApproved = firstApprovedAction?.created ?? '';
+
const formattedFrom = formatPhoneNumber(getDisplayNameOrDefault(fromDetails));
const formattedTo = !shouldShowBlankTo ? formatPhoneNumber(getDisplayNameOrDefault(toDetails)) : '';
+ const formattedFirstApprover = firstApproverAccountID ? formatPhoneNumber(getDisplayNameOrDefault(firstApproverDetails)) : '';
const formattedStatus = getReportStatusTranslation({stateNum: reportItem.stateNum, statusNum: reportItem.statusNum, translate});
const policy = getPolicyFromKey(data, reportItem);
@@ -2897,6 +2925,10 @@ function getReportSections({
from: (fromDetails ?? emptyPersonalDetails) as OnyxTypes.PersonalDetails,
to: (toDetails ?? emptyPersonalDetails) as OnyxTypes.PersonalDetails,
exported: lastExportedActionByReportID.get(reportItem.reportID)?.created ?? '',
+ firstApproved,
+ firstApproverAvatar: firstApproverDetails?.avatar,
+ firstApproverAccountID,
+ formattedFirstApprover,
formattedFrom,
formattedTo,
formattedStatus,
@@ -4319,6 +4351,10 @@ function getSearchColumnTranslationKey(column: SearchSortBy): TranslationPaths {
return 'common.submitted';
case CONST.SEARCH.TABLE_COLUMNS.APPROVED:
return 'search.filters.approved';
+ case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER:
+ return 'search.filters.firstApprover';
+ case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED:
+ return 'search.filters.firstApproved';
case CONST.SEARCH.TABLE_COLUMNS.POSTED:
return 'search.filters.posted';
case CONST.SEARCH.TABLE_COLUMNS.EXPORTED:
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index 0d2f27493727..31f90733b836 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -1907,16 +1907,20 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
columnWidth = {...getWidthStyle(variables.w130), ...styles.alignItemsCenter};
break;
case CONST.SEARCH.TABLE_COLUMNS.SUBMITTED:
- columnWidth = {...getWidthStyle(isSubmittedColumnWide ? variables.w92 : variables.w72)};
+ columnWidth = {...getWidthStyle(isSubmittedColumnWide ? variables.w102 : variables.w62)};
break;
case CONST.SEARCH.TABLE_COLUMNS.APPROVED:
- columnWidth = {...getWidthStyle(isApprovedColumnWide ? variables.w92 : variables.w72)};
+ columnWidth = {...getWidthStyle(isApprovedColumnWide ? variables.w102 : variables.w62)};
+ break;
+ case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED:
+ // Fixed width: wide enough for both the long "First approved" header and a past-year date, so no year-based widening is needed.
+ columnWidth = {...getWidthStyle(variables.w102)};
break;
case CONST.SEARCH.TABLE_COLUMNS.POSTED:
- columnWidth = {...getWidthStyle(isPostedColumnWide ? variables.w92 : variables.w72)};
+ columnWidth = {...getWidthStyle(isPostedColumnWide ? variables.w102 : variables.w62)};
break;
case CONST.SEARCH.TABLE_COLUMNS.EXPORTED:
- columnWidth = {...getWidthStyle(isExportedColumnWide ? variables.w92 : variables.w72)};
+ columnWidth = {...getWidthStyle(isExportedColumnWide ? variables.w102 : variables.w62)};
break;
case CONST.SEARCH.TABLE_COLUMNS.DATE:
columnWidth = {...getWidthStyle(isDateColumnWide ? variables.w102 : variables.w62)};
@@ -1996,6 +2000,7 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
case CONST.SEARCH.TABLE_COLUMNS.MERCHANT:
case CONST.SEARCH.TABLE_COLUMNS.FROM:
case CONST.SEARCH.TABLE_COLUMNS.TO:
+ case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER:
case CONST.SEARCH.TABLE_COLUMNS.ASSIGNEE:
case CONST.SEARCH.TABLE_COLUMNS.TITLE:
case CONST.SEARCH.TABLE_COLUMNS.DESCRIPTION:
diff --git a/tests/unit/ButtonStyleUtilsTest.ts b/tests/unit/ButtonStyleUtilsTest.ts
index 45bf143a3917..8a29cbed0990 100644
--- a/tests/unit/ButtonStyleUtilsTest.ts
+++ b/tests/unit/ButtonStyleUtilsTest.ts
@@ -2,6 +2,7 @@ import CONST from '@src/CONST';
import type {ThemeStyles} from '@src/styles';
import type {ThemeColors} from '@src/styles/theme/types';
import createStyleUtils from '@src/styles/utils';
+import variables from '@src/styles/variables';
const mockTheme = {} as ThemeColors;
@@ -22,7 +23,7 @@ const mockStyles = {
buttonOpacityDisabled: {opacity: 0.5},
} as unknown as ThemeStyles;
-const {getButtonSizeStyle, getButtonPaddingStyle, getButtonStyleWithIcon, getButtonVariantStyles} = createStyleUtils(mockTheme, mockStyles);
+const {getButtonSizeStyle, getButtonPaddingStyle, getButtonStyleWithIcon, getButtonVariantStyles, getReportTableColumnStyles} = createStyleUtils(mockTheme, mockStyles);
describe('getButtonSizeStyle', () => {
it.each([
@@ -88,3 +89,9 @@ describe('getButtonVariantStyles', () => {
});
});
});
+
+describe('getReportTableColumnStyles - First approved column width', () => {
+ it('uses a fixed wide width (fits the long header and a past-year date, so no year-based widening)', () => {
+ expect(getReportTableColumnStyles(CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED)).toEqual({width: variables.w102});
+ });
+});
diff --git a/tests/unit/Search/ColumnAvailabilityTest.ts b/tests/unit/Search/ColumnAvailabilityTest.ts
index 5927f9cf896f..a9e142d60353 100644
--- a/tests/unit/Search/ColumnAvailabilityTest.ts
+++ b/tests/unit/Search/ColumnAvailabilityTest.ts
@@ -30,6 +30,8 @@ describe('Column availability single source of truth', () => {
// Expense-report (report list) view.
TABLE_COLUMNS.REIMBURSABLE_TOTAL,
TABLE_COLUMNS.NON_REIMBURSABLE_TOTAL,
+ TABLE_COLUMNS.FIRST_APPROVER,
+ TABLE_COLUMNS.FIRST_APPROVED,
// Grouped search views.
TABLE_COLUMNS.EXPENSES,
TABLE_COLUMNS.FEED,
diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts
index ffae3d48ce01..3aa280c91515 100644
--- a/tests/unit/Search/SearchUIUtilsTest.ts
+++ b/tests/unit/Search/SearchUIUtilsTest.ts
@@ -1143,6 +1143,10 @@ const transactionReportGroupListItems = [
shouldShowYearSubmitted: true,
shouldShowYearApproved: false,
shouldShowYearExported: false,
+ firstApproved: '',
+ firstApproverAvatar: undefined,
+ firstApproverAccountID: undefined,
+ formattedFirstApprover: '',
stateNum: 0,
statusNum: 0,
to: emptyPersonalDetails,
@@ -1263,6 +1267,10 @@ const transactionReportGroupListItems = [
shouldShowYearSubmitted: true,
shouldShowYearApproved: false,
shouldShowYearExported: false,
+ firstApproved: '',
+ firstApproverAvatar: undefined,
+ firstApproverAccountID: undefined,
+ formattedFirstApprover: '',
stateNum: 1,
statusNum: 1,
to: {
@@ -1389,6 +1397,10 @@ const transactionReportGroupListItems = [
shouldShowYearSubmitted: true,
shouldShowYearApproved: false,
shouldShowYearExported: false,
+ firstApproved: '',
+ firstApproverAvatar: undefined,
+ firstApproverAccountID: undefined,
+ formattedFirstApprover: '',
stateNum: 1,
statusNum: 1,
total: 4400,
@@ -1596,6 +1608,10 @@ const transactionReportGroupListItems = [
shouldShowYearSubmitted: true,
shouldShowYearApproved: false,
shouldShowYearExported: false,
+ firstApproved: '',
+ firstApproverAvatar: undefined,
+ firstApproverAccountID: undefined,
+ formattedFirstApprover: '',
stateNum: 0,
statusNum: 0,
to: emptyPersonalDetails,
@@ -6004,6 +6020,80 @@ describe('SearchUIUtils', () => {
const item = sections.find((s) => s.keyForList === rptFilterReportID);
expect(item?.pendingAction).toBeUndefined();
});
+
+ it('should populate firstApprover/firstApproved from the report APPROVED action', () => {
+ const approvedAt = '2024-12-22 09:30:00';
+ const data = makeReportFilterTestData(
+ {type: CONST.REPORT.TYPE.EXPENSE},
+ {},
+ {
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${rptFilterReportID}`]: {
+ 'approved-1': {
+ reportActionID: 'approved-1',
+ actionName: CONST.REPORT.ACTIONS.TYPE.APPROVED,
+ actorAccountID: approverAccountID,
+ created: approvedAt,
+ },
+ },
+ },
+ );
+ const [sections] = callGetReportSections(data);
+ const item = sections.find((s) => s.keyForList === rptFilterReportID);
+ expect(item?.firstApproved).toBe(approvedAt);
+ expect(item?.firstApproverAccountID).toBe(approverAccountID);
+ expect(item?.formattedFirstApprover).toBeTruthy();
+ });
+
+ it('should use the earliest approval action (FORWARDED before APPROVED) for the first approver', () => {
+ const forwardedAt = '2024-12-20 08:00:00';
+ const approvedAt = '2024-12-22 09:30:00';
+ const data = makeReportFilterTestData(
+ {type: CONST.REPORT.TYPE.EXPENSE},
+ {},
+ {
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${rptFilterReportID}`]: {
+ 'approved-1': {
+ reportActionID: 'approved-1',
+ actionName: CONST.REPORT.ACTIONS.TYPE.APPROVED,
+ actorAccountID: adminAccountID,
+ created: approvedAt,
+ },
+ 'forwarded-1': {
+ reportActionID: 'forwarded-1',
+ actionName: CONST.REPORT.ACTIONS.TYPE.FORWARDED,
+ actorAccountID: approverAccountID,
+ created: forwardedAt,
+ },
+ },
+ },
+ );
+ const [sections] = callGetReportSections(data);
+ const item = sections.find((s) => s.keyForList === rptFilterReportID);
+ expect(item?.firstApproved).toBe(forwardedAt);
+ expect(item?.firstApproverAccountID).toBe(approverAccountID);
+ });
+
+ it('should leave firstApprover/firstApproved blank when there is no approval action (no FE fallback to managerID/approved)', () => {
+ const data = makeReportFilterTestData(
+ {type: CONST.REPORT.TYPE.EXPENSE, approved: '2024-12-22 09:30:00', managerID: adminAccountID},
+ {},
+ {
+ [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${rptFilterReportID}`]: {
+ 'comment-1': {
+ reportActionID: 'comment-1',
+ actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT,
+ actorAccountID: adminAccountID,
+ created: '2024-12-22 09:30:00',
+ },
+ },
+ },
+ );
+ const [sections] = callGetReportSections(data);
+ const item = sections.find((s) => s.keyForList === rptFilterReportID);
+ expect(item?.firstApproved).toBe('');
+ expect(item?.firstApproverAccountID).toBeUndefined();
+ expect(item?.formattedFirstApprover).toBe('');
+ });
});
});
@@ -8622,6 +8712,12 @@ describe('SearchUIUtils', () => {
expect(categoryGLCodeHeader?.sortColumnName).toBe(CONST.SEARCH.SORT_BY_COLUMNS.CATEGORY_GL_CODE);
});
+ test('Should include First approver and First approved in sort options', () => {
+ const texts = SearchUIUtils.getSortByOptions([CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER, CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED], translateLocal).map((option) => option.text);
+ expect(texts).toContain(translateLocal('search.filters.firstApprover'));
+ expect(texts).toContain(translateLocal('search.filters.firstApproved'));
+ });
+
test('Should show Tag GL Code whenever that column is selected, even when no transaction resolves a tag GL code', () => {
const baseTransaction = searchResults.data[`transactions_${transactionID}`];
const transactionWithTagGLCode = {