-
Notifications
You must be signed in to change notification settings - Fork 3.9k
[Home Page] Add Review X expenses action to For you to surface expenses against company expense policy #91983
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; | ||
| import CONST from '@src/CONST'; | ||
| import ONYXKEYS from '@src/ONYXKEYS'; | ||
| import type {FlaggedExpensesDerivedValue} from '@src/types/onyx'; | ||
| import type Report from '@src/types/onyx/Report'; | ||
| import type TransactionViolations from '@src/types/onyx/TransactionViolation'; | ||
|
|
||
| type FlaggedExpenseEntry = FlaggedExpensesDerivedValue['flaggedExpenses'][number]; | ||
|
|
||
| const EMPTY_VALUE: FlaggedExpensesDerivedValue = {flaggedExpenses: []}; | ||
|
|
||
| /** | ||
| * Returns true when this report is an OPEN/OPEN expense report owned by the current user. | ||
| * | ||
| * `currentUserAccountID` is required. Callers should pass `session?.accountID ?? CONST.DEFAULT_NUMBER_ID` | ||
| * so that the ownership check fails closed when the session is not yet populated (no real ownerAccountID is 0). | ||
| */ | ||
| function isCurrentUserOpenExpenseReport(report: Report | null | undefined, currentUserAccountID: number): boolean { | ||
| if (!report) { | ||
| return false; | ||
| } | ||
| if (report.type !== CONST.REPORT.TYPE.EXPENSE) { | ||
| return false; | ||
| } | ||
| if (report.ownerAccountID !== currentUserAccountID) { | ||
| return false; | ||
| } | ||
| return report.stateNum === CONST.REPORT.STATE_NUM.OPEN && report.statusNum === CONST.REPORT.STATUS_NUM.OPEN; | ||
| } | ||
|
|
||
| /** | ||
| * Returns true when the given violation list has at least one entry that should surface in the | ||
| * `Review X expenses` row: a transaction-level violation that the user can act on. Violations | ||
| * marked `showInReview === false` and report-field violations (`fieldRequired`) are excluded. | ||
| */ | ||
| function hasReviewableViolation(violations: TransactionViolations | null | undefined): boolean { | ||
| if (!violations || violations.length === 0) { | ||
| return false; | ||
| } | ||
|
|
||
| return violations.some((violation) => { | ||
| if (!violation) { | ||
| return false; | ||
| } | ||
| if (violation.showInReview === false) { | ||
| return false; | ||
| } | ||
| if (violation.name === CONST.REPORT_VIOLATIONS.FIELD_REQUIRED) { | ||
| return false; | ||
| } | ||
| return true; | ||
| }); | ||
| } | ||
|
|
||
| export default createOnyxDerivedValueConfig({ | ||
| key: ONYXKEYS.DERIVED.FLAGGED_EXPENSES, | ||
| dependencies: [ONYXKEYS.COLLECTION.REPORT, ONYXKEYS.COLLECTION.TRANSACTION, ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, ONYXKEYS.SESSION], | ||
| compute: ([allReports, allTransactions, allTransactionViolations, session]) => { | ||
| if (!allReports || !allTransactions) { | ||
| return EMPTY_VALUE; | ||
| } | ||
|
|
||
| const currentUserAccountID = session?.accountID ?? CONST.DEFAULT_NUMBER_ID; | ||
| const flaggedExpenses: FlaggedExpenseEntry[] = []; | ||
|
|
||
| for (const transactionKey of Object.keys(allTransactions)) { | ||
| const transaction = allTransactions[transactionKey]; | ||
| if (!transaction?.transactionID || !transaction.reportID) { | ||
| continue; | ||
| } | ||
|
|
||
| const report = allReports[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`]; | ||
| if (!isCurrentUserOpenExpenseReport(report, currentUserAccountID)) { | ||
| continue; | ||
| } | ||
|
|
||
| const violations = allTransactionViolations?.[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`]; | ||
| if (!hasReviewableViolation(violations)) { | ||
| continue; | ||
| } | ||
|
|
||
| flaggedExpenses.push({transactionID: transaction.transactionID, reportID: transaction.reportID}); | ||
| } | ||
|
|
||
| return {flaggedExpenses}; | ||
| }, | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,7 +15,7 @@ import CONST from '@src/CONST'; | |
| import ONYXKEYS from '@src/ONYXKEYS'; | ||
| import ROUTES from '@src/ROUTES'; | ||
| import {accountIDSelector} from '@src/selectors/Session'; | ||
| import todosReportCountsSelector, {EMPTY_TODOS_SINGLE_REPORT_IDS, todosSingleReportIDsSelector} from '@src/selectors/Todos'; | ||
| import todosReportCountsSelector, {EMPTY_FLAGGED_EXPENSES_REVIEW, EMPTY_TODOS_SINGLE_REPORT_IDS, flaggedExpensesReviewSelector, todosSingleReportIDsSelector} from '@src/selectors/Todos'; | ||
| import EmptyState from './EmptyState'; | ||
| import ForYouSkeleton from './ForYouSkeleton'; | ||
|
|
||
|
|
@@ -29,24 +29,33 @@ function ForYouSection() { | |
| const [isLoadingReportData = false] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA); | ||
| const [reportCounts = CONST.EMPTY_TODOS_REPORT_COUNTS] = useOnyx(ONYXKEYS.DERIVED.TODOS, {selector: todosReportCountsSelector}); | ||
| const [singleReportIDs = EMPTY_TODOS_SINGLE_REPORT_IDS] = useOnyx(ONYXKEYS.DERIVED.TODOS, {selector: todosSingleReportIDsSelector}); | ||
| const [flaggedExpensesReview = EMPTY_FLAGGED_EXPENSES_REVIEW] = useOnyx(ONYXKEYS.DERIVED.FLAGGED_EXPENSES, {selector: flaggedExpensesReviewSelector}); | ||
|
|
||
| const icons = useMemoizedLazyExpensifyIcons(['MoneyBag', 'Send', 'ThumbsUp', 'Export']); | ||
| const icons = useMemoizedLazyExpensifyIcons(['Exclamation', 'MoneyBag', 'Send', 'ThumbsUp', 'Export']); | ||
|
|
||
| const submitCount = reportCounts?.[CONST.SEARCH.SEARCH_KEYS.SUBMIT] ?? 0; | ||
| const approveCount = reportCounts?.[CONST.SEARCH.SEARCH_KEYS.APPROVE] ?? 0; | ||
| const payCount = reportCounts?.[CONST.SEARCH.SEARCH_KEYS.PAY] ?? 0; | ||
| const exportCount = reportCounts?.[CONST.SEARCH.SEARCH_KEYS.EXPORT] ?? 0; | ||
| const flaggedExpensesCount = flaggedExpensesReview.count; | ||
|
|
||
| const hasAnyTodos = submitCount > 0 || approveCount > 0 || payCount > 0 || exportCount > 0; | ||
| const hasAnyTodos = flaggedExpensesCount > 0 || submitCount > 0 || approveCount > 0 || payCount > 0 || exportCount > 0; | ||
|
|
||
| const navigateToReport = useCallback( | ||
| (reportID: string) => { | ||
| if (shouldUseNarrowLayout) { | ||
| Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID, undefined, undefined, ROUTES.HOME)); | ||
| return; | ||
| } | ||
| Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID, backTo: ROUTES.HOME})); | ||
| }, | ||
| [shouldUseNarrowLayout], | ||
| ); | ||
|
|
||
| const createNavigationHandler = useCallback( | ||
| (action: string, queryParams: Record<string, unknown>, reportID?: string) => () => { | ||
| if (reportID) { | ||
| if (shouldUseNarrowLayout) { | ||
| Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID, undefined, undefined, ROUTES.HOME)); | ||
| } else { | ||
| Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID, backTo: ROUTES.HOME})); | ||
| } | ||
| navigateToReport(reportID); | ||
| return; | ||
| } | ||
|
|
||
|
|
@@ -60,12 +69,29 @@ function ForYouSection() { | |
| }), | ||
| ); | ||
| }, | ||
| [shouldUseNarrowLayout], | ||
| [navigateToReport], | ||
| ); | ||
|
|
||
| const createReviewExpensesHandler = useCallback( | ||
| (firstReportID: string | undefined) => () => { | ||
| if (!firstReportID) { | ||
| return; | ||
| } | ||
| navigateToReport(firstReportID); | ||
| }, | ||
| [navigateToReport], | ||
| ); | ||
|
|
||
| const todoItems = useMemo( | ||
| () => | ||
| [ | ||
| { | ||
| key: 'reviewExpenses', | ||
| count: flaggedExpensesCount, | ||
| icon: icons.Exclamation, | ||
| translationKey: 'homePage.forYouSection.reviewExpenses' as const, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a user is running any non-English locale and has flagged expenses, this new translation key is looked up from that locale, but the commit only adds Useful? React with 👍 / 👎. |
||
| handler: createReviewExpensesHandler(flaggedExpensesReview.firstReportID), | ||
| }, | ||
| { | ||
| key: 'submit', | ||
| count: submitCount, | ||
|
|
@@ -103,7 +129,23 @@ function ForYouSection() { | |
| ), | ||
| }, | ||
| ].filter((item) => item.count > 0), | ||
| [accountID, approveCount, createNavigationHandler, exportCount, icons.Export, icons.MoneyBag, icons.Send, icons.ThumbsUp, payCount, singleReportIDs, submitCount], | ||
| [ | ||
| accountID, | ||
| approveCount, | ||
| createNavigationHandler, | ||
| createReviewExpensesHandler, | ||
| exportCount, | ||
| flaggedExpensesCount, | ||
| flaggedExpensesReview.firstReportID, | ||
| icons.Exclamation, | ||
| icons.Export, | ||
| icons.MoneyBag, | ||
| icons.Send, | ||
| icons.ThumbsUp, | ||
| payCount, | ||
| singleReportIDs, | ||
| submitCount, | ||
| ], | ||
| ); | ||
|
|
||
| const renderTodoItems = () => ( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For cached/server violations where
typeiswarningornoticeandshowInReviewis omitted, this check treats them as reviewable because it only excludesshowInReview === false. Other review visibility logic in the app only considers warnings/notices actionable whenshowInReviewis true, so users can get aReview X expensesrow for warnings that are not actually shown in review. Please gate warnings/notices onshowInReview === truewhile still allowing realviolationtypes.Useful? React with 👍 / 👎.