diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 33908be9c137..cf5cc8f9a41a 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -7077,6 +7077,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,
@@ -7277,6 +7279,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 7151ebab9782..748b001a8cb3 100644
--- a/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx
+++ b/src/components/Search/SearchList/ListItem/ExpenseReportListItemRow/ExpenseReportListItemRowWide.tsx
@@ -82,6 +82,27 @@ function ExpenseReportListItemRowWide({
/>
),
+ [CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER]: (
+
+ {!!item.firstApproverAccountID && (
+
+ )}
+
+ ),
+ [CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED]: (
+
+
+
+ ),
[CONST.SEARCH.TABLE_COLUMNS.EXPORTED]: (
([
+ CONST.SEARCH.TABLE_COLUMNS.SUBMITTED,
+ CONST.SEARCH.TABLE_COLUMNS.APPROVED,
+ CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED,
+ CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER,
+ CONST.SEARCH.TABLE_COLUMNS.EXPORTED,
+ CONST.SEARCH.TABLE_COLUMNS.EXPORTED_TO,
+ CONST.SEARCH.TABLE_COLUMNS.TO,
+ CONST.SEARCH.TABLE_COLUMNS.POLICY_NAME,
+]);
+
+/**
+ * Scans the search results once and returns the set of hideable Reports-list columns that have
+ * at least one report with a value. Used to drop empty columns from the Reports table, the same
+ * way `updateColumns` does for the transaction-search path.
+ * @private
+ */
+function getReportListColumnsWithData(data: OnyxTypes.SearchResults['data'] | OnyxTypes.Transaction[]): Set {
+ const present = new Set();
+ if (Array.isArray(data)) {
+ return present;
+ }
+ for (const key of Object.keys(data)) {
+ if (isReportActionEntry(key)) {
+ for (const action of Object.values(data[key])) {
+ if (action.actionName === CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_CSV || action.actionName === CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION) {
+ present.add(CONST.SEARCH.TABLE_COLUMNS.EXPORTED);
+ present.add(CONST.SEARCH.TABLE_COLUMNS.EXPORTED_TO);
+ }
+ }
+ continue;
+ }
+ if (isReportEntry(key)) {
+ const report = data[key];
+ if (report?.submitted) {
+ present.add(CONST.SEARCH.TABLE_COLUMNS.SUBMITTED);
+ }
+ if (report?.approved) {
+ present.add(CONST.SEARCH.TABLE_COLUMNS.APPROVED);
+ }
+ // Mirrors export's `report.approvers[0]`; needs backend `approvers` to render.
+ if (report?.approvers?.length) {
+ present.add(CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED);
+ present.add(CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER);
+ }
+ if (report?.managerID) {
+ present.add(CONST.SEARCH.TABLE_COLUMNS.TO);
+ }
+ if (report?.policyID) {
+ present.add(CONST.SEARCH.TABLE_COLUMNS.POLICY_NAME);
+ }
+ }
+ }
+ return present;
+}
+
/**
* @private
*/
@@ -1738,6 +1802,7 @@ type PreprocessingContext = {
shouldShowYearCreatedReport: boolean;
shouldShowYearSubmittedReport: boolean;
shouldShowYearApprovedReport: boolean;
+ shouldShowYearFirstApprovedReport: boolean;
shouldShowYearExportedReport: boolean;
shouldShowAmountInWideColumn: boolean;
shouldShowTaxAmountInWideColumn: boolean;
@@ -1764,6 +1829,7 @@ function createPreprocessingContext(): PreprocessingContext {
shouldShowYearCreatedReport: false,
shouldShowYearSubmittedReport: false,
shouldShowYearApprovedReport: false,
+ shouldShowYearFirstApprovedReport: false,
shouldShowYearExportedReport: false,
shouldShowAmountInWideColumn: false,
shouldShowTaxAmountInWideColumn: false,
@@ -1824,6 +1890,10 @@ function processReportEntry(ctx: PreprocessingContext, report: OnyxTypes.Report)
if (!ctx.shouldShowYearApprovedReport && report.approved && DateUtils.doesDateBelongToAPastYear(report.approved)) {
ctx.shouldShowYearApprovedReport = true;
}
+ const firstApprovedDate = report.approvers?.at(0)?.date;
+ if (!ctx.shouldShowYearFirstApprovedReport && firstApprovedDate && DateUtils.doesDateBelongToAPastYear(firstApprovedDate)) {
+ ctx.shouldShowYearFirstApprovedReport = true;
+ }
if (!ctx.shouldShowYearSubmitted && report.submitted && DateUtils.doesDateBelongToAPastYear(report.submitted)) {
ctx.shouldShowYearSubmitted = true;
@@ -2711,6 +2781,7 @@ function getReportSections({
shouldShowYearCreatedReport,
shouldShowYearSubmittedReport,
shouldShowYearApprovedReport,
+ shouldShowYearFirstApprovedReport,
shouldShowYearExportedReport,
shouldShowAmountInWideColumn,
shouldShowTaxAmountInWideColumn,
@@ -2765,8 +2836,17 @@ function getReportSections({
emptyPersonalDetails;
const toDetails = !shouldShowBlankTo && reportItem.managerID ? mergedPersonalDetails?.[reportItem.managerID] : emptyPersonalDetails;
+ // First approver/approved come from BE `report.approvers[0]`; blank when absent.
+ const reportFirstApprover = reportItem.approvers?.at(0);
+ const firstApproverAccountID = reportFirstApprover
+ ? (reportFirstApprover.accountID ?? Object.values(mergedPersonalDetails).find((details) => details?.login === reportFirstApprover.email)?.accountID)
+ : undefined;
+ const firstApproverDetails = firstApproverAccountID ? (mergedPersonalDetails?.[firstApproverAccountID] ?? emptyPersonalDetails) : emptyPersonalDetails;
+ const firstApproved = reportFirstApprover?.date ?? '';
+
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 policyFromKey = getPolicyFromKey(data, reportItem);
@@ -2806,6 +2886,10 @@ function getReportSections({
from: (fromDetails ?? emptyPersonalDetails) as OnyxTypes.PersonalDetails,
to: (toDetails ?? emptyPersonalDetails) as OnyxTypes.PersonalDetails,
exported: lastExportedActionByReportID.get(reportItem.reportID)?.created ?? '',
+ firstApproved,
+ firstApprover: (firstApproverDetails ?? emptyPersonalDetails) as OnyxTypes.PersonalDetails,
+ firstApproverAccountID,
+ formattedFirstApprover,
formattedFrom,
formattedTo,
formattedStatus,
@@ -2815,6 +2899,7 @@ function getReportSections({
shouldShowYear: shouldShowYearCreatedReport,
shouldShowYearSubmitted: shouldShowYearSubmittedReport,
shouldShowYearApproved: shouldShowYearApprovedReport,
+ shouldShowYearFirstApproved: shouldShowYearFirstApprovedReport,
shouldShowYearExported: shouldShowYearExportedReport,
hasVisibleViolations: hasVisibleViolationsForReport,
isRejectedReport,
@@ -4111,6 +4196,10 @@ function getSearchColumnTranslationKey(column: SearchColumnType): TranslationPat
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:
@@ -5264,6 +5353,16 @@ function getColumnsToShow({
result.push(col);
}
+ // Drop hideable columns that have no value in any report in the current results — an
+ // empty column is just noise. Mirrors the per-row data-presence convention that
+ // `updateColumns` applies to the transaction-search path. Columns not in
+ // HIDEABLE_EXPENSE_REPORT_COLUMNS always have a value and are kept as-is.
+ const hasHideableColumnSelected = result.some((col) => HIDEABLE_EXPENSE_REPORT_COLUMNS.has(col));
+ if (hasHideableColumnSelected) {
+ const columnsWithData = getReportListColumnsWithData(data);
+ return result.filter((col) => !HIDEABLE_EXPENSE_REPORT_COLUMNS.has(col) || columnsWithData.has(col));
+ }
+
return result;
}
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index 322377895fef..b40ee8cc6d67 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -58,6 +58,7 @@ type GetReportTableColumnStylesParams = {
isTaxAmountColumnWide?: boolean;
isSubmittedColumnWide?: boolean;
isApprovedColumnWide?: boolean;
+ isFirstApprovedColumnWide?: boolean;
isPostedColumnWide?: boolean;
isExportedColumnWide?: boolean;
shouldRemoveTotalColumnFlex?: boolean;
@@ -1872,6 +1873,7 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
const {
isSubmittedColumnWide,
isApprovedColumnWide,
+ isFirstApprovedColumnWide,
isPostedColumnWide,
isExportedColumnWide,
isDateColumnWide,
@@ -1905,6 +1907,9 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
case CONST.SEARCH.TABLE_COLUMNS.APPROVED:
columnWidth = {...getWidthStyle(isApprovedColumnWide ? variables.w92 : variables.w72)};
break;
+ case CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED:
+ columnWidth = {...getWidthStyle(isFirstApprovedColumnWide ? variables.w92 : variables.w72)};
+ break;
case CONST.SEARCH.TABLE_COLUMNS.POSTED:
columnWidth = {...getWidthStyle(isPostedColumnWide ? variables.w92 : variables.w72)};
break;
@@ -1978,6 +1983,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/src/types/onyx/Report.ts b/src/types/onyx/Report.ts
index 9469059b2a91..2ee404832259 100644
--- a/src/types/onyx/Report.ts
+++ b/src/types/onyx/Report.ts
@@ -98,6 +98,18 @@ type Report = OnyxCommon.OnyxValueWithOfflineFeedback<
/** The date the report was approved */
approved?: string;
+ /** Backend-computed approver list (mirrors export's `report.approvers`); `[0]` is the first approver. */
+ approvers?: Array<{
+ /** Email of the approver */
+ email: string;
+
+ /** Account ID of the approver, when provided by the backend */
+ accountID?: number;
+
+ /** Timestamp of this approval */
+ date: string;
+ }>;
+
/** The specific type of chat */
chatType?: ValueOf;
diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts
index cd3fb1c77daa..9e842b906e19 100644
--- a/tests/unit/Search/SearchUIUtilsTest.ts
+++ b/tests/unit/Search/SearchUIUtilsTest.ts
@@ -1120,8 +1120,13 @@ const transactionReportGroupListItems = [
exported: '',
shouldShowYear: true,
shouldShowYearSubmitted: true,
+ firstApproved: '',
+ firstApprover: emptyPersonalDetails,
+ firstApproverAccountID: undefined,
+ formattedFirstApprover: '',
shouldShowYearApproved: false,
shouldShowYearExported: false,
+ shouldShowYearFirstApproved: false,
stateNum: 0,
statusNum: 0,
to: emptyPersonalDetails,
@@ -1232,8 +1237,13 @@ const transactionReportGroupListItems = [
reportName: 'Expense Report #123',
shouldShowYear: true,
shouldShowYearSubmitted: true,
+ firstApproved: '',
+ firstApprover: emptyPersonalDetails,
+ firstApproverAccountID: undefined,
+ formattedFirstApprover: '',
shouldShowYearApproved: false,
shouldShowYearExported: false,
+ shouldShowYearFirstApproved: false,
stateNum: 1,
statusNum: 1,
to: {
@@ -1354,8 +1364,13 @@ const transactionReportGroupListItems = [
reportName: 'Approver owes ₫44.00',
shouldShowYear: true,
shouldShowYearSubmitted: true,
+ firstApproved: '',
+ firstApprover: emptyPersonalDetails,
+ firstApproverAccountID: undefined,
+ formattedFirstApprover: '',
shouldShowYearApproved: false,
shouldShowYearExported: false,
+ shouldShowYearFirstApproved: false,
stateNum: 1,
statusNum: 1,
total: 4400,
@@ -1545,8 +1560,13 @@ const transactionReportGroupListItems = [
reportName: 'Expense Report #123',
shouldShowYear: true,
shouldShowYearSubmitted: true,
+ firstApproved: '',
+ firstApprover: emptyPersonalDetails,
+ firstApproverAccountID: undefined,
+ formattedFirstApprover: '',
shouldShowYearApproved: false,
shouldShowYearExported: false,
+ shouldShowYearFirstApproved: false,
stateNum: 0,
statusNum: 0,
to: emptyPersonalDetails,
@@ -5851,6 +5871,53 @@ describe('SearchUIUtils', () => {
const item = sections.find((s) => s.keyForList === rptFilterReportID);
expect(item?.pendingAction).toBeUndefined();
});
+
+ it('should populate firstApprover/firstApproved from report.approvers[0] when backend provides it', () => {
+ const approvedAt = '2024-12-22 09:30:00';
+ const data = makeReportFilterTestData({
+ type: CONST.REPORT.TYPE.EXPENSE,
+ stateNum: CONST.REPORT.STATE_NUM.APPROVED,
+ statusNum: CONST.REPORT.STATUS_NUM.APPROVED,
+ approved: approvedAt,
+ managerID: adminAccountID,
+ approvers: [{email: approverEmail, accountID: approverAccountID, date: approvedAt}],
+ });
+ const [sections] = callGetReportSections(data);
+ const item = sections.find((s) => s.keyForList === rptFilterReportID);
+ expect(item?.firstApproved).toBe(approvedAt);
+ expect(item?.firstApproverAccountID).toBe(approverAccountID);
+ });
+
+ it('should resolve firstApproverAccountID from email when approvers[0].accountID is missing', () => {
+ const approvedAt = '2024-12-22 09:30:00';
+ const data = makeReportFilterTestData({
+ type: CONST.REPORT.TYPE.EXPENSE,
+ stateNum: CONST.REPORT.STATE_NUM.APPROVED,
+ statusNum: CONST.REPORT.STATUS_NUM.APPROVED,
+ approved: approvedAt,
+ managerID: adminAccountID,
+ approvers: [{email: approverEmail, date: approvedAt}],
+ });
+ const [sections] = callGetReportSections(data);
+ const item = sections.find((s) => s.keyForList === rptFilterReportID);
+ expect(item?.firstApproverAccountID).toBe(approverAccountID);
+ expect(item?.firstApproved).toBe(approvedAt);
+ });
+
+ it('should leave firstApprover/firstApproved blank when backend omits approvers (no FE fallback to managerID/approved)', () => {
+ const data = makeReportFilterTestData({
+ type: CONST.REPORT.TYPE.EXPENSE,
+ stateNum: CONST.REPORT.STATE_NUM.APPROVED,
+ statusNum: CONST.REPORT.STATUS_NUM.APPROVED,
+ approved: '2024-12-22 09:30:00',
+ managerID: approverAccountID,
+ });
+ const [sections] = callGetReportSections(data);
+ const item = sections.find((s) => s.keyForList === rptFilterReportID);
+ expect(item?.firstApproved).toBe('');
+ expect(item?.firstApproverAccountID).toBeUndefined();
+ expect(item?.formattedFirstApprover).toBe('');
+ });
});
});
@@ -8603,6 +8670,45 @@ describe('SearchUIUtils', () => {
});
expect(columnsWithoutComments).not.toContain(CONST.SEARCH.TABLE_COLUMNS.COMMENTS);
});
+
+ test('Should hide FIRST_APPROVED/FIRST_APPROVER for expense reports when no report has approvers', () => {
+ const visibleColumns = [CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED, CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER, CONST.SEARCH.TABLE_COLUMNS.TOTAL];
+ const data: OnyxTypes.SearchResults['data'] = {
+ ...searchResults.data,
+ [`report_${reportID2}`]: {...searchResults.data[`report_${reportID2}`], approvers: undefined},
+ };
+
+ const columns = SearchUIUtils.getColumnsToShow({
+ currentAccountID: adminAccountID,
+ data,
+ visibleColumns,
+ type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT,
+ });
+
+ expect(columns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED);
+ expect(columns).not.toContain(CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER);
+ });
+
+ test('Should show FIRST_APPROVED/FIRST_APPROVER for expense reports when at least one report has approvers', () => {
+ const visibleColumns = [CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED, CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER, CONST.SEARCH.TABLE_COLUMNS.TOTAL];
+ const data: OnyxTypes.SearchResults['data'] = {
+ ...searchResults.data,
+ [`report_${reportID2}`]: {
+ ...searchResults.data[`report_${reportID2}`],
+ approvers: [{email: approverEmail, accountID: approverAccountID, date: '2024-12-22 09:30:00'}],
+ },
+ };
+
+ const columns = SearchUIUtils.getColumnsToShow({
+ currentAccountID: adminAccountID,
+ data,
+ visibleColumns,
+ type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT,
+ });
+
+ expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVED);
+ expect(columns).toContain(CONST.SEARCH.TABLE_COLUMNS.FIRST_APPROVER);
+ });
});
describe('Test getCustomColumns', () => {