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 = {