Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1159,6 +1159,7 @@ const ONYXKEYS = {
TODOS: 'todos',
RAM_ONLY_SORTED_REPORT_ACTIONS: 'sortedReportActions',
OPEN_AND_SUBMITTED_REPORTS_BY_POLICY_ID: 'openAndSubmittedReportsByPolicyID',
FLAGGED_EXPENSES: 'flaggedExpenses',
},

/** Stores HybridApp specific state required to interoperate with OldDot */
Expand Down Expand Up @@ -1637,6 +1638,7 @@ type OnyxDerivedValuesMapping = {
[ONYXKEYS.DERIVED.TODOS]: OnyxTypes.TodosDerivedValue;
[ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: OnyxTypes.SortedReportActionsDerivedValue;
[ONYXKEYS.DERIVED.OPEN_AND_SUBMITTED_REPORTS_BY_POLICY_ID]: OnyxTypes.OpenAndSubmittedReportsByPolicyIDDerivedValue;
[ONYXKEYS.DERIVED.FLAGGED_EXPENSES]: OnyxTypes.FlaggedExpensesDerivedValue;
};

type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping & OnyxDerivedValuesMapping;
Expand Down
1 change: 1 addition & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1037,6 +1037,7 @@ const translations = {
menuItemDescription: 'See what Expensify can do in 2 min',
},
forYouSection: {
reviewExpenses: ({count}: {count: number}) => `Review ${count} ${count === 1 ? 'expense' : 'expenses'}`,
submit: ({count}: {count: number}) => `Submit ${count} ${count === 1 ? 'report' : 'reports'}`,
approve: ({count}: {count: number}) => `Approve ${count} ${count === 1 ? 'report' : 'reports'}`,
pay: ({count}: {count: number}) => `Pay ${count} ${count === 1 ? 'report' : 'reports'}`,
Expand Down
2 changes: 2 additions & 0 deletions src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {ValueOf} from 'type-fest';
import ONYXKEYS from '@src/ONYXKEYS';
import cardFeedErrorsConfig from './configs/cardFeedErrors';
import flaggedExpensesConfig from './configs/flaggedExpenses';
import nonPersonalAndWorkspaceCardListConfig from './configs/nonPersonalAndWorkspaceCardList';
import openAndSubmittedReportsByPolicyIDConfig from './configs/openAndSubmittedReportsByPolicyID';
import outstandingReportsByPolicyIDConfig from './configs/outstandingReportsByPolicyID';
Expand All @@ -27,6 +28,7 @@ const ONYX_DERIVED_VALUES = {
[ONYXKEYS.DERIVED.TODOS]: todosConfig,
[ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: sortedReportActionsConfig,
[ONYXKEYS.DERIVED.OPEN_AND_SUBMITTED_REPORTS_BY_POLICY_ID]: openAndSubmittedReportsByPolicyIDConfig,
[ONYXKEYS.DERIVED.FLAGGED_EXPENSES]: flaggedExpensesConfig,
} as const satisfies {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[Key in ValueOf<typeof ONYXKEYS.DERIVED>]: OnyxDerivedValueConfig<Key, any>;
Expand Down
87 changes: 87 additions & 0 deletions src/libs/actions/OnyxDerived/configs/flaggedExpenses.ts
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) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore non-review warnings without showInReview

For cached/server violations where type is warning or notice and showInReview is omitted, this check treats them as reviewable because it only excludes showInReview === false. Other review visibility logic in the app only considers warnings/notices actionable when showInReview is true, so users can get a Review X expenses row for warnings that are not actually shown in review. Please gate warnings/notices on showInReview === true while still allowing real violation types.

Useful? React with 👍 / 👎.

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};
},
});
62 changes: 52 additions & 10 deletions src/pages/home/ForYouSection/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
}

Expand All @@ -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,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add reviewExpenses to every locale

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 homePage.forYouSection.reviewExpenses to en.ts. IntlStore.get() does not fall back to English for missing locale keys, so this path throws in development and shows/logs the raw missing key in production/staging. Please add the matching key to the other src/languages/* files before rendering this row.

Useful? React with 👍 / 👎.

handler: createReviewExpensesHandler(flaggedExpensesReview.firstReportID),
},
{
key: 'submit',
count: submitCount,
Expand Down Expand Up @@ -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 = () => (
Expand Down
47 changes: 45 additions & 2 deletions src/selectors/Todos.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {shallowEqual} from 'fast-equals';
import type {OnyxEntry} from 'react-native-onyx';
import CONST from '@src/CONST';
import type {TodosDerivedValue} from '@src/types/onyx';
import type {FlaggedExpensesDerivedValue, TodosDerivedValue} from '@src/types/onyx';

const EMPTY_TODOS_SINGLE_REPORT_IDS = Object.freeze({
[CONST.SEARCH.SEARCH_KEYS.SUBMIT]: undefined,
Expand Down Expand Up @@ -66,5 +66,48 @@ const todosSingleReportIDsSelector = (todos: OnyxEntry<TodosDerivedValue>) => {
return newValue;
};

type FlaggedExpensesReview = {
/** Total number of flagged expenses */
count: number;
/** Transaction ID of the first flagged expense, used to seed the carousel handoff */
firstTransactionID: string | undefined;
/** Parent report ID of the first flagged expense */
firstReportID: string | undefined;
};

const EMPTY_FLAGGED_EXPENSES_REVIEW: FlaggedExpensesReview = Object.freeze({
count: 0,
firstTransactionID: undefined,
firstReportID: undefined,
}) as FlaggedExpensesReview;

// Manual memoization mirrors `todosSingleReportIDsSelector`: ForYouSection's useMemo dependency
// list consumes the returned object as a whole, so we need referential stability across
// equivalent derived snapshots to avoid cascading re-renders on every Onyx churn.
let previousFlaggedExpensesReview: FlaggedExpensesReview = EMPTY_FLAGGED_EXPENSES_REVIEW;

const flaggedExpensesReviewSelector = (flaggedExpensesValue: OnyxEntry<FlaggedExpensesDerivedValue>): FlaggedExpensesReview => {
const flaggedExpenses = flaggedExpensesValue?.flaggedExpenses;
if (!flaggedExpenses || flaggedExpenses.length === 0) {
previousFlaggedExpensesReview = EMPTY_FLAGGED_EXPENSES_REVIEW;
return EMPTY_FLAGGED_EXPENSES_REVIEW;
}

const first = flaggedExpenses.at(0);
const newValue: FlaggedExpensesReview = {
count: flaggedExpenses.length,
firstTransactionID: first?.transactionID,
firstReportID: first?.reportID,
};

if (shallowEqual(previousFlaggedExpensesReview, newValue)) {
return previousFlaggedExpensesReview;
}

previousFlaggedExpensesReview = newValue;
return newValue;
};

export default todosReportCountsSelector;
export {todosSingleReportIDsSelector, EMPTY_TODOS_SINGLE_REPORT_IDS};
export {EMPTY_FLAGGED_EXPENSES_REVIEW, EMPTY_TODOS_SINGLE_REPORT_IDS, flaggedExpensesReviewSelector, todosSingleReportIDsSelector};
export type {FlaggedExpensesReview};
18 changes: 18 additions & 0 deletions src/types/onyx/DerivedValues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,23 @@ type TodosDerivedValue = {
transactionsByReportID: Record<string, Transaction[]>;
};

/**
* The derived value for flagged expenses.
*
* Aggregates transactions on the current user's `OPEN`/`OPEN` expense reports that have
* at least one transaction-level violation (excluding `showInReview === false` entries and
* `REPORT_VIOLATIONS.FIELD_REQUIRED` entries that may slip into the collection).
*/
type FlaggedExpensesDerivedValue = {
/** Ordered list of flagged transactions with their parent report IDs */
flaggedExpenses: Array<{
/** ID of the flagged transaction */
transactionID: string;
/** ID of the parent expense report */
reportID: string;
}>;
};

/**
* The derived value for sorted report actions, last report actions, and cached transaction thread report IDs.
*/
Expand Down Expand Up @@ -297,6 +314,7 @@ export type {
CardFeedErrorsDerivedValue,
TodosDerivedValue,
TodoMetadata,
FlaggedExpensesDerivedValue,
CardFeedErrorsObject,
CardFeedErrorState,
CardFeedErrors,
Expand Down
2 changes: 2 additions & 0 deletions src/types/onyx/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import type {CurrencyList} from './Currency';
import type CustomStatusDraft from './CustomStatusDraft';
import type {
CardFeedErrorsDerivedValue,
FlaggedExpensesDerivedValue,
NonPersonalAndWorkspaceCardListDerivedValue,
OpenAndSubmittedReportsByPolicyIDDerivedValue,
OutstandingReportsByPolicyIDDerivedValue,
Expand Down Expand Up @@ -395,6 +396,7 @@ export type {
CardFeedErrorsDerivedValue,
TodosDerivedValue,
TodoMetadata,
FlaggedExpensesDerivedValue,
ScheduleCallDraft,
ValidateUserAndGetAccessiblePolicies,
VacationDelegate,
Expand Down
Loading
Loading