From 3f28b7876b12711ee7521e94ba89cdef67af4864 Mon Sep 17 00:00:00 2001 From: Artem Makushov Date: Fri, 28 Nov 2025 02:18:05 +0100 Subject: [PATCH 01/99] initial bulk edit multiple --- src/CONST/index.ts | 1 + src/ROUTES.ts | 9 + src/SCREENS.ts | 9 + src/hooks/useSelectedTransactionsActions.ts | 17 +- src/languages/en.ts | 3 + src/languages/es.ts | 3 + src/libs/API/types.ts | 2 + .../ModalStackNavigators/index.tsx | 9 + src/libs/Navigation/linkingConfig/config.ts | 9 + src/libs/ReportUtils.ts | 34 +++ src/libs/actions/IOU.ts | 31 +++ .../SearchEditMultipleAmountPage.tsx | 54 +++++ .../SearchEditMultipleAttendeesPage.tsx | 57 +++++ .../SearchEditMultipleCategoryPage.tsx | 67 ++++++ .../SearchEditMultipleDatePage.tsx | 67 ++++++ .../SearchEditMultipleDescriptionPage.tsx | 72 +++++++ .../SearchEditMultipleMerchantPage.tsx | 71 +++++++ .../SearchEditMultiplePage.tsx | 201 ++++++++++++++++++ .../SearchEditMultipleReportPage.tsx | 129 +++++++++++ .../SearchEditMultipleTagPage.tsx | 80 +++++++ src/pages/Search/SearchEditMultiple/index.ts | 9 + 21 files changed, 933 insertions(+), 1 deletion(-) create mode 100644 src/pages/Search/SearchEditMultiple/SearchEditMultipleAmountPage.tsx create mode 100644 src/pages/Search/SearchEditMultiple/SearchEditMultipleAttendeesPage.tsx create mode 100644 src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage.tsx create mode 100644 src/pages/Search/SearchEditMultiple/SearchEditMultipleDatePage.tsx create mode 100644 src/pages/Search/SearchEditMultiple/SearchEditMultipleDescriptionPage.tsx create mode 100644 src/pages/Search/SearchEditMultiple/SearchEditMultipleMerchantPage.tsx create mode 100644 src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx create mode 100644 src/pages/Search/SearchEditMultiple/SearchEditMultipleReportPage.tsx create mode 100644 src/pages/Search/SearchEditMultiple/SearchEditMultipleTagPage.tsx create mode 100644 src/pages/Search/SearchEditMultiple/index.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 33d7f6211a23..f52962e72ce1 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6611,6 +6611,7 @@ const CONST = { TAG: 'tag', }, BULK_ACTION_TYPES: { + EDIT: 'edit', EXPORT: 'export', APPROVE: 'approve', PAY: 'pay', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f69d235c0700..c6ccab5724b2 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -111,6 +111,15 @@ const ROUTES = { }, TRANSACTION_HOLD_REASON_RHP: 'search/hold', MOVE_TRANSACTIONS_SEARCH_RHP: 'search/move-transactions', + SEARCH_EDIT_MULTIPLE_TRANSACTIONS_RHP: 'search/edit-multiple-transactions', + SEARCH_EDIT_MULTIPLE_AMOUNT_RHP: 'search/edit-multiple/amount', + SEARCH_EDIT_MULTIPLE_DESCRIPTION_RHP: 'search/edit-multiple/description', + SEARCH_EDIT_MULTIPLE_MERCHANT_RHP: 'search/edit-multiple/merchant', + SEARCH_EDIT_MULTIPLE_DATE_RHP: 'search/edit-multiple/date', + SEARCH_EDIT_MULTIPLE_CATEGORY_RHP: 'search/edit-multiple/category', + SEARCH_EDIT_MULTIPLE_TAG_RHP: 'search/edit-multiple/tag', + SEARCH_EDIT_MULTIPLE_ATTENDEES_RHP: 'search/edit-multiple/attendees', + SEARCH_EDIT_MULTIPLE_REPORT_RHP: 'search/edit-multiple/report', // This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated CONCIERGE: 'concierge', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 966881477b22..b7e74f510ec9 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -92,6 +92,15 @@ const SCREENS = { ADVANCED_FILTERS_IN_RHP: 'Search_Advanced_Filters_In_RHP', TRANSACTION_HOLD_REASON_RHP: 'Search_Transaction_Hold_Reason_RHP', TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP: 'Search_Transactions_Change_Report_RHP', + EDIT_MULTIPLE_TRANSACTIONS_RHP: 'Search_Edit_Multiple_Transactions_RHP', + EDIT_MULTIPLE_AMOUNT_RHP: 'Search_Edit_Multiple_Amount_RHP', + EDIT_MULTIPLE_DESCRIPTION_RHP: 'Search_Edit_Multiple_Description_RHP', + EDIT_MULTIPLE_MERCHANT_RHP: 'Search_Edit_Multiple_Merchant_RHP', + EDIT_MULTIPLE_DATE_RHP: 'Search_Edit_Multiple_Date_RHP', + EDIT_MULTIPLE_CATEGORY_RHP: 'Search_Edit_Multiple_Category_RHP', + EDIT_MULTIPLE_TAG_RHP: 'Search_Edit_Multiple_Tag_RHP', + EDIT_MULTIPLE_ATTENDEES_RHP: 'Search_Edit_Multiple_Attendees_RHP', + EDIT_MULTIPLE_REPORT_RHP: 'Search_Edit_Multiple_Report_RHP', }, SETTINGS: { ROOT: 'Settings_Root', diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index 7a830853d8b3..b01b2bbc7b4e 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -12,6 +12,7 @@ import { canDeleteCardTransactionByLiabilityType, canDeleteTransaction, canEditFieldOfMoneyRequest, + canEditMultipleTransactions, canHoldUnholdReportAction, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, getReportOrDraftReport, @@ -64,7 +65,7 @@ function useSelectedTransactionsActions({ const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH, {canBeMissing: true}); const [integrationsExportTemplates] = useOnyx(ONYXKEYS.NVP_INTEGRATION_SERVER_EXPORT_TEMPLATES, {canBeMissing: true}); const [csvExportLayouts] = useOnyx(ONYXKEYS.NVP_CSV_EXPORT_LAYOUTS, {canBeMissing: true}); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Stopwatch', 'Trashcan', 'ArrowRight', 'Table', 'DocumentMerge', 'Export', 'ArrowCollapse'] as const); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Stopwatch', 'Trashcan', 'ArrowRight', 'Table', 'DocumentMerge', 'Export', 'ArrowCollapse', 'Pencil'] as const); const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(selectedTransactionIDs); const isReportArchived = useReportIsArchived(report?.reportID); const {deleteTransactions} = useDeleteTransactions({report, reportActions, policy}); @@ -156,6 +157,20 @@ function useSelectedTransactionsActions({ return []; } const options = []; + + const canEditMultiple = canEditMultipleTransactions(selectedTransactionsList); + + if (canEditMultiple) { + options.push({ + text: translate('search.bulkActions.editMultiple'), + icon: expensifyIcons.Pencil, + value: CONST.SEARCH.BULK_ACTION_TYPES.EDIT, + onSelected: () => { + Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_TRANSACTIONS_RHP); + }, + }); + } + const isMoneyRequestReport = isMoneyRequestReportUtils(report); const isReportReimbursed = report?.stateNum === CONST.REPORT.STATE_NUM.APPROVED && report?.statusNum === CONST.REPORT.STATUS_NUM.REIMBURSED; diff --git a/src/languages/en.ts b/src/languages/en.ts index 26c35982959a..30e588694442 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6452,6 +6452,9 @@ const translations = { savedSearchesMenuItemTitle: 'Saved', groupedExpenses: 'grouped expenses', bulkActions: { + editMultiple: 'Edit multiple', + editMultipleTitle: 'Edit multiple expenses', + editMultipleDescription: "Changes will be set for all selected expenses and will override any previously set values. Just sayin'.", approve: 'Approve', pay: 'Pay', delete: 'Delete', diff --git a/src/languages/es.ts b/src/languages/es.ts index ecd3d8e7d74b..339a0e81d9f2 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6107,6 +6107,9 @@ ${amount} para ${merchant} - ${date}`, deleteSavedSearchConfirm: '¿Estás seguro de que quieres eliminar esta búsqueda?', groupedExpenses: 'gastos agrupados', bulkActions: { + editMultiple: 'Editar múltiples', + editMultipleTitle: 'Editar múltiples gastos', + editMultipleDescription: 'Los cambios se aplicarán a todos los gastos seleccionados y anularán cualquier valor previamente establecido. Solo digo.', approve: 'Aprobar', pay: 'Pagar', delete: 'Eliminar', diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index d266104f9691..670b4f8096ec 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -196,6 +196,7 @@ const WRITE_COMMANDS = { DELETE_REPORT_FIELD: 'RemoveReportField', SET_REPORT_NAME: 'RenameReport', COMPLETE_SPLIT_BILL: 'CompleteSplitBill', + UPDATE_MONEY_REQUEST: 'UpdateMoneyRequest', UPDATE_MONEY_REQUEST_ATTENDEES: 'UpdateMoneyRequestAttendees', UPDATE_MONEY_REQUEST_DATE: 'UpdateMoneyRequestDate', UPDATE_MONEY_REQUEST_REIMBURSABLE: 'UpdateMoneyRequestReimbursable', @@ -702,6 +703,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_REPORT_NAME]: Parameters.SetReportNameParams; [WRITE_COMMANDS.DELETE_REPORT_FIELD]: Parameters.DeleteReportFieldParams; [WRITE_COMMANDS.COMPLETE_SPLIT_BILL]: Parameters.CompleteSplitBillParams; + [WRITE_COMMANDS.UPDATE_MONEY_REQUEST]: Parameters.UpdateMoneyRequestParams; [WRITE_COMMANDS.UPDATE_MONEY_REQUEST_ATTENDEES]: Parameters.UpdateMoneyRequestParams; [WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DATE]: Parameters.UpdateMoneyRequestParams; [WRITE_COMMANDS.UPDATE_MONEY_REQUEST_MERCHANT]: Parameters.UpdateMoneyRequestParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 2b8a6bd45529..0b4a235fcedd 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -848,6 +848,15 @@ const SearchReportModalStackNavigator = createModalStackNavigator require('../../../../pages/Search/SearchHoldReasonPage').default, [SCREENS.SEARCH.TRANSACTION_HOLD_REASON_RHP]: () => require('../../../../pages/Search/SearchHoldReasonPage').default, [SCREENS.SEARCH.TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP]: () => require('../../../../pages/Search/SearchTransactionsChangeReport').default, + [SCREENS.SEARCH.EDIT_MULTIPLE_TRANSACTIONS_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultiplePage').default, + [SCREENS.SEARCH.EDIT_MULTIPLE_AMOUNT_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleAmountPage').default, + [SCREENS.SEARCH.EDIT_MULTIPLE_DESCRIPTION_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleDescriptionPage').default, + [SCREENS.SEARCH.EDIT_MULTIPLE_MERCHANT_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleMerchantPage').default, + [SCREENS.SEARCH.EDIT_MULTIPLE_DATE_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleDatePage').default, + [SCREENS.SEARCH.EDIT_MULTIPLE_CATEGORY_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage').default, + [SCREENS.SEARCH.EDIT_MULTIPLE_TAG_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleTagPage').default, + [SCREENS.SEARCH.EDIT_MULTIPLE_ATTENDEES_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleAttendeesPage').default, + [SCREENS.SEARCH.EDIT_MULTIPLE_REPORT_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleReportPage').default, }); const SearchAdvancedFiltersModalStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 74662cbff8ef..862e68baa1bc 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1571,6 +1571,15 @@ const config: LinkingOptions['config'] = { [SCREENS.SEARCH.MONEY_REQUEST_REPORT_HOLD_TRANSACTIONS]: ROUTES.SEARCH_MONEY_REQUEST_REPORT_HOLD_TRANSACTIONS.route, [SCREENS.SEARCH.TRANSACTION_HOLD_REASON_RHP]: ROUTES.TRANSACTION_HOLD_REASON_RHP, [SCREENS.SEARCH.TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP]: ROUTES.MOVE_TRANSACTIONS_SEARCH_RHP, + [SCREENS.SEARCH.EDIT_MULTIPLE_TRANSACTIONS_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_TRANSACTIONS_RHP, + [SCREENS.SEARCH.EDIT_MULTIPLE_AMOUNT_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_AMOUNT_RHP, + [SCREENS.SEARCH.EDIT_MULTIPLE_DESCRIPTION_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_DESCRIPTION_RHP, + [SCREENS.SEARCH.EDIT_MULTIPLE_MERCHANT_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_MERCHANT_RHP, + [SCREENS.SEARCH.EDIT_MULTIPLE_DATE_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_DATE_RHP, + [SCREENS.SEARCH.EDIT_MULTIPLE_CATEGORY_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_CATEGORY_RHP, + [SCREENS.SEARCH.EDIT_MULTIPLE_TAG_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_TAG_RHP, + [SCREENS.SEARCH.EDIT_MULTIPLE_ATTENDEES_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_ATTENDEES_RHP, + [SCREENS.SEARCH.EDIT_MULTIPLE_REPORT_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_REPORT_RHP, }, }, [SCREENS.RIGHT_MODAL.SEARCH_ADVANCED_FILTERS]: { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 273264a1053e..5a76b57fe797 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4616,6 +4616,39 @@ function canEditReportPolicy(report: OnyxEntry, reportPolicy: OnyxEntry< return false; } +/** + * Checks if the user can edit multiple transactions + */ +function canEditMultipleTransactions(selectedTransactions: Transaction[]): boolean { + if (selectedTransactions.length < 2) { + return false; + } + + // Iterate through selected transactions and check if at least one field is editable for at least one transaction + for (const transaction of selectedTransactions) { + const reportAction = getIOUActionForTransactionID(Object.values(allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction.reportID}`] ?? {}), transaction.transactionID); + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`]; + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; + + const fieldsToCheck = [ + CONST.EDIT_REQUEST_FIELD.AMOUNT, + CONST.EDIT_REQUEST_FIELD.MERCHANT, + CONST.EDIT_REQUEST_FIELD.CATEGORY, + CONST.EDIT_REQUEST_FIELD.TAG, + CONST.EDIT_REQUEST_FIELD.DESCRIPTION, + CONST.EDIT_REQUEST_FIELD.DATE, + ]; + + for (const field of fieldsToCheck) { + if (canEditFieldOfMoneyRequest(reportAction, field, undefined, undefined, undefined, transaction, report, policy)) { + return true; + } + } + } + + return false; +} + /** * Checks if the current user can edit the provided property of an expense * @@ -12682,6 +12715,7 @@ export { canHoldUnholdReportAction, canEditReportPolicy, canEditFieldOfMoneyRequest, + canEditMultipleTransactions, canEditMoneyRequest, canEditReportAction, canEditReportDescription, diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index bf681bfe8554..a458a6c71972 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -14578,6 +14578,36 @@ function addReportApprover( API.write(WRITE_COMMANDS.ADD_REPORT_APPROVER, params, onyxData); } +function updateMultipleMoneyRequests( + transactionIDs: string[], + transactionChanges: TransactionChanges, + policy: OnyxEntry, + policyTags: OnyxEntry, + policyCategories: OnyxEntry, +) { + transactionIDs.forEach((transactionID) => { + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + if (!transaction) { + return; + } + + // Use the transaction's report ID as the thread ID if it's a regular expense + const transactionThreadReportID = transaction.reportID; + + const data = getUpdateMoneyRequestParams({ + transactionID, + transactionThreadReportID, + transactionChanges, + policy, + policyTagList: policyTags, + policyCategories, + }); + + const {params, onyxData} = data; + API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST, params, onyxData); + }); +} + export { adjustRemainingSplitShares, approveMoneyRequest, @@ -14704,6 +14734,7 @@ export { getUpdateMoneyRequestParams, getUpdateTrackExpenseParams, getReportPreviewAction, + updateMultipleMoneyRequests, }; export type { GPSPoint as GpsPoint, diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleAmountPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleAmountPage.tsx new file mode 100644 index 000000000000..b14218d7085e --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleAmountPage.tsx @@ -0,0 +1,54 @@ +import React, {useCallback} from 'react'; +import Onyx from 'react-native-onyx'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import {convertToBackendAmount} from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import MoneyRequestAmountForm from '@pages/iou/MoneyRequestAmountForm'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +type CurrentMoney = {amount: string; currency: string}; + +function SearchEditMultipleAmountPage() { + const {translate} = useLocalize(); + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, {canBeMissing: true}); + + const currency = draftTransaction?.currency ?? CONST.CURRENCY.USD; + const amount = draftTransaction?.amount ?? 0; + + const saveAmount = useCallback((currentMoney: CurrentMoney) => { + const newAmount = convertToBackendAmount(Number.parseFloat(currentMoney.amount)); + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, { + amount: newAmount, + currency: currentMoney.currency, + }); + Navigation.goBack(); + }, []); + + return ( + + + + + ); +} + +SearchEditMultipleAmountPage.displayName = 'SearchEditMultipleAmountPage'; + +export default SearchEditMultipleAmountPage; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleAttendeesPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleAttendeesPage.tsx new file mode 100644 index 000000000000..5ff5f3742d7f --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleAttendeesPage.tsx @@ -0,0 +1,57 @@ +import React, {useCallback, useState} from 'react'; +import Onyx from 'react-native-onyx'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import Navigation from '@libs/Navigation/Navigation'; +import {getAttendees} from '@libs/TransactionUtils'; +import MoneyRequestAttendeeSelector from '@pages/iou/request/MoneyRequestAttendeeSelector'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Attendee} from '@src/types/onyx/IOU'; + +function SearchEditMultipleAttendeesPage() { + const {translate} = useLocalize(); + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, {canBeMissing: true}); + + const [attendees, setAttendees] = useState(() => getAttendees(draftTransaction)); + + const saveAttendees = useCallback(() => { + if (attendees.length <= 0) { + Navigation.goBack(); + return; + } + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, { + comment: { + ...draftTransaction?.comment, + attendees, + }, + }); + Navigation.goBack(); + }, [attendees, draftTransaction?.comment]); + + return ( + + + + + ); +} + +SearchEditMultipleAttendeesPage.displayName = 'SearchEditMultipleAttendeesPage'; + +export default SearchEditMultipleAttendeesPage; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage.tsx new file mode 100644 index 000000000000..2836c24e5175 --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage.tsx @@ -0,0 +1,67 @@ +import React, {useCallback, useMemo} from 'react'; +import Onyx from 'react-native-onyx'; +import CategoryPicker from '@components/CategoryPicker'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import {useSearchContext} from '@components/Search/SearchContext'; +import type {ListItem} from '@components/SelectionListWithSections/types'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +function SearchEditMultipleCategoryPage() { + const {translate} = useLocalize(); + const {selectedTransactions} = useSearchContext(); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, {canBeMissing: true}); + + // Determine policyID based on context + const policyID = useMemo(() => { + const transactionValues = Object.values(selectedTransactions); + if (transactionValues.length === 0) { + return activePolicyID; + } + + const firstPolicyID = transactionValues[0]?.policyID; + const allSamePolicy = transactionValues.every((t) => t.policyID === firstPolicyID); + + if (allSamePolicy && firstPolicyID) { + return firstPolicyID; + } + + return activePolicyID; + }, [selectedTransactions, activePolicyID]); + + const currentCategory = draftTransaction?.category ?? ''; + + const saveCategory = useCallback((item: ListItem) => { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, { + category: item.searchText, + }); + Navigation.goBack(); + }, []); + + return ( + + + + + ); +} + +SearchEditMultipleCategoryPage.displayName = 'SearchEditMultipleCategoryPage'; + +export default SearchEditMultipleCategoryPage; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleDatePage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleDatePage.tsx new file mode 100644 index 000000000000..c44e21b4b3ca --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleDatePage.tsx @@ -0,0 +1,67 @@ +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import Onyx from 'react-native-onyx'; +import DatePicker from '@components/DatePicker'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/MoneyRequestDateForm'; + +function SearchEditMultipleDatePage() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, {canBeMissing: true}); + + const currentDate = draftTransaction?.created ?? ''; + + const saveDate = useCallback((value: FormOnyxValues) => { + const newDate = value.moneyRequestCreated; + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, { + created: newDate, + }); + Navigation.goBack(); + }, []); + + return ( + + + + + + + + + ); +} + +SearchEditMultipleDatePage.displayName = 'SearchEditMultipleDatePage'; + +export default SearchEditMultipleDatePage; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleDescriptionPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleDescriptionPage.tsx new file mode 100644 index 000000000000..0f1f2ba678d9 --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleDescriptionPage.tsx @@ -0,0 +1,72 @@ +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import Onyx from 'react-native-onyx'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import TextInput from '@components/TextInput'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/MoneyRequestDescriptionForm'; + +function SearchEditMultipleDescriptionPage() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {inputCallbackRef} = useAutoFocusInput(); + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, {canBeMissing: true}); + + const currentDescription = draftTransaction?.comment?.comment ?? ''; + + const saveDescription = useCallback((value: FormOnyxValues) => { + const newDescription = value.moneyRequestComment.trim(); + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, { + comment: {comment: newDescription}, + }); + Navigation.goBack(); + }, []); + + return ( + + + + + + + + + ); +} + +SearchEditMultipleDescriptionPage.displayName = 'SearchEditMultipleDescriptionPage'; + +export default SearchEditMultipleDescriptionPage; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleMerchantPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleMerchantPage.tsx new file mode 100644 index 000000000000..426480320524 --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleMerchantPage.tsx @@ -0,0 +1,71 @@ +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import Onyx from 'react-native-onyx'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import TextInput from '@components/TextInput'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/MoneyRequestMerchantForm'; + +function SearchEditMultipleMerchantPage() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {inputCallbackRef} = useAutoFocusInput(); + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, {canBeMissing: true}); + + const currentMerchant = draftTransaction?.merchant ?? ''; + + const saveMerchant = useCallback((value: FormOnyxValues) => { + const newMerchant = value.moneyRequestMerchant.trim(); + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, { + merchant: newMerchant, + }); + Navigation.goBack(); + }, []); + + return ( + + + + + + + + + ); +} + +SearchEditMultipleMerchantPage.displayName = 'SearchEditMultipleMerchantPage'; + +export default SearchEditMultipleMerchantPage; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx new file mode 100644 index 000000000000..bf05d572f734 --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx @@ -0,0 +1,201 @@ +import React, {useEffect, useMemo} from 'react'; +import {View} from 'react-native'; +import Onyx from 'react-native-onyx'; +import Button from '@components/Button'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import {useSearchContext} from '@components/Search/SearchContext'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {updateMultipleMoneyRequests} from '@libs/actions/IOU'; +import {convertToDisplayString} from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {TransactionChanges} from '@src/types/onyx/Transaction'; + +function SearchEditMultiplePage() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {selectedTransactions, selectedTransactionIDs} = useSearchContext(); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, {canBeMissing: true}); + + // Determine policyID based on context: + // - If all selected transactions belong to the same policy, use that policy + // - Otherwise, fall back to the user's active workspace policy + const policyID = useMemo(() => { + const transactionValues = Object.values(selectedTransactions); + if (transactionValues.length === 0) { + return activePolicyID; + } + + const firstPolicyID = transactionValues[0]?.policyID; + const allSamePolicy = transactionValues.every((t) => t.policyID === firstPolicyID); + + if (allSamePolicy && firstPolicyID) { + return firstPolicyID; + } + + return activePolicyID; + }, [selectedTransactions, activePolicyID]); + + const policy = policyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] : undefined; + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID ?? '-1'}`, {canBeMissing: true}); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID ?? '-1'}`, {canBeMissing: true}); + + useEffect(() => { + // Initialize the draft transaction with minimal values + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, { + transactionID: CONST.IOU.OPTIMISTIC_TRANSACTION_ID, + currency: policy?.outputCurrency ?? CONST.CURRENCY.USD, + }); + + return () => { + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, null); + }; + }, [policy?.outputCurrency]); + + const save = () => { + if (!draftTransaction) { + return; + } + + const changes: TransactionChanges = {}; + if (draftTransaction.amount !== undefined && draftTransaction.amount !== 0) { + changes.amount = draftTransaction.amount; + } + if (draftTransaction.currency) { + changes.currency = draftTransaction.currency; + } + if (draftTransaction.merchant) { + changes.merchant = draftTransaction.merchant; + } + if (draftTransaction.comment?.comment) { + changes.comment = draftTransaction.comment.comment; + } + if (draftTransaction.created) { + changes.created = draftTransaction.created; + } + if (draftTransaction.category) { + changes.category = draftTransaction.category; + } + if (draftTransaction.tag) { + changes.tag = draftTransaction.tag; + } + if (draftTransaction.billable !== undefined) { + changes.billable = draftTransaction.billable; + } + if (draftTransaction.reimbursable !== undefined) { + changes.reimbursable = draftTransaction.reimbursable; + } + + if (Object.keys(changes).length === 0) { + Navigation.dismissModal(); + return; + } + + updateMultipleMoneyRequests(selectedTransactionIDs, changes, policy, policyTags, policyCategories); + + Navigation.dismissModal(); + }; + + const currency = draftTransaction?.currency ?? policy?.outputCurrency ?? CONST.CURRENCY.USD; + + const attendeesTitle = useMemo(() => { + const attendees = draftTransaction?.comment?.attendees; + if (!Array.isArray(attendees) || attendees.length === 0) { + return ''; + } + return attendees.map((item) => item?.displayName ?? item?.login).join(', '); + }, [draftTransaction?.comment?.attendees]); + + const fields = useMemo(() => { + // TODO: Check policy fields to show only enabled ones + const allFields = [ + { + description: translate('iou.amount'), + title: draftTransaction?.amount ? convertToDisplayString(Math.abs(draftTransaction.amount), currency) : '', + route: ROUTES.SEARCH_EDIT_MULTIPLE_AMOUNT_RHP, + }, + { + description: translate('common.description'), + title: draftTransaction?.comment?.comment ?? '', + route: ROUTES.SEARCH_EDIT_MULTIPLE_DESCRIPTION_RHP, + }, + { + description: translate('common.merchant'), + title: draftTransaction?.merchant ?? '', + route: ROUTES.SEARCH_EDIT_MULTIPLE_MERCHANT_RHP, + }, + { + description: translate('common.date'), + title: draftTransaction?.created ?? '', + route: ROUTES.SEARCH_EDIT_MULTIPLE_DATE_RHP, + }, + { + description: translate('common.category'), + title: draftTransaction?.category ?? '', + route: ROUTES.SEARCH_EDIT_MULTIPLE_CATEGORY_RHP, + }, + { + description: translate('common.tag'), + title: draftTransaction?.tag ?? '', + route: ROUTES.SEARCH_EDIT_MULTIPLE_TAG_RHP, + }, + { + description: translate('iou.attendees'), + title: attendeesTitle, + route: ROUTES.SEARCH_EDIT_MULTIPLE_ATTENDEES_RHP, + }, + { + description: translate('common.report'), + title: draftTransaction?.reportID ?? '', + route: ROUTES.SEARCH_EDIT_MULTIPLE_REPORT_RHP, + }, + ]; + + return allFields; + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps + }, [draftTransaction, translate, currency, attendeesTitle]); + + return ( + + + + + {translate('search.bulkActions.editMultipleDescription')} + {fields.map((field) => ( + Navigation.navigate(field.route)} + shouldShowRightIcon + /> + ))} + +