diff --git a/src/CONST.ts b/src/CONST.ts index 97cd7fa56811..eccd28aea810 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5979,6 +5979,8 @@ const CONST = { }, BULK_ACTION_TYPES: { EXPORT: 'export', + APPROVE: 'approve', + PAY: 'pay', HOLD: 'hold', UNHOLD: 'unhold', DELETE: 'delete', diff --git a/src/components/Search/SearchContext.tsx b/src/components/Search/SearchContext.tsx index f3206868d556..7be80589ac50 100644 --- a/src/components/Search/SearchContext.tsx +++ b/src/components/Search/SearchContext.tsx @@ -1,6 +1,8 @@ import React, {useCallback, useContext, useMemo, useState} from 'react'; import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; +import {isMoneyRequestReport} from '@libs/ReportUtils'; import * as SearchUIUtils from '@libs/SearchUIUtils'; +import CONST from '@src/CONST'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import type {SearchContext, SelectedTransactions} from './types'; @@ -22,13 +24,12 @@ const Context = React.createContext(defaultSearchContext); function getReportsFromSelectedTransactions(data: TransactionListItemType[] | ReportListItemType[] | ReportActionListItemType[], selectedTransactions: SelectedTransactions) { return (data ?? []) .filter( - (item) => - !SearchUIUtils.isTransactionListItemType(item) && - !SearchUIUtils.isReportActionListItemType(item) && - item.reportID && + (item): item is ReportListItemType => + SearchUIUtils.isReportListItemType(item) && + isMoneyRequestReport(item) && item?.transactions?.every((transaction: {keyForList: string | number}) => selectedTransactions[transaction.keyForList]?.isSelected), ) - .map((item) => item.reportID); + .map((item) => ({reportID: item.reportID, action: item.action ?? CONST.SEARCH.ACTION_TYPES.VIEW, total: item.total ?? 0, policyID: item.policyID ?? ''})); } function SearchContextProvider({children}: ChildrenProps) { diff --git a/src/components/Search/SearchPageHeader.tsx b/src/components/Search/SearchPageHeader.tsx index e7d2e7c39042..f4feaef632b7 100644 --- a/src/components/Search/SearchPageHeader.tsx +++ b/src/components/Search/SearchPageHeader.tsx @@ -26,7 +26,7 @@ import ROUTES from '@src/ROUTES'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; import {useSearchContext} from './SearchContext'; import SearchPageHeaderInput from './SearchPageHeaderInput'; -import type {SearchQueryJSON} from './types'; +import type {PaymentData, SearchQueryJSON} from './types'; type SearchPageHeaderProps = {queryJSON: SearchQueryJSON}; @@ -50,6 +50,7 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { const [currencyList = {}] = useOnyx(ONYXKEYS.CURRENCY_LIST); const [policyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); const [policyTagsLists] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); + const [lastPaymentMethods = {}] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD); const [isDeleteExpensesConfirmModalVisible, setIsDeleteExpensesConfirmModalVisible] = useState(false); const [isOfflineModalVisible, setIsOfflineModalVisible] = useState(false); const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false); @@ -79,6 +80,71 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { } const options: Array> = []; + const isAnyTransactionOnHold = Object.values(selectedTransactions).some((transaction) => transaction.isHeld); + + const shouldShowApproveOption = + !isOffline && + !isAnyTransactionOnHold && + (selectedReports.length + ? selectedReports.every((report) => report.action === CONST.SEARCH.ACTION_TYPES.APPROVE) + : selectedTransactionsKeys.every((id) => selectedTransactions[id].action === CONST.SEARCH.ACTION_TYPES.APPROVE)); + + if (shouldShowApproveOption) { + options.push({ + icon: Expensicons.ThumbsUp, + text: translate('search.bulkActions.approve'), + value: CONST.SEARCH.BULK_ACTION_TYPES.APPROVE, + shouldCloseModalOnSelect: true, + onSelected: () => { + if (isOffline) { + setIsOfflineModalVisible(true); + return; + } + + const transactionIDList = selectedReports.length ? undefined : Object.keys(selectedTransactions); + const reportIDList = !selectedReports.length + ? Object.values(selectedTransactions).map((transaction) => transaction.reportID) + : selectedReports?.filter((report) => !!report).map((report) => report.reportID) ?? []; + SearchActions.approveMoneyRequestOnSearch(hash, reportIDList, transactionIDList); + }, + }); + } + + const shouldShowPayOption = + !isOffline && + !isAnyTransactionOnHold && + (selectedReports.length + ? selectedReports.every((report) => report.action === CONST.SEARCH.ACTION_TYPES.PAY && report.policyID && lastPaymentMethods[report.policyID]) + : selectedTransactionsKeys.every( + (id) => selectedTransactions[id].action === CONST.SEARCH.ACTION_TYPES.PAY && selectedTransactions[id].policyID && lastPaymentMethods[selectedTransactions[id].policyID], + )); + + if (shouldShowPayOption) { + options.push({ + icon: Expensicons.MoneyBag, + text: translate('search.bulkActions.pay'), + value: CONST.SEARCH.BULK_ACTION_TYPES.PAY, + shouldCloseModalOnSelect: true, + onSelected: () => { + if (isOffline) { + setIsOfflineModalVisible(true); + return; + } + const transactionIDList = selectedReports.length ? undefined : Object.keys(selectedTransactions); + const paymentData = ( + selectedReports.length + ? selectedReports.map((report) => ({reportID: report.reportID, amount: report.total, paymentType: lastPaymentMethods[report.policyID]})) + : Object.values(selectedTransactions).map((transaction) => ({ + reportID: transaction.reportID, + amount: transaction.amount, + paymentType: lastPaymentMethods[transaction.policyID], + })) + ) as PaymentData[]; + + SearchActions.payMoneyRequestOnSearch(hash, paymentData, transactionIDList); + }, + }); + } options.push({ icon: Expensicons.Download, @@ -91,7 +157,7 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { return; } - const reportIDList = selectedReports.filter((report): report is string => !!report) ?? []; + const reportIDList = selectedReports?.filter((report) => !!report).map((report) => report.reportID) ?? []; SearchActions.exportSearchItemsToCSV( {query: status, jsonQuery: JSON.stringify(queryJSON), reportIDList, transactionIDList: selectedTransactionsKeys, policyIDs: [activeWorkspaceID ?? '']}, () => { @@ -190,6 +256,7 @@ function SearchPageHeader({queryJSON}: SearchPageHeaderProps) { activeWorkspaceID, selectedReports, styles.textWrap, + lastPaymentMethods, ]); if (shouldUseNarrowLayout) { diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index f7ebeb6907fe..ea712ddaafd6 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -24,6 +24,7 @@ import isSearchTopmostCentralPane from '@libs/Navigation/isSearchTopmostCentralP import * as ReportUtils from '@libs/ReportUtils'; import * as SearchQueryUtils from '@libs/SearchQueryUtils'; import * as SearchUIUtils from '@libs/SearchUIUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; import Navigation from '@navigation/Navigation'; import type {AuthScreensParamList} from '@navigation/types'; import EmptySearchView from '@pages/Search/EmptySearchView'; @@ -49,7 +50,20 @@ const searchHeaderHeight = 54; const sortableSearchStatuses: SearchStatus[] = [CONST.SEARCH.STATUS.EXPENSE.ALL]; function mapTransactionItemToSelectedEntry(item: TransactionListItemType): [string, SelectedTransactionInfo] { - return [item.keyForList, {isSelected: true, canDelete: item.canDelete, canHold: item.canHold, canUnhold: item.canUnhold, action: item.action}]; + return [ + item.keyForList, + { + isSelected: true, + canDelete: item.canDelete, + canHold: item.canHold, + isHeld: TransactionUtils.isOnHold(item), + canUnhold: item.canUnhold, + action: item.action, + reportID: item.reportID, + policyID: item.policyID, + amount: item.modifiedAmount ?? item.amount, + }, + ]; } function mapToTransactionItemWithSelectionInfo(item: TransactionListItemType, selectedTransactions: SelectedTransactions, canSelectMultiple: boolean, shouldAnimateInHighlight: boolean) { @@ -83,7 +97,20 @@ function prepareTransactionsList(item: TransactionListItemType, selectedTransact return transactions; } - return {...selectedTransactions, [item.keyForList]: {isSelected: true, canDelete: item.canDelete, canHold: item.canHold, canUnhold: item.canUnhold, action: item.action}}; + return { + ...selectedTransactions, + [item.keyForList]: { + isSelected: true, + canDelete: item.canDelete, + canHold: item.canHold, + isHeld: TransactionUtils.isOnHold(item), + canUnhold: item.canUnhold, + action: item.action, + reportID: item.reportID, + policyID: item.policyID, + amount: item.modifiedAmount ?? item.amount, + }, + }; } function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentContainerStyle}: SearchProps) { @@ -225,9 +252,13 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo newTransactionList[transaction.transactionID] = { action: transaction.action, canHold: transaction.canHold, + isHeld: TransactionUtils.isOnHold(transaction), canUnhold: transaction.canUnhold, isSelected: selectedTransactions[transaction.transactionID].isSelected, canDelete: transaction.canDelete, + reportID: transaction.reportID, + policyID: transaction.policyID, + amount: transaction.modifiedAmount ?? transaction.amount, }; }); } else { @@ -242,9 +273,13 @@ function Search({queryJSON, onSearchListScroll, isSearchScreenFocused, contentCo newTransactionList[transaction.transactionID] = { action: transaction.action, canHold: transaction.canHold, + isHeld: TransactionUtils.isOnHold(transaction), canUnhold: transaction.canUnhold, isSelected: selectedTransactions[transaction.transactionID].isSelected, canDelete: transaction.canDelete, + reportID: transaction.reportID, + policyID: transaction.policyID, + amount: transaction.modifiedAmount ?? transaction.amount, }; }); }); diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 130ad7ae6f6e..af72b7f6bcc1 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -1,7 +1,7 @@ import type {ValueOf} from 'type-fest'; import type {ReportActionListItemType, ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; import type CONST from '@src/CONST'; -import type {SearchDataTypes, SearchReport} from '@src/types/onyx/SearchResults'; +import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; /** Model of the selected transaction */ type SelectedTransactionInfo = { @@ -14,16 +14,36 @@ type SelectedTransactionInfo = { /** If the transaction can be put on hold */ canHold: boolean; + /** Whether the transaction is currently held */ + isHeld: boolean; + /** If the transaction can be removed from hold */ canUnhold: boolean; /** The action that can be performed for the transaction */ - action: string; + action: ValueOf; + + /** The reportID of the transaction */ + reportID: string; + + /** The policyID tied to the report the transaction is reported on */ + policyID: string; + + /** The transaction amount */ + amount: number; }; -/** Model of selected results */ +/** Model of selected transactons */ type SelectedTransactions = Record; +/** Model of selected reports */ +type SelectedReports = { + reportID: string; + policyID: string; + action: ValueOf; + total: number; +}; + /** Model of payment data used by Search bulk actions */ type PaymentData = { reportID: string; @@ -42,7 +62,7 @@ type SearchStatus = ExpenseSearchStatus | InvoiceSearchStatus | TripSearchStatus type SearchContext = { currentSearchHash: number; selectedTransactions: SelectedTransactions; - selectedReports: Array; + selectedReports: SelectedReports[]; setCurrentSearchHash: (hash: number) => void; setSelectedTransactions: (selectedTransactions: SelectedTransactions, data: TransactionListItemType[] | ReportListItemType[] | ReportActionListItemType[]) => void; clearSelectedTransactions: (hash?: number) => void; diff --git a/src/components/SelectionList/Search/ReportListItem.tsx b/src/components/SelectionList/Search/ReportListItem.tsx index b03932db2532..18352c6e0596 100644 --- a/src/components/SelectionList/Search/ReportListItem.tsx +++ b/src/components/SelectionList/Search/ReportListItem.tsx @@ -133,6 +133,7 @@ function ReportListItem({ onFocus={onFocus} onLongPressRow={onLongPressRow} shouldSyncFocus={shouldSyncFocus} + isLoading={reportItem.isActionLoading} /> ); } diff --git a/src/components/SelectionList/Search/TransactionListItem.tsx b/src/components/SelectionList/Search/TransactionListItem.tsx index a23a7048644e..42bf05179bdb 100644 --- a/src/components/SelectionList/Search/TransactionListItem.tsx +++ b/src/components/SelectionList/Search/TransactionListItem.tsx @@ -22,6 +22,7 @@ function TransactionListItem({ onFocus, onLongPressRow, shouldSyncFocus, + isLoading, }: TransactionListItemProps) { const transactionItem = item as unknown as TransactionListItemType; const styles = useThemeStyles(); @@ -85,7 +86,7 @@ function TransactionListItem({ canSelectMultiple={!!canSelectMultiple} isButtonSelected={item.isSelected} shouldShowTransactionCheckbox={false} - isLoading={transactionItem.isActionLoading} + isLoading={isLoading ?? transactionItem.isActionLoading} /> ); diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 4297ec71ff79..454ec259a22d 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -331,7 +331,10 @@ type RadioListItemProps = ListItemProps; type TableListItemProps = ListItemProps; -type TransactionListItemProps = ListItemProps; +type TransactionListItemProps = ListItemProps & { + /** Whether the item's action is loading */ + isLoading?: boolean; +}; type ReportListItemProps = ListItemProps; diff --git a/src/languages/en.ts b/src/languages/en.ts index a5ace3ac9c02..b2bc8e820448 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4562,6 +4562,8 @@ const translations = { savedSearchesMenuItemTitle: 'Saved', groupedExpenses: 'grouped expenses', bulkActions: { + approve: 'Approve', + pay: 'Pay', delete: 'Delete', hold: 'Hold', unhold: 'Unhold', diff --git a/src/languages/es.ts b/src/languages/es.ts index 51ecb6da0087..7a7d03fbafcc 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4611,6 +4611,8 @@ const translations = { deleteSavedSearchConfirm: '¿Estás seguro de que quieres eliminar esta búsqueda?', groupedExpenses: 'gastos agrupados', bulkActions: { + approve: 'Aprobar', + pay: 'Pagar', delete: 'Eliminar', hold: 'Bloquear', unhold: 'Desbloquear', diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 44b5bb7f7ce9..bf481cffcf73 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -45,9 +45,9 @@ Onyx.connect({ }); function handleActionButtonPress(hash: number, item: TransactionListItemType | ReportListItemType, goToItem: () => void) { - // The transactionID is needed to handle actions taken on `status:all` where transactions on single expense reports can be approved/paid. + // The transactionIDList is needed to handle actions taken on `status:all` where transactions on single expense reports can be approved/paid. // We need the transactionID to display the loading indicator for that list item's action. - const transactionID = isTransactionListItemType(item) ? item.transactionID : undefined; + const transactionID = isTransactionListItemType(item) ? [item.transactionID] : undefined; const data = (allSnapshots?.[`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`]?.data ?? {}) as SearchResults['data']; const allReportTransactions = ( isReportListItemType(item) @@ -85,7 +85,7 @@ function getPayActionCallback(hash: number, item: TransactionListItemType | Repo const report = (allSnapshots?.[`${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`]?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`] ?? {}) as SearchReport; const amount = Math.abs((report?.total ?? 0) - (report?.nonReimbursableTotal ?? 0)); - const transactionID = isTransactionListItemType(item) ? item.transactionID : undefined; + const transactionID = isTransactionListItemType(item) ? [item.transactionID] : undefined; if (lastPolicyPaymentMethod === CONST.IOU.PAYMENT_TYPE.ELSEWHERE) { payMoneyRequestOnSearch(hash, [{reportID: item.reportID, amount, paymentType: lastPolicyPaymentMethod}], transactionID); @@ -242,33 +242,36 @@ function holdMoneyRequestOnSearch(hash: number, transactionIDList: string[], com API.write(WRITE_COMMANDS.HOLD_MONEY_REQUEST_ON_SEARCH, {hash, transactionIDList, comment}, {optimisticData, finallyData}); } -function approveMoneyRequestOnSearch(hash: number, reportIDList: string[], transactionID?: string) { +function approveMoneyRequestOnSearch(hash: number, reportIDList: string[], transactionIDList?: string[]) { const createActionLoadingData = (isLoading: boolean): OnyxUpdate[] => [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, value: { - data: transactionID - ? {[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: {isActionLoading: isLoading}} + data: transactionIDList + ? (Object.fromEntries( + transactionIDList.map((transactionID) => [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {isActionLoading: isLoading}]), + ) as Partial) : (Object.fromEntries(reportIDList.map((reportID) => [`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {isActionLoading: isLoading}])) as Partial), }, }, ]; - const optimisticData: OnyxUpdate[] = createActionLoadingData(true); const finallyData: OnyxUpdate[] = createActionLoadingData(false); API.write(WRITE_COMMANDS.APPROVE_MONEY_REQUEST_ON_SEARCH, {hash, reportIDList}, {optimisticData, finallyData}); } -function payMoneyRequestOnSearch(hash: number, paymentData: PaymentData[], transactionID?: string) { +function payMoneyRequestOnSearch(hash: number, paymentData: PaymentData[], transactionIDList?: string[]) { const createActionLoadingData = (isLoading: boolean): OnyxUpdate[] => [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}`, value: { - data: transactionID - ? {[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: {isActionLoading: isLoading}} + data: transactionIDList + ? (Object.fromEntries( + transactionIDList.map((transactionID) => [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {isActionLoading: isLoading}]), + ) as Partial) : (Object.fromEntries(paymentData.map((item) => [`${ONYXKEYS.COLLECTION.REPORT}${item.reportID}`, {isActionLoading: isLoading}])) as Partial), }, },