diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 79a6c40c91e08..870b1dd9e6524 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1474,6 +1474,8 @@ const CONST = { SUBMITTED: 1, APPROVED: 2, BILLING: 3, + REIMBURSABLE: 4, + REIMBURSABLE_BILLABLE: 5, AUTOREIMBURSED: 6, }, STATUS_NUM: { @@ -2890,6 +2892,8 @@ const CONST = { QUANTITY_MAX_LENGTH: 12, // This is the transactionID used when going through the create expense flow so that it mimics a real transaction (like the edit flow) OPTIMISTIC_TRANSACTION_ID: '1', + // This is the transactionID used when bulk editing multiple expenses + OPTIMISTIC_BULK_EDIT_TRANSACTION_ID: 'optimisticBulkEditTransactionID', // Note: These payment types are used when building IOU reportAction message values in the server and should // not be changed. LOCATION_PERMISSION_PROMPT_THRESHOLD_DAYS: 7, @@ -3129,6 +3133,7 @@ const CONST = { MAKE_AUDITOR: 'makeAuditor', }, BULK_ACTION_TYPES: { + EDIT: 'edit', DELETE: 'delete', DISABLE: 'disable', ENABLE: 'enable', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 47dbc171a2b72..3b3f552032280 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -973,6 +973,12 @@ const ONYXKEYS = { WORKSPACE_PER_DIEM_FORM_DRAFT: 'workspacePerDiemFormDraft', ENABLE_GLOBAL_REIMBURSEMENTS: 'enableGlobalReimbursementsForm', ENABLE_GLOBAL_REIMBURSEMENTS_DRAFT: 'enableGlobalReimbursementsFormDraft', + SEARCH_EDIT_MULTIPLE_DESCRIPTION_FORM: 'searchEditMultipleDescriptionForm', + SEARCH_EDIT_MULTIPLE_DESCRIPTION_FORM_DRAFT: 'searchEditMultipleDescriptionFormDraft', + SEARCH_EDIT_MULTIPLE_MERCHANT_FORM: 'searchEditMultipleMerchantForm', + SEARCH_EDIT_MULTIPLE_MERCHANT_FORM_DRAFT: 'searchEditMultipleMerchantFormDraft', + SEARCH_EDIT_MULTIPLE_DATE_FORM: 'searchEditMultipleDateForm', + SEARCH_EDIT_MULTIPLE_DATE_FORM_DRAFT: 'searchEditMultipleDateFormDraft', CREATE_DOMAIN_FORM: 'createDomainForm', CREATE_DOMAIN_FORM_DRAFT: 'createDomainFormDraft', SPLIT_EXPENSE_EDIT_DATES: 'splitExpenseEditDates', @@ -1091,6 +1097,9 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM]: FormTypes.InternationalBankAccountForm; [ONYXKEYS.FORMS.WORKSPACE_PER_DIEM_FORM]: FormTypes.WorkspacePerDiemForm; [ONYXKEYS.FORMS.ENABLE_GLOBAL_REIMBURSEMENTS]: FormTypes.EnableGlobalReimbursementsForm; + [ONYXKEYS.FORMS.SEARCH_EDIT_MULTIPLE_DESCRIPTION_FORM]: FormTypes.SearchEditMultipleDescriptionForm; + [ONYXKEYS.FORMS.SEARCH_EDIT_MULTIPLE_MERCHANT_FORM]: FormTypes.SearchEditMultipleMerchantForm; + [ONYXKEYS.FORMS.SEARCH_EDIT_MULTIPLE_DATE_FORM]: FormTypes.SearchEditMultipleDateForm; [ONYXKEYS.FORMS.CREATE_DOMAIN_FORM]: FormTypes.CreateDomainForm; [ONYXKEYS.FORMS.SPLIT_EXPENSE_EDIT_DATES]: FormTypes.SplitExpenseEditDateForm; }; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 16c126be5dfe8..05a8a292ada4d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -124,6 +124,16 @@ const ROUTES = { TRANSACTION_HOLD_REASON_RHP: 'search/hold', SEARCH_REJECT_REASON_RHP: 'search/reject', 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', + SEARCH_EDIT_MULTIPLE_TAX_RHP: 'search/edit-multiple/tax', // 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 989a9ae3fe92d..656bbd11dc42a 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -93,6 +93,14 @@ const SCREENS = { TRANSACTION_HOLD_REASON_RHP: 'Search_Transaction_Hold_Reason_RHP', SEARCH_REJECT_REASON_RHP: 'Search_Reject_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_TAX_RHP: 'Search_Edit_Multiple_Tax_RHP', }, SETTINGS: { ROOT: 'Settings_Root', diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index 0291873e3e24a..9979d5e0981fe 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -12,6 +12,7 @@ import { canDeleteCardTransactionByLiabilityType, canDeleteTransaction, canEditFieldOfMoneyRequest, + canEditMultipleTransactions, canHoldUnholdReportAction, canRejectReportAction, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, @@ -68,13 +69,28 @@ function useSelectedTransactionsActions({ const {selectedTransactionIDs, clearSelectedTransactions, currentSearchHash, selectedTransactions: selectedTransactionsMeta} = useSearchContext(); const allTransactions = useAllTransactions(); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false}); + const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {canBeMissing: false}); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: false}); const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID, {canBeMissing: true}); 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 [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Stopwatch', 'Trashcan', 'ArrowRight', 'Table', 'DocumentMerge', 'Export', 'ArrowCollapse', 'ArrowSplit', 'ThumbsDown']); + const expensifyIcons = useMemoizedLazyExpensifyIcons([ + 'Stopwatch', + 'Trashcan', + 'ArrowRight', + 'Table', + 'DocumentMerge', + 'Export', + 'ArrowCollapse', + 'ArrowSplit', + 'ThumbsDown', + 'ArrowSplit', + 'ThumbsDown', + 'Pencil', + ]); const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(selectedTransactionIDs); const isReportArchived = useReportIsArchived(report?.reportID); const {deleteTransactions} = useDeleteTransactions({report, reportActions, policy}); @@ -169,6 +185,20 @@ function useSelectedTransactionsActions({ return []; } const options = []; + + const canEditMultiple = canEditMultipleTransactions(selectedTransactionsList, allReportActions, allReports, allPolicies); + + 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; @@ -400,9 +430,12 @@ function useSelectedTransactionsActions({ expensifyIcons.DocumentMerge, expensifyIcons.ArrowCollapse, expensifyIcons.Trashcan, + expensifyIcons.Pencil, localeCompare, isOnSearch, login, + allReportActions, + allPolicies, ]); return { diff --git a/src/languages/de.ts b/src/languages/de.ts index 596d3229b0844..e0f5ca09dc5b6 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6759,6 +6759,9 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard savedSearchesMenuItemTitle: 'Gespeichert', groupedExpenses: 'gruppierte Ausgaben', bulkActions: { + editMultiple: 'Mehrere bearbeiten', + editMultipleTitle: 'Mehrere Ausgaben bearbeiten', + editMultipleDescription: 'Änderungen werden für alle ausgewählten Ausgaben festgelegt und überschreiben alle zuvor festgelegten Werte.', approve: 'Genehmigen', pay: 'Bezahlen', delete: 'Löschen', diff --git a/src/languages/en.ts b/src/languages/en.ts index 4eba58e91e096..1aef8ff21faa7 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6642,6 +6642,10 @@ const translations = { savedSearchesMenuItemTitle: 'Saved', groupedExpenses: 'grouped expenses', bulkActions: { + editMultiple: 'Edit multiple', + editMultipleTitle: 'Edit multiple expenses', + // cspell:disable + 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 80e29ddee6c98..c7cdfedaa46b0 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6374,6 +6374,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.', approve: 'Aprobar', pay: 'Pagar', delete: 'Eliminar', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 6d549456652ec..c61d5dcbd3c66 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6770,6 +6770,9 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin savedSearchesMenuItemTitle: 'Enregistré', groupedExpenses: 'dépenses groupées', bulkActions: { + editMultiple: 'Modifier plusieurs', + editMultipleTitle: 'Modifier plusieurs dépenses', + editMultipleDescription: 'Les modifications seront appliquées à toutes les dépenses sélectionnées et remplaceront toutes les valeurs précédemment définies.', approve: 'Approuver', pay: 'Payer', delete: 'Supprimer', diff --git a/src/languages/it.ts b/src/languages/it.ts index ed74ff8d04b08..2b8324716710e 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6744,6 +6744,9 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori savedSearchesMenuItemTitle: 'Salvato', groupedExpenses: 'spese raggruppate', bulkActions: { + editMultiple: 'Modifica multipli', + editMultipleTitle: 'Modifica più spese', + editMultipleDescription: 'Le modifiche verranno applicate a tutte le spese selezionate e sostituiranno i valori precedentemente impostati.', approve: 'Approva', pay: 'Paga', delete: 'Elimina', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index b05c8be8d62ac..0b6df421ee4f8 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6690,6 +6690,9 @@ ${reportName} savedSearchesMenuItemTitle: '保存済み', groupedExpenses: 'グループ化された経費', bulkActions: { + editMultiple: '複数を編集', + editMultipleTitle: '複数の経費を編集', + editMultipleDescription: '変更は選択したすべての経費に適用され、以前に設定された値は上書きされます。', approve: '承認', pay: '支払う', delete: '削除', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 60de38a79867c..cfed28f0df1e4 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6733,6 +6733,9 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten savedSearchesMenuItemTitle: 'Opgeslagen', groupedExpenses: 'gegroepeerde uitgaven', bulkActions: { + editMultiple: 'Meerdere bewerken', + editMultipleTitle: 'Meerdere uitgaven bewerken', + editMultipleDescription: 'Wijzigingen worden toegepast op alle geselecteerde uitgaven en overschrijven eerder ingestelde waarden.', approve: 'Goedkeuren', pay: 'Betalen', delete: 'Verwijderen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 536cec1da2f71..707021bbfc542 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6718,6 +6718,9 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i savedSearchesMenuItemTitle: 'Zapisano', groupedExpenses: 'pogrupowane wydatki', bulkActions: { + editMultiple: 'Edytuj wiele', + editMultipleTitle: 'Edytuj wiele wydatków', + editMultipleDescription: 'Zmiany zostaną zastosowane do wszystkich wybranych wydatków i zastąpią wcześniej ustawione wartości.', approve: 'Zatwierdź', pay: 'Zapłać', delete: 'Usuń', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index f4d070481d452..7fc83378af0f9 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6722,6 +6722,9 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe savedSearchesMenuItemTitle: 'Salvo', groupedExpenses: 'despesas agrupadas', bulkActions: { + editMultiple: 'Editar múltiplos', + editMultipleTitle: 'Editar múltiplas despesas', + editMultipleDescription: 'As alterações serão aplicadas a todas as despesas selecionadas e substituirão quaisquer valores definidos anteriormente.', approve: 'Aprovar', pay: 'Pagar', delete: 'Excluir', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index e7c85607477df..22aa63d0ec96d 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6579,6 +6579,9 @@ ${reportName} savedSearchesMenuItemTitle: '已保存', groupedExpenses: '已分组报销费用', bulkActions: { + editMultiple: '批量编辑', + editMultipleTitle: '编辑多个费用', + editMultipleDescription: '更改将应用于所有选定的费用,并将覆盖之前设置的任何值。', approve: '批准', pay: '支付', delete: '删除', diff --git a/src/libs/API/parameters/UpdateMoneyRequestParams.ts b/src/libs/API/parameters/UpdateMoneyRequestParams.ts index 0f210d6c661d3..68dbb7a8f6797 100644 --- a/src/libs/API/parameters/UpdateMoneyRequestParams.ts +++ b/src/libs/API/parameters/UpdateMoneyRequestParams.ts @@ -4,6 +4,8 @@ type UpdateMoneyRequestParams = Partial & { reportID?: string; transactionID?: string; reportActionID?: string; + /** Used for bulk updates - JSON stringified object containing only changed fields */ + updates?: string; }; export default UpdateMoneyRequestParams; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index b808473b64bc6..153ac9c2f9e28 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -197,6 +197,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', @@ -720,6 +721,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 8e70d36864994..edb01ef636d44 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -881,6 +881,14 @@ const SearchReportActionsModalStackNavigator = createModalStackNavigator require('../../../../pages/Search/SearchHoldReasonPage').default, [SCREENS.SEARCH.SEARCH_REJECT_REASON_RHP]: () => require('../../../../pages/Search/SearchRejectReasonPage').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_TAX_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleTaxPage').default, }); const SearchAdvancedFiltersModalStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 5dee953b22703..606ea0e76e9ad 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1699,6 +1699,14 @@ const config: LinkingOptions['config'] = { [SCREENS.SEARCH.TRANSACTION_HOLD_REASON_RHP]: ROUTES.TRANSACTION_HOLD_REASON_RHP, [SCREENS.SEARCH.SEARCH_REJECT_REASON_RHP]: ROUTES.SEARCH_REJECT_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_TAX_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_TAX_RHP, }, }, [SCREENS.RIGHT_MODAL.SEARCH_ADVANCED_FILTERS]: { diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index f15613ff14ef4..eb1f3107b4fa1 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4645,6 +4645,43 @@ function canEditReportPolicy(report: OnyxEntry, reportPolicy: OnyxEntry< return false; } +/** + * Checks if the user can edit multiple transactions + */ +function canEditMultipleTransactions( + selectedTransactions: Transaction[], + reportActions: OnyxCollection, + reports: OnyxCollection, + policies: OnyxCollection, +): boolean { + if (selectedTransactions.length < 2) { + return false; + } + + for (const transaction of selectedTransactions) { + const reportAction = getIOUActionForTransactionID(Object.values(reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction.reportID}`] ?? {}), transaction.transactionID); + const report = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`]; + const policy = policies?.[`${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, + ]; + + const isTransactionEditable = fieldsToCheck.some((field) => canEditFieldOfMoneyRequest(reportAction, field, undefined, undefined, undefined, transaction, report, policy)); + + if (!isTransactionEditable) { + return false; + } + } + + return true; +} + /** * Checks if the current user can edit the provided property of an expense * @@ -13283,6 +13320,7 @@ export { isOneTransactionReport, isTrackExpenseReportNew, shouldHideSingleReportField, + canEditMultipleTransactions, }; export type { diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 01415c68c99af..c95c1f28ec70d 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -3505,7 +3505,42 @@ function getTableMinWidth(columns: SearchColumnType[]) { return minWidth; } +/** + * Determine policyID based on selected transactions: + * - If all selected transactions belong to the same policy, use that policy + * - Otherwise, fall back to the user's active workspace policy + */ +function getSearchBulkEditPolicyID( + selectedTransactionIDs: string[], + activePolicyID: string | undefined, + allTransactions: OnyxCollection | undefined, + allReports: OnyxCollection | undefined, +): string | undefined { + if (selectedTransactionIDs.length === 0) { + return activePolicyID; + } + + const policyIDs = selectedTransactionIDs.map((transactionID) => { + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + if (!transaction?.reportID) { + return undefined; + } + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`]; + return report?.policyID; + }); + + const firstPolicyID = policyIDs.at(0); + const allSamePolicy = policyIDs.every((policyID) => policyID === firstPolicyID); + + if (allSamePolicy && firstPolicyID) { + return firstPolicyID; + } + + return activePolicyID; +} + export { + getSearchBulkEditPolicyID, getSuggestedSearches, getDefaultActionableSearchMenuItem, getListItem, diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index c07865a07db4b..8c1f00f0b890e 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -97,6 +97,7 @@ import { import { getAllReportActions, getIOUActionForReportID, + getIOUActionForTransactionID, getLastVisibleAction, getLastVisibleMessage, getOriginalMessage, @@ -142,6 +143,7 @@ import { buildOptimisticUnHoldReportAction, buildTransactionThread, canBeAutoReimbursed, + canEditFieldOfMoneyRequest, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, findSelfDMReportID, generateReportID, @@ -14532,6 +14534,228 @@ function addReportApprover( API.write(WRITE_COMMANDS.ADD_REPORT_APPROVER, params, onyxData); } +function updateMultipleMoneyRequests( + transactionIDs: string[], + changes: TransactionChanges, + policy: OnyxEntry, + reports: OnyxCollection, + transactions: OnyxCollection, + reportActions: OnyxCollection, +) { + for (const transactionID of transactionIDs) { + const transaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + if (!transaction) { + continue; + } + + const transactionThreadReportID = transaction.reportID; + const transactionThread = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; + const iouReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThread?.parentReportID}`] ?? null; + const isFromExpenseReport = isExpenseReport(iouReport); + + const transactionReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`] ?? {}; + const reportAction = getIOUActionForTransactionID(Object.values(transactionReportActions), transactionID); + + const canEditField = (field: ValueOf) => { + return canEditFieldOfMoneyRequest(reportAction, field, undefined, false, undefined, transaction, iouReport, policy); + }; + + const transactionChanges: TransactionChanges = {}; + + if (changes.merchant && canEditField(CONST.EDIT_REQUEST_FIELD.MERCHANT)) { + transactionChanges.merchant = changes.merchant; + } + if (changes.created && canEditField(CONST.EDIT_REQUEST_FIELD.DATE)) { + transactionChanges.created = changes.created; + } + if (changes.amount && canEditField(CONST.EDIT_REQUEST_FIELD.AMOUNT)) { + transactionChanges.amount = changes.amount; + } + if (changes.currency && canEditField(CONST.EDIT_REQUEST_FIELD.CURRENCY)) { + transactionChanges.currency = changes.currency; + } + if (changes.category && canEditField(CONST.EDIT_REQUEST_FIELD.CATEGORY)) { + transactionChanges.category = changes.category; + } + if (changes.tag && canEditField(CONST.EDIT_REQUEST_FIELD.TAG)) { + transactionChanges.tag = changes.tag; + } + if (changes.comment && canEditField(CONST.EDIT_REQUEST_FIELD.DESCRIPTION)) { + transactionChanges.comment = changes.comment; + } + if (changes.taxCode && canEditField(CONST.EDIT_REQUEST_FIELD.TAX_RATE)) { + transactionChanges.taxCode = changes.taxCode; + } + if (changes.billable !== undefined && canEditField(CONST.EDIT_REQUEST_FIELD.REIMBURSABLE)) { + transactionChanges.billable = changes.billable; + } + if (changes.reimbursable !== undefined && canEditField(CONST.EDIT_REQUEST_FIELD.REIMBURSABLE)) { + transactionChanges.reimbursable = changes.reimbursable; + } + + const updates: Record = {}; + if (transactionChanges.merchant) { + updates.merchant = transactionChanges.merchant; + } + if (transactionChanges.created) { + updates.created = transactionChanges.created; + } + if (transactionChanges.currency) { + updates.currency = transactionChanges.currency; + } + if (transactionChanges.category) { + updates.category = transactionChanges.category; + } + if (transactionChanges.tag) { + updates.tag = transactionChanges.tag; + } + if (transactionChanges.comment) { + updates.comment = transactionChanges.comment; + } + if (transactionChanges.taxCode) { + updates.taxCode = transactionChanges.taxCode; + } + if (transactionChanges.amount) { + updates.amount = isFromExpenseReport ? -Math.abs(transactionChanges.amount) : transactionChanges.amount; + } + if (transactionChanges.billable !== undefined || transactionChanges.reimbursable !== undefined) { + const billable = transactionChanges.billable; + const reimbursable = transactionChanges.reimbursable; + + if (billable && reimbursable) { + updates.state = CONST.REPORT.STATE_NUM.REIMBURSABLE_BILLABLE; + } else if (billable) { + updates.state = CONST.REPORT.STATE_NUM.BILLING; + } else if (reimbursable) { + updates.state = CONST.REPORT.STATE_NUM.REIMBURSABLE; + } + } + + // Skip if no updates + if (Object.keys(updates).length === 0) { + continue; + } + + // Generate optimistic report action ID + const modifiedExpenseReportActionID = NumberUtils.rand64(); + + const optimisticData: OnyxUpdate[] = []; + const successData: OnyxUpdate[] = []; + const failureData: OnyxUpdate[] = []; + + // Pending fields for the transaction + const pendingFields: OnyxTypes.Transaction['pendingFields'] = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE])); + const clearedPendingFields = getClearedPendingFields(transactionChanges); + + const errorFields = Object.fromEntries( + // eslint-disable-next-line @typescript-eslint/no-deprecated + Object.keys(pendingFields).map((key) => [key, {[DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericEditFailureMessage')}]), + ); + + // Build updated transaction + const updatedTransaction = getUpdatedTransaction({ + transaction, + transactionChanges, + isFromExpenseReport, + policy, + }); + + // Optimistic transaction update + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...updatedTransaction, + pendingFields, + isLoading: false, + errorFields: null, + }, + }); + + // Build optimistic modified expense report action + const optimisticReportAction = buildOptimisticModifiedExpenseReportAction(transactionThread, transaction, transactionChanges, isFromExpenseReport, policy, updatedTransaction); + + // Optimistic report action + if (transactionThreadReportID) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, + value: { + [modifiedExpenseReportActionID]: { + ...optimisticReportAction, + reportActionID: modifiedExpenseReportActionID, + }, + }, + }); + } + + // Success data - clear pending fields + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingFields: clearedPendingFields, + }, + }); + + // Failure data - revert transaction + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...transaction, + pendingFields: clearedPendingFields, + errorFields, + }, + }); + + // Failure data - remove optimistic report action + if (transactionThreadReportID) { + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, + value: { + [modifiedExpenseReportActionID]: { + pendingAction: null, + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage'), + }, + }, + }); + } + + const params = { + transactionID, + reportActionID: modifiedExpenseReportActionID, + updates: JSON.stringify(updates), + }; + + API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST, params, {optimisticData, successData, failureData}); + } +} + +/** + * Initializes the draft transaction for bulk editing multiple expenses + */ +function initBulkEditDraftTransaction() { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`, { + transactionID: CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID, + }); +} + +/** + * Clears the draft transaction used for bulk editing + */ +function clearBulkEditDraftTransaction() { + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`, null); +} + +/** + * Updates the draft transaction for bulk editing multiple expenses + */ +function updateBulkEditDraftTransaction(transactionChanges: Partial) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`, transactionChanges); +} + export { adjustRemainingSplitShares, approveMoneyRequest, @@ -14667,6 +14891,10 @@ export { getReceiptError, getSearchOnyxUpdate, setMoneyRequestTimeRate, + updateMultipleMoneyRequests, + initBulkEditDraftTransaction, + clearBulkEditDraftTransaction, + updateBulkEditDraftTransaction, setMoneyRequestTimeCount, }; export type { diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleAmountPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleAmountPage.tsx new file mode 100644 index 0000000000000..c3f54459f5271 --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleAmountPage.tsx @@ -0,0 +1,106 @@ +import {useFocusEffect} from '@react-navigation/native'; +import React, {useRef, useState} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import {useSearchContext} from '@components/Search/SearchContext'; +import isTextInputFocused from '@components/TextInput/BaseTextInput/isTextInputFocused'; +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import {updateBulkEditDraftTransaction} from '@libs/actions/IOU'; +import {convertToBackendAmount} from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {getSearchBulkEditPolicyID} from '@libs/SearchUIUtils'; +import MoneyRequestAmountForm from '@pages/iou/MoneyRequestAmountForm'; +import IOURequestStepCurrencyModal from '@pages/iou/request/step/IOURequestStepCurrencyModal'; +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_BULK_EDIT_TRANSACTION_ID}`, {canBeMissing: true}); + const {selectedTransactionIDs} = useSearchContext(); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); + const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {canBeMissing: true}); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true}); + + const textInput = useRef(null); + const focusTimeoutRef = useRef(null); + + const policyID = getSearchBulkEditPolicyID(selectedTransactionIDs, activePolicyID, allTransactions, allReports); + const policy = policyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] : undefined; + const policyCurrency = policy?.outputCurrency ?? CONST.CURRENCY.USD; + + const initialCurrency = draftTransaction?.currency ?? policyCurrency; + const [selectedCurrency, setSelectedCurrency] = useState(initialCurrency); + const [isCurrencyPickerVisible, setIsCurrencyPickerVisible] = useState(false); + + useFocusEffect(() => { + if (isCurrencyPickerVisible) { + return; + } + focusTimeoutRef.current = setTimeout(() => textInput.current?.focus(), CONST.ANIMATED_TRANSITION + 100); + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; + }); + + const amount = draftTransaction?.amount ?? 0; + + const saveAmount = (currentMoney: CurrentMoney) => { + const newAmount = convertToBackendAmount(Number.parseFloat(currentMoney.amount)); + const shouldUpdateCurrency = selectedCurrency !== initialCurrency; + updateBulkEditDraftTransaction({ + amount: newAmount, + ...(shouldUpdateCurrency ? {currency: currentMoney.currency} : {}), + }); + Navigation.goBack(); + }; + + const showCurrencyPicker = () => { + if (isTextInputFocused(textInput)) { + textInput.current?.blur(); + } + setIsCurrencyPickerVisible(true); + }; + + return ( + + + setIsCurrencyPickerVisible(false)} + headerText={translate('common.selectCurrency')} + value={selectedCurrency} + onInputChange={(value) => setSelectedCurrency(value)} + /> + { + textInput.current = e; + }} + onCurrencyButtonPress={showCurrencyPicker} + onSubmitButtonPress={saveAmount} + /> + + ); +} + +SearchEditMultipleAmountPage.displayName = 'SearchEditMultipleAmountPage'; + +export default SearchEditMultipleAmountPage; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage.tsx new file mode 100644 index 0000000000000..0005ef8239d79 --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +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 {updateBulkEditDraftTransaction} from '@libs/actions/IOU'; +import Log from '@libs/Log'; +import Navigation from '@libs/Navigation/Navigation'; +import {getSearchBulkEditPolicyID} from '@libs/SearchUIUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +function SearchEditMultipleCategoryPage() { + const {translate} = useLocalize(); + const {selectedTransactionIDs} = useSearchContext(); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`, {canBeMissing: true}); + const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {canBeMissing: true}); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true}); + + // Determine policyID based on context + const policyID = getSearchBulkEditPolicyID(selectedTransactionIDs, activePolicyID, allTransactions, allReports); + + const currentCategory = draftTransaction?.category ?? ''; + + const saveCategory = (item: ListItem) => { + if (!item.searchText) { + Log.hmmm(`[SearchEditMultipleCategoryPage] no category selected for bulk edit`); + Navigation.goBack(); + return; + } + updateBulkEditDraftTransaction({ + 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 0000000000000..ff5a9466168a6 --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleDatePage.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import {View} from 'react-native'; +import DatePicker from '@components/DatePicker'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, 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 {updateBulkEditDraftTransaction} from '@libs/actions/IOU'; +import Navigation from '@libs/Navigation/Navigation'; +import {isValidDate} from '@libs/ValidationUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/SearchEditMultipleDateForm'; + +function SearchEditMultipleDatePage() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`, {canBeMissing: true}); + + const currentDate = draftTransaction?.created ?? ''; + + const validate = (value: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + const dateValue = value.date; + if (dateValue && !isValidDate(dateValue)) { + errors.date = translate('common.error.dateInvalid'); + } + return errors; + }; + + const saveDate = (value: FormOnyxValues) => { + const newDate = value.date; + updateBulkEditDraftTransaction({ + 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 0000000000000..61df42a29e13a --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleDescriptionPage.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import {View} from 'react-native'; +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 {updateBulkEditDraftTransaction} from '@libs/actions/IOU'; +import Navigation from '@libs/Navigation/Navigation'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/SearchEditMultipleDescriptionForm'; + +function SearchEditMultipleDescriptionPage() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {inputCallbackRef} = useAutoFocusInput(); + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`, {canBeMissing: true}); + + const currentDescription = draftTransaction?.comment?.comment ?? ''; + + const saveDescription = (value: FormOnyxValues) => { + const newDescription = value.description?.trim() ?? ''; + updateBulkEditDraftTransaction({ + 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 0000000000000..32432e3f220ba --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleMerchantPage.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import {View} from 'react-native'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, 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 {updateBulkEditDraftTransaction} from '@libs/actions/IOU'; +import Navigation from '@libs/Navigation/Navigation'; +import {isValidInputLength} from '@libs/ValidationUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/SearchEditMultipleMerchantForm'; + +function SearchEditMultipleMerchantPage() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {inputCallbackRef} = useAutoFocusInput(); + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`, {canBeMissing: true}); + + const currentMerchant = draftTransaction?.merchant ?? ''; + + const validate = (value: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + const {isValid, byteLength} = isValidInputLength(value.merchant ?? '', CONST.MERCHANT_NAME_MAX_BYTES); + + if (!isValid) { + errors.merchant = translate('common.error.characterLimitExceedCounter', byteLength, CONST.MERCHANT_NAME_MAX_BYTES); + } + + return errors; + }; + + const saveMerchant = (value: FormOnyxValues) => { + const newMerchant = value.merchant?.trim() ?? ''; + updateBulkEditDraftTransaction({ + 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 0000000000000..5fb3b0fcfd701 --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx @@ -0,0 +1,259 @@ +import React, {useEffect} from 'react'; +import {View} from 'react-native'; +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 Switch from '@components/Switch'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {clearBulkEditDraftTransaction, initBulkEditDraftTransaction, updateBulkEditDraftTransaction, updateMultipleMoneyRequests} from '@libs/actions/IOU'; +import {convertToDisplayString} from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; +import {canEditFieldOfMoneyRequest} from '@libs/ReportUtils'; +import {getSearchBulkEditPolicyID} from '@libs/SearchUIUtils'; +import {getTaxName, isDistanceRequest, isManagedCardTransaction, isPerDiemRequest} from '@libs/TransactionUtils'; +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 {selectedTransactionIDs} = useSearchContext(); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`, {canBeMissing: true}); + const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, {canBeMissing: true}); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true}); + const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS, {canBeMissing: true}); + + const hasCustomUnitTransaction = selectedTransactionIDs.some((transactionID) => { + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + return isDistanceRequest(transaction) || isPerDiemRequest(transaction); + }); + + const hasPartiallyEditableTransaction = selectedTransactionIDs.some((transactionID) => { + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + if (!transaction) { + return false; + } + + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`]; + const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction.reportID}`] ?? {}; + const reportAction = getIOUActionForTransactionID(Object.values(reportActions), transactionID); + const transactionPolicy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; + + return !canEditFieldOfMoneyRequest(reportAction, CONST.EDIT_REQUEST_FIELD.AMOUNT, undefined, false, undefined, transaction, report, transactionPolicy); + }); + + const areSelectedTransactionsBillable = selectedTransactionIDs.every((transactionID) => { + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + if (!transaction) { + return false; + } + + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`]; + const transactionPolicy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; + return transactionPolicy?.disabledFields?.defaultBillable === false || !!transaction.billable; + }); + + const areSelectedTransactionsReimbursable = selectedTransactionIDs.every((transactionID) => { + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + if (!transaction) { + return false; + } + + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`]; + const transactionPolicy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; + return transactionPolicy?.disabledFields?.reimbursable === false && !isManagedCardTransaction(transaction); + }); + + // 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 = getSearchBulkEditPolicyID(selectedTransactionIDs, activePolicyID, allTransactions, allReports); + + const policy = policyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] : undefined; + + const isTaxTrackingEnabled = !!policy?.tax?.trackingEnabled; + + useEffect(() => { + if (draftTransaction?.transactionID) { + return; + } + initBulkEditDraftTransaction(); + }, [draftTransaction?.transactionID]); + + useEffect(() => { + return () => { + clearBulkEditDraftTransaction(); + }; + }, []); + + 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.taxCode) { + changes.taxCode = draftTransaction.taxCode; + } + 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, allReports, allTransactions, allReportActions); + + Navigation.dismissModal(); + }; + + const currency = policy?.outputCurrency ?? CONST.CURRENCY.USD; + const displayCurrency = draftTransaction?.currency ?? currency; + + const updateBillable = (billable: boolean) => { + updateBulkEditDraftTransaction({billable}); + }; + + const updateReimbursable = (reimbursable: boolean) => { + updateBulkEditDraftTransaction({reimbursable}); + }; + + const fields = [ + { + description: translate('iou.amount'), + title: draftTransaction?.amount ? convertToDisplayString(Math.abs(draftTransaction.amount), displayCurrency) : '', + route: ROUTES.SEARCH_EDIT_MULTIPLE_AMOUNT_RHP, + disabled: hasCustomUnitTransaction || hasPartiallyEditableTransaction, + }, + { + 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, + disabled: hasCustomUnitTransaction, + }, + { + description: translate('common.date'), + title: draftTransaction?.created ?? '', + route: ROUTES.SEARCH_EDIT_MULTIPLE_DATE_RHP, + disabled: hasPartiallyEditableTransaction, + }, + { + 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, + }, + ...(isTaxTrackingEnabled + ? [ + { + description: translate('iou.taxRate'), + title: draftTransaction?.taxCode ? (getTaxName(policy, draftTransaction) ?? '') : '', + route: ROUTES.SEARCH_EDIT_MULTIPLE_TAX_RHP, + disabled: hasCustomUnitTransaction, + }, + ] + : []), + ]; + + return ( + + + + + {translate('search.bulkActions.editMultipleDescription')} + {fields.map((field) => ( + Navigation.navigate(field.route)} + shouldShowRightIcon={!field.disabled} + disabled={field.disabled} + interactive={!field.disabled} + /> + ))} + {areSelectedTransactionsBillable && ( + + {translate('common.billable')} + + + )} + {areSelectedTransactionsReimbursable && ( + + {translate('common.reimbursable')} + + + )} + +