diff --git a/src/CONST/index.ts b/src/CONST/index.ts index eac7a929fbda..1d650b2296ec 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3029,6 +3029,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, @@ -4241,6 +4243,7 @@ const CONST = { TAX_RATE: 'taxRate', TAX_AMOUNT: 'taxAmount', REIMBURSABLE: 'reimbursable', + BILLABLE: 'billable', REPORT: 'report', }, FOOTER: { @@ -7015,6 +7018,7 @@ const CONST = { TAG: 'tag', }, BULK_ACTION_TYPES: { + EDIT: 'edit', EXPORT: 'export', APPROVE: 'approve', PAY: 'pay', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index f613d8008311..4739a2f17d34 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1006,6 +1006,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', @@ -1141,6 +1147,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; [ONYXKEYS.FORMS.EXPENSE_RULE_FORM]: FormTypes.ExpenseRuleForm; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index c926950cfc69..fdac678c0b51 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -178,6 +178,19 @@ const ROUTES = { }, }, SEARCH_REJECT_REASON_RHP: 'search/reject', + 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: { + route: 'search/edit-multiple/tag/:tagListIndex', + getRoute: (tagListIndex = 0) => `search/edit-multiple/tag/${tagListIndex}` as const, + }, + SEARCH_EDIT_MULTIPLE_BILLABLE_RHP: 'search/edit-multiple/billable', + SEARCH_EDIT_MULTIPLE_REIMBURSABLE_RHP: 'search/edit-multiple/reimbursable', + SEARCH_EDIT_MULTIPLE_TAX_RHP: 'search/edit-multiple/tax', MOVE_TRANSACTIONS_SEARCH_RHP: { route: 'search/move-transactions/search/:backTo?', getRoute: (backTo?: string) => { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 137777611024..b478b7e7127b 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -96,6 +96,16 @@ const SCREENS = { TRANSACTION_HOLD_REASON_RHP: 'Search_Transaction_Hold_Reason_RHP', TRANSACTION_HOLD_REASON_SEARCH: 'Search_Transaction_Hold_Reason_Search', SEARCH_REJECT_REASON_RHP: 'Search_Reject_Reason_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_BILLABLE_RHP: 'Search_Edit_Multiple_Billable_RHP', + EDIT_MULTIPLE_REIMBURSABLE_RHP: 'Search_Edit_Multiple_Reimbursable_RHP', + EDIT_MULTIPLE_TAX_RHP: 'Search_Edit_Multiple_Tax_RHP', TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP: 'Search_Transactions_Change_Report_Search', }, SETTINGS: { diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 184d96977288..85af75005de7 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -74,7 +74,7 @@ import { isTrackExpenseReportNew, shouldEnableNegative, } from '@libs/ReportUtils'; -import {hasEnabledTags} from '@libs/TagsOptionsListUtils'; +import {hasEnabledTags, shouldShowDependentTagList} from '@libs/TagsOptionsListUtils'; import { getBillable, getCurrency, @@ -84,7 +84,6 @@ import { getOriginalAmountForDisplay, getOriginalTransactionWithSplitInfo, getReimbursable, - getTagArrayFromName, getTagForDisplay, getTaxName, hasMissingSmartscanFields, @@ -707,30 +706,7 @@ function MoneyRequestView({ const tagForDisplay = getTagForDisplay(updatedTransaction ?? transaction, index); let shouldShow = false; if (hasDependentTags) { - if (index === 0) { - shouldShow = true; - } else { - const prevTagValue = getTagForDisplay(transaction, index - 1); - if (!prevTagValue) { - shouldShow = false; - } else { - const parentTag = getTagArrayFromName(transactionTag ?? '') - .slice(0, index) - .join(':'); - - const availableTags = Object.values(tags).filter((policyTag) => { - const filterRegex = policyTag.rules?.parentTagsFilter; - if (!filterRegex) { - return true; - } - - const regex = new RegExp(filterRegex); - return regex.test(parentTag ?? ''); - }); - - shouldShow = availableTags.some((tag) => tag.enabled); - } - } + shouldShow = shouldShowDependentTagList(index, transactionTag, tags); } else { shouldShow = !!tagForDisplay || (canEdit && hasEnabledOptions(tags)); } diff --git a/src/components/TaxPicker.tsx b/src/components/TaxPicker.tsx index 4026cdae2858..3b152b4db483 100644 --- a/src/components/TaxPicker.tsx +++ b/src/components/TaxPicker.tsx @@ -40,9 +40,25 @@ type TaxPickerProps = { * If enabled, the content will have a bottom padding equal to account for the safe bottom area inset. */ addBottomSafeAreaPadding?: boolean; + + /** + * If enabled, allows deselecting the currently selected tax rate by tapping it again. + * When disabled (default), tapping the selected tax rate will dismiss the picker without calling onSubmit. + */ + allowDeselect?: boolean; }; -function TaxPicker({selectedTaxRate = '', policyID, transactionID, onSubmit, action, iouType, onDismiss = Navigation.goBack, addBottomSafeAreaPadding}: TaxPickerProps) { +function TaxPicker({ + selectedTaxRate = '', + policyID, + transactionID, + onSubmit, + action, + iouType, + onDismiss = Navigation.goBack, + addBottomSafeAreaPadding, + allowDeselect = false, +}: TaxPickerProps) { const {translate, localeCompare} = useLocalize(); const [searchValue, setSearchValue] = useState(''); const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, {canBeMissing: true}); @@ -89,7 +105,8 @@ function TaxPicker({selectedTaxRate = '', policyID, transactionID, onSubmit, act const selectedOptionKey = sections?.at(0)?.data?.find((taxRate) => taxRate.searchText === selectedTaxRate)?.keyForList; const handleSelectRow = (newSelectedOption: TaxRatesOption) => { - if (selectedOptionKey === newSelectedOption.keyForList) { + // If deselection is not allowed and the same option is selected, just dismiss + if (!allowDeselect && selectedOptionKey === newSelectedOption.keyForList) { onDismiss(); return; } diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index 5fa6b5845b47..b55f8b4751d1 100644 --- a/src/hooks/useSelectedTransactionsActions.ts +++ b/src/hooks/useSelectedTransactionsActions.ts @@ -4,6 +4,7 @@ import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import {useDelegateNoAccessActions, useDelegateNoAccessState} from '@components/DelegateNoAccessModalProvider'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import {useSearchContext} from '@components/Search/SearchContext'; +import {initBulkEditDraftTransaction} from '@libs/actions/IOU'; import {unholdRequest} from '@libs/actions/IOU/Hold'; import {initSplitExpense} from '@libs/actions/IOU/Split'; import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; @@ -16,6 +17,7 @@ import { canDeleteCardTransactionByLiabilityType, canDeleteTransaction, canEditFieldOfMoneyRequest, + canEditMultipleTransactions, canHoldUnholdReportAction, canRejectReportAction, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, @@ -76,13 +78,27 @@ 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', + 'Pencil', + ] as const); + const {duplicateTransactions, duplicateTransactionViolations} = useDuplicateTransactionsAndViolations(selectedTransactionIDs); const isReportArchived = useReportIsArchived(report?.reportID); const {deleteTransactions} = useDeleteTransactions({report, reportActions, policy}); @@ -184,6 +200,20 @@ function useSelectedTransactionsActions({ let computedOptions: Array> = []; if (selectedTransactionIDs.length) { 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: () => { + initBulkEditDraftTransaction(selectedTransactionIDs); + 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; @@ -395,6 +425,7 @@ function useSelectedTransactionsActions({ onSelected: showDeleteModal, }); } + computedOptions = options; } diff --git a/src/languages/de.ts b/src/languages/de.ts index f00219dad4e6..de5844fc519a 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7131,6 +7131,9 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und topMerchants: 'Top-Händler', 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 f711778f0a9c..6874809032d7 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7076,6 +7076,10 @@ const translations = { spendOverTime: 'Spend over time', 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 06ade9e3ed55..f17ad78b0a27 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6894,6 +6894,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 3205fae5b60c..4f3106aa577a 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7155,6 +7155,9 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip topMerchants: 'Commerçants principaux', 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 74802c1c8262..4e8739264ec4 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7117,6 +7117,9 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo topMerchants: 'Commercianti principali', 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 d42ce59c6be8..165a7d6d6602 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7047,6 +7047,9 @@ ${reportName} topMerchants: '上位加盟店', groupedExpenses: 'グループ化された経費', bulkActions: { + editMultiple: '複数を編集', + editMultipleTitle: '複数の経費を編集', + editMultipleDescription: '変更は選択したすべての経費に適用され、以前に設定された値は上書きされます。', approve: '承認', pay: '支払う', delete: '削除', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 995afbd652a2..cfa5c84c7955 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7102,6 +7102,9 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar topMerchants: 'Topverkopers', 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 2141601c619c..7e9e6fc12000 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7085,6 +7085,9 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i topMerchants: 'Najlepsi sprzedawcy', groupedExpenses: 'zgrupowane 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 6b50e300a449..3c010f828fc8 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7087,6 +7087,9 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e topMerchants: 'Principais comerciantes', 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 f68eba7a15c8..36e68fd05b71 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6947,6 +6947,9 @@ ${reportName} topMerchants: '热门商家', 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 0f210d6c661d..68dbb7a8f679 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 cb9c4ed7be79..c0d5257e5893 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -203,6 +203,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', @@ -748,6 +749,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/DebugUtils.ts b/src/libs/DebugUtils.ts index 6707b8ac8d79..48866ca8d9b0 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -993,6 +993,8 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) return validateNumber(value); case 'iouRequestType': return validateConstantEnum(value, CONST.IOU.REQUEST_TYPE); + case 'selectedTransactionIDs': + return validateArray(value, 'string'); case 'participants': return validateArray>(value, { accountID: 'number', @@ -1087,6 +1089,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) reportName: CONST.RED_BRICK_ROAD_PENDING_ACTION, routes: CONST.RED_BRICK_ROAD_PENDING_ACTION, transactionID: CONST.RED_BRICK_ROAD_PENDING_ACTION, + selectedTransactionIDs: CONST.RED_BRICK_ROAD_PENDING_ACTION, tag: CONST.RED_BRICK_ROAD_PENDING_ACTION, transactionType: CONST.RED_BRICK_ROAD_PENDING_ACTION, isFromGlobalCreate: CONST.RED_BRICK_ROAD_PENDING_ACTION, diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index b2c6d72d2329..68edf449a0a5 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -974,6 +974,16 @@ 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_BILLABLE_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleBooleanPage').default, + [SCREENS.SEARCH.EDIT_MULTIPLE_REIMBURSABLE_RHP]: () => require('../../../../pages/Search/SearchEditMultiple/SearchEditMultipleBooleanPage').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 96d1f270340d..514b9e2234da 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1860,6 +1860,16 @@ const config: LinkingOptions['config'] = { exact: true, }, [SCREENS.SEARCH.SEARCH_REJECT_REASON_RHP]: ROUTES.SEARCH_REJECT_REASON_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.route, + [SCREENS.SEARCH.EDIT_MULTIPLE_BILLABLE_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_BILLABLE_RHP, + [SCREENS.SEARCH.EDIT_MULTIPLE_REIMBURSABLE_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_REIMBURSABLE_RHP, + [SCREENS.SEARCH.EDIT_MULTIPLE_TAX_RHP]: ROUTES.SEARCH_EDIT_MULTIPLE_TAX_RHP, [SCREENS.SEARCH.TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP]: { path: ROUTES.MOVE_TRANSACTIONS_SEARCH_RHP.route, exact: true, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index f006433283fa..0712d2280b1a 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -4765,6 +4765,57 @@ 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, + areReportsSelected = false, +): boolean { + if (areReportsSelected) { + return false; + } + + 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 isApproved = isReportApproved({report}); + + if (isApproved || isSettled(report)) { + return false; + } + + 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.EDIT_REQUEST_FIELD.BILLABLE, + CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, + CONST.EDIT_REQUEST_FIELD.TAX_RATE, + ]; + + 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 * @@ -4790,6 +4841,7 @@ function canEditFieldOfMoneyRequest( CONST.EDIT_REQUEST_FIELD.DISTANCE_RATE, CONST.EDIT_REQUEST_FIELD.REIMBURSABLE, CONST.EDIT_REQUEST_FIELD.REPORT, + CONST.EDIT_REQUEST_FIELD.BILLABLE, ]; if (!isMoneyRequestAction(reportAction) || !canEditMoneyRequest(reportAction, isChatReportArchived, report, policy, linkedTransaction)) { @@ -4805,6 +4857,10 @@ function canEditFieldOfMoneyRequest( const moneyRequestReport = report ?? (iouMessage?.IOUReportID ? (getReport(iouMessage?.IOUReportID, allReports) ?? ({} as Report)) : ({} as Report)); const transaction = linkedTransaction ?? allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${iouMessage?.IOUTransactionID}`] ?? ({} as Transaction); + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.BILLABLE && isInvoiceReport(moneyRequestReport) && isReportApproved({report: moneyRequestReport})) { + return false; + } + if (isSettled(String(moneyRequestReport.reportID)) || isReportIDApproved(String(moneyRequestReport.reportID))) { return false; } @@ -12919,6 +12975,7 @@ export { canHoldUnholdReportAction, canEditReportPolicy, canEditFieldOfMoneyRequest, + canEditMultipleTransactions, canEditMoneyRequest, canEditReportAction, canEditReportDescription, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index aee06956b98a..d4e5a37a9074 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -4588,6 +4588,40 @@ 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; +} + function filterValidHasValues(hasValues: string[] | undefined, type: SearchDataTypes | undefined, translate: LocalizedTranslate): string[] | undefined { if (!hasValues || !type) { return undefined; @@ -4654,6 +4688,7 @@ function shouldShowDeleteOption( } export { + getSearchBulkEditPolicyID, getSuggestedSearches, getDefaultActionableSearchMenuItem, getListItem, diff --git a/src/libs/TagsOptionsListUtils.ts b/src/libs/TagsOptionsListUtils.ts index b1e93e683785..b829fdf2161e 100644 --- a/src/libs/TagsOptionsListUtils.ts +++ b/src/libs/TagsOptionsListUtils.ts @@ -3,9 +3,10 @@ import type {LocaleContextProps, LocalizedTranslate} from '@components/LocaleCon import CONST from '@src/CONST'; import type {Policy, PolicyTag, PolicyTagLists, PolicyTags, Transaction} from '@src/types/onyx'; import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import {insertTagIntoTransactionTagsString} from './IOUUtils'; import {hasEnabledOptions} from './OptionsListUtils'; import type {Option} from './OptionsListUtils'; -import {getCleanedTagName, getTagLists, hasDependentTags as hasDependentTagsPolicyUtils, isMultiLevelTags as isMultiLevelTagsPolicyUtils} from './PolicyUtils'; +import {getCleanedTagName, getTagList, getTagLists, hasDependentTags as hasDependentTagsPolicyUtils, isMultiLevelTags as isMultiLevelTagsPolicyUtils} from './PolicyUtils'; import tokenizedSearch from './tokenizedSearch'; import {getTagArrayFromName, getTagForDisplay} from './TransactionUtils'; @@ -29,6 +30,16 @@ type TagVisibility = { shouldShow: boolean; }; +type UpdatedTransactionTagParams = { + transactionTag: string; + selectedTagName: string; + currentTag: string; + tagListIndex: number; + policyTags: OnyxEntry; + hasDependentTags: boolean; + hasMultipleTagLists: boolean; +}; + /** * Transforms the provided tags into option objects. * @@ -225,6 +236,35 @@ function getTagVisibility({ }); } +/** + * Determines whether a dependent tag list should be shown based on the selected parent tag + * and available enabled tags for the current level. + */ +function shouldShowDependentTagList(tagListIndex: number, transactionTag: string | undefined, tags: PolicyTags | undefined): boolean { + if (tagListIndex === 0) { + return true; + } + + const tagParts = getTagArrayFromName(transactionTag ?? ''); + const previousTagValue = tagParts.at(tagListIndex - 1); + if (!previousTagValue) { + return false; + } + + const parentTag = tagParts.slice(0, tagListIndex).join(':'); + const availableTags = Object.values(tags ?? {}).filter((policyTag) => { + const filterRegex = policyTag.rules?.parentTagsFilter; + if (!filterRegex) { + return true; + } + + const regex = new RegExp(filterRegex); + return regex.test(parentTag ?? ''); + }); + + return availableTags.some((tag) => tag.enabled); +} + /** * Checks if any tag from policy tag lists exists in the transaction tag string. * @@ -250,6 +290,45 @@ function hasMatchingTag(policyTagLists: OnyxEntry, transactionTa }); } +function getUpdatedTransactionTag({transactionTag, selectedTagName, currentTag, tagListIndex, policyTags, hasDependentTags, hasMultipleTagLists}: UpdatedTransactionTagParams): string { + const isSelectedTag = selectedTagName === currentTag; + + if (hasDependentTags) { + const tagParts = transactionTag ? getTagArrayFromName(transactionTag) : []; + + if (isSelectedTag) { + // Deselect: clear this and all child tags. + tagParts.splice(tagListIndex); + } else { + // Select new tag: replace this index and clear child tags. + tagParts.splice(tagListIndex, tagParts.length - tagListIndex, selectedTagName); + + const policyTagLists = getTagLists(policyTags); + // Auto-select subsequent tags if there is only one enabled tag + for (let i = tagListIndex + 1; i < policyTagLists.length; i++) { + const availableNextLevelTags = getTagList(policyTags, i); + const enabledTags = Object.values(availableNextLevelTags.tags).filter((tag) => tag.enabled); + + if (enabledTags.length === 1) { + // If there is only one enabled tag, we can auto-select it. + const firstTag = enabledTags.at(0); + if (firstTag) { + tagParts.push(firstTag.name); + } + } else { + // If there are no enabled tags or more than one, stop auto-selecting. + break; + } + } + } + + return tagParts.join(':'); + } + + // Independent tags (fallback): use comma-separated list. + return insertTagIntoTransactionTagsString(transactionTag, isSelectedTag ? '' : selectedTagName, tagListIndex, hasMultipleTagLists); +} + /** * Gets enabled tags filtered by parent tag at a specific index level. * @@ -274,5 +353,5 @@ function getEnabledTags(tags: PolicyTags, tag: string, index: number) { }); } -export {getTagsOptions, getTagListSections, hasEnabledTags, sortTags, getTagVisibility, hasMatchingTag, getEnabledTags}; +export {getTagsOptions, getTagListSections, hasEnabledTags, sortTags, getTagVisibility, hasMatchingTag, getUpdatedTransactionTag, shouldShowDependentTagList, getEnabledTags}; export type {SelectedTagOption, TagVisibility, TagOption}; diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 6758f7874983..eecb1983cc8f 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -94,6 +94,7 @@ import { import { getAllReportActions, getIOUActionForReportID, + getIOUActionForTransactionID, getLastVisibleAction, getLastVisibleMessage, getOriginalMessage, @@ -136,6 +137,7 @@ import { buildOptimisticSubmittedReportAction, buildOptimisticUnapprovedReportAction, canBeAutoReimbursed, + canEditFieldOfMoneyRequest, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, findSelfDMReportID, generateReportID, @@ -4411,6 +4413,56 @@ function calculateDiffAmount( return null; } +function getUpdatedMoneyRequestReportData( + iouReport: OnyxTypes.OnyxInputOrEntry, + updatedTransaction: OnyxTypes.OnyxInputOrEntry, + transaction: OnyxEntry, + isTransactionOnHold: boolean, + policy: OnyxEntry, + actorAccountID?: number, + transactionChanges?: TransactionChanges, +) { + const calculatedDiffAmount = calculateDiffAmount(iouReport, updatedTransaction, transaction); + const isTotalIndeterminate = calculatedDiffAmount === null; + const diff = calculatedDiffAmount ?? 0; + + let updatedMoneyRequestReport: OnyxTypes.OnyxInputOrEntry; + if (!iouReport) { + updatedMoneyRequestReport = null; + } else if ((isExpenseReport(iouReport) || isInvoiceReportReportUtils(iouReport)) && !Number.isNaN(iouReport.total) && iouReport.total !== undefined) { + // For expense report, the amount is negative, so we should subtract total from diff + updatedMoneyRequestReport = { + ...iouReport, + total: iouReport.total - diff, + }; + if (!transaction?.reimbursable && typeof updatedMoneyRequestReport.nonReimbursableTotal === 'number') { + updatedMoneyRequestReport.nonReimbursableTotal -= diff; + } + if (updatedTransaction && transaction?.reimbursable !== updatedTransaction?.reimbursable && typeof updatedMoneyRequestReport.nonReimbursableTotal === 'number') { + updatedMoneyRequestReport.nonReimbursableTotal += updatedTransaction.reimbursable ? -updatedTransaction.amount : updatedTransaction.amount; + } + if (!isTransactionOnHold) { + if (typeof updatedMoneyRequestReport.unheldTotal === 'number') { + updatedMoneyRequestReport.unheldTotal -= diff; + } + if (!transaction?.reimbursable && typeof updatedMoneyRequestReport.unheldNonReimbursableTotal === 'number') { + updatedMoneyRequestReport.unheldNonReimbursableTotal -= diff; + } + if (updatedTransaction && transaction?.reimbursable !== updatedTransaction?.reimbursable && typeof updatedMoneyRequestReport.unheldNonReimbursableTotal === 'number') { + updatedMoneyRequestReport.unheldNonReimbursableTotal += updatedTransaction.reimbursable ? -updatedTransaction.amount : updatedTransaction.amount; + } + } + // Only recalculate reportName when reimbursable status changes and the report uses a formula title + if (transactionChanges && 'reimbursable' in transactionChanges) { + updatedMoneyRequestReport = maybeUpdateReportNameForFormulaTitle(updatedMoneyRequestReport, policy); + } + } else { + updatedMoneyRequestReport = updateIOUOwnerAndTotal(iouReport, actorAccountID ?? CONST.DEFAULT_NUMBER_ID, diff, getCurrency(transaction), false, true, isTransactionOnHold); + } + + return {updatedMoneyRequestReport, isTotalIndeterminate}; +} + type GetUpdateMoneyRequestParamsType = { transactionID: string | undefined; transactionThreadReport: OnyxEntry; @@ -4618,53 +4670,16 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U } // Step 4: Compute the IOU total and update the report preview message (and report header) so LHN amount owed is correct. - const calculatedDiffAmount = calculateDiffAmount(iouReport, updatedTransaction, transaction); - // If calculatedDiffAmount is null it means we cannot calculate the new iou report total from front-end due to currency differences. - const isTotalIndeterminate = calculatedDiffAmount === null; - const diff = calculatedDiffAmount ?? 0; - - let updatedMoneyRequestReport: OnyxTypes.OnyxInputOrEntry; - if (!iouReport) { - updatedMoneyRequestReport = null; - } else if ((isExpenseReport(iouReport) || isInvoiceReportReportUtils(iouReport)) && !Number.isNaN(iouReport.total) && iouReport.total !== undefined) { - // For expense report, the amount is negative, so we should subtract total from diff - updatedMoneyRequestReport = { - ...iouReport, - total: iouReport.total - diff, - }; - if (!transaction?.reimbursable && typeof updatedMoneyRequestReport.nonReimbursableTotal === 'number') { - updatedMoneyRequestReport.nonReimbursableTotal -= diff; - } - if (updatedTransaction && transaction?.reimbursable !== updatedTransaction?.reimbursable && typeof updatedMoneyRequestReport.nonReimbursableTotal === 'number') { - updatedMoneyRequestReport.nonReimbursableTotal += updatedTransaction.reimbursable ? -updatedTransaction.amount : updatedTransaction.amount; - } - if (!isTransactionOnHold) { - if (typeof updatedMoneyRequestReport.unheldTotal === 'number') { - updatedMoneyRequestReport.unheldTotal -= diff; - } - if (!transaction?.reimbursable && typeof updatedMoneyRequestReport.unheldNonReimbursableTotal === 'number') { - updatedMoneyRequestReport.unheldNonReimbursableTotal -= diff; - } - if (updatedTransaction && transaction?.reimbursable !== updatedTransaction?.reimbursable && typeof updatedMoneyRequestReport.unheldNonReimbursableTotal === 'number') { - updatedMoneyRequestReport.unheldNonReimbursableTotal += updatedTransaction.reimbursable ? -updatedTransaction.amount : updatedTransaction.amount; - } - } - - // Only recalculate reportName when reimbursable status changes and the report uses a formula title - if ('reimbursable' in transactionChanges) { - updatedMoneyRequestReport = maybeUpdateReportNameForFormulaTitle(updatedMoneyRequestReport, policy); - } - } else { - updatedMoneyRequestReport = updateIOUOwnerAndTotal( - iouReport, - updatedReportAction?.actorAccountID ?? CONST.DEFAULT_NUMBER_ID, - diff, - getCurrency(transaction), - false, - true, - isTransactionOnHold, - ); - } + // If the diff is indeterminate we cannot calculate the new iou report total from front-end due to currency differences. + const {updatedMoneyRequestReport, isTotalIndeterminate} = getUpdatedMoneyRequestReportData( + iouReport, + updatedTransaction, + transaction, + isTransactionOnHold, + policy, + updatedReportAction?.actorAccountID, + transactionChanges, + ); optimisticData.push( { @@ -13673,6 +13688,339 @@ function addReportApprover( API.write(WRITE_COMMANDS.ADD_REPORT_APPROVER, params, onyxData); } +function removeUnchangedBulkEditFields( + transactionChanges: TransactionChanges, + transaction: OnyxTypes.Transaction, + baseIOUReport: OnyxEntry | null, + policy: OnyxEntry, +): TransactionChanges { + const iouType = isInvoiceReportReportUtils(baseIOUReport ?? undefined) ? CONST.IOU.TYPE.INVOICE : CONST.IOU.TYPE.SUBMIT; + const allowNegative = shouldEnableNegative(baseIOUReport ?? undefined, policy, iouType); + const currentDetails = getTransactionDetails(transaction, undefined, policy, allowNegative); + if (!currentDetails) { + return transactionChanges; + } + + const changeKeys = Object.keys(transactionChanges) as Array; + if (changeKeys.length === 0) { + return transactionChanges; + } + + let filteredChanges: TransactionChanges = {}; + + for (const field of changeKeys) { + const nextValue = transactionChanges[field]; + const currentValue = currentDetails[field as keyof TransactionDetails]; + + if (nextValue !== currentValue) { + filteredChanges = { + ...filteredChanges, + [field]: nextValue, + }; + } + } + + return filteredChanges; +} + +function updateMultipleMoneyRequests( + transactionIDs: string[], + changes: TransactionChanges, + policy: OnyxEntry, + reports: OnyxCollection, + transactions: OnyxCollection, + reportActions: OnyxCollection, +) { + // Track running totals per report so multiple edits in the same report compound correctly. + const optimisticReportsByID: Record = {}; + for (const transactionID of transactionIDs) { + const transaction = transactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + if (!transaction) { + continue; + } + + const iouReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`] ?? null; + const baseIouReport = iouReport?.reportID ? (optimisticReportsByID[iouReport.reportID] ?? iouReport) : iouReport; + const isFromExpenseReport = isExpenseReport(baseIouReport); + + const transactionReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction.reportID}`] ?? {}; + const reportAction = getIOUActionForTransactionID(Object.values(transactionReportActions), transactionID); + const transactionThreadReportID = transaction.transactionThreadReportID ?? reportAction?.childReportID; + const transactionThread = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; + + const canEditField = (field: ValueOf) => { + return canEditFieldOfMoneyRequest(reportAction, field, undefined, false, undefined, transaction, iouReport, policy); + }; + + let 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 !== undefined && 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.BILLABLE)) { + transactionChanges.billable = changes.billable; + } + if (changes.reimbursable !== undefined && canEditField(CONST.EDIT_REQUEST_FIELD.REIMBURSABLE)) { + transactionChanges.reimbursable = changes.reimbursable; + } + + transactionChanges = removeUnchangedBulkEditFields(transactionChanges, transaction, baseIouReport, policy); + + 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 !== undefined) { + updates.amount = transactionChanges.amount; + } + if (transactionChanges.billable !== undefined) { + updates.billable = transactionChanges.billable; + } + if (transactionChanges.reimbursable !== undefined) { + updates.reimbursable = transactionChanges.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((field) => [field, 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((field) => [field, {[DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericEditFailureMessage')}]), + ); + + // Build updated transaction + const updatedTransaction = getUpdatedTransaction({ + transaction, + transactionChanges, + isFromExpenseReport, + policy, + }); + const isTransactionOnHold = isOnHold(transaction); + + // Optimistic transaction update + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...updatedTransaction, + pendingFields, + isLoading: false, + errorFields: null, + }, + }); + + // To build proper offline update message, we need to include the currency + const optimisticTransactionChanges = + transactionChanges?.amount !== undefined && !transactionChanges?.currency ? {...transactionChanges, currency: getCurrency(transaction)} : transactionChanges; + + // Build optimistic modified expense report action + const optimisticReportAction = buildOptimisticModifiedExpenseReportAction( + transactionThread, + transaction, + optimisticTransactionChanges, + isFromExpenseReport, + policy, + updatedTransaction, + ); + + const {updatedMoneyRequestReport, isTotalIndeterminate} = getUpdatedMoneyRequestReportData( + baseIouReport, + updatedTransaction, + transaction, + isTransactionOnHold, + policy, + optimisticReportAction?.actorAccountID, + ); + + if (updatedMoneyRequestReport) { + if (updatedMoneyRequestReport.reportID) { + optimisticReportsByID[updatedMoneyRequestReport.reportID] = updatedMoneyRequestReport; + } + optimisticData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: {...updatedMoneyRequestReport, ...(isTotalIndeterminate && {pendingFields: {total: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}})}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.parentReportID}`, + value: getOutstandingChildRequest(updatedMoneyRequestReport), + }, + ); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: {pendingAction: null, ...(isTotalIndeterminate && {pendingFields: {total: null}})}, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport?.reportID}`, + value: {...iouReport, ...(isTotalIndeterminate && {pendingFields: {total: null}})}, + }); + } + + // Optimistic report action + if (transactionThreadReportID) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, + value: { + [modifiedExpenseReportActionID]: { + ...optimisticReportAction, + reportActionID: modifiedExpenseReportActionID, + }, + }, + }); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: { + lastReadTime: optimisticReportAction.created, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: { + lastReadTime: transactionThread?.lastReadTime, + }, + }); + } + + // Success data - clear pending fields + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingFields: clearedPendingFields, + }, + }); + + if (transactionThreadReportID) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, + value: { + [modifiedExpenseReportActionID]: {pendingAction: null}, + }, + }); + } + + // 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 bulk-edit draft transaction under one fixed placeholder ID. + * We keep a single draft in Onyx to store the shared edits for a multi-select, + * then apply those edits to each real transaction later. The placeholder ID is + * just the storage key and never equals any actual transactionID. + */ +function initBulkEditDraftTransaction(selectedTransactionIDs: string[]) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`, { + transactionID: CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID, + selectedTransactionIDs, + }); +} + +/** + * 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: NullishDeep) { + Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`, transactionChanges); +} + export { approveMoneyRequest, canApproveIOU, @@ -13776,6 +14124,10 @@ export { getUpdateMoneyRequestParams, getUpdateTrackExpenseParams, getReportPreviewAction, + updateMultipleMoneyRequests, + initBulkEditDraftTransaction, + clearBulkEditDraftTransaction, + updateBulkEditDraftTransaction, mergePolicyRecentlyUsedCurrencies, mergePolicyRecentlyUsedCategories, getAllPersonalDetails, diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleAmountPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleAmountPage.tsx new file mode 100644 index 000000000000..0aabd45b5365 --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleAmountPage.tsx @@ -0,0 +1,135 @@ +import {useFocusEffect} from '@react-navigation/native'; +import React, {useMemo, useRef, useState} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +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 {isInvoiceReport, shouldEnableNegative} from '@libs/ReportUtils'; +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 [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 selectedTransactionIDs = useMemo(() => draftTransaction?.selectedTransactionIDs ?? [], [draftTransaction?.selectedTransactionIDs]); + + 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 allowNegative = useMemo(() => { + if (!selectedTransactionIDs.length) { + return false; + } + + return selectedTransactionIDs.every((transactionID) => { + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + if (!transaction) { + return false; + } + + const transactionReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`]; + const iouReport = + transactionReport?.type === CONST.REPORT.TYPE.CHAT && transactionReport?.parentReportID + ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionReport.parentReportID}`] + : transactionReport; + if (!iouReport) { + return false; + } + + const transactionPolicy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport.policyID}`]; + const iouType = isInvoiceReport(iouReport) ? CONST.IOU.TYPE.INVOICE : CONST.IOU.TYPE.SUBMIT; + return shouldEnableNegative(iouReport, transactionPolicy, iouType); + }); + }, [selectedTransactionIDs, allTransactions, allReports, policies]); + const amountForForm = allowNegative ? amount : Math.abs(amount); + + const saveAmount = (currentMoney: CurrentMoney) => { + const newAmount = convertToBackendAmount(Number.parseFloat(currentMoney.amount)); + // TODO: Currency update should be handled in a separate PR + updateBulkEditDraftTransaction({ + amount: newAmount, + }); + 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} + allowFlippingAmount={allowNegative} + /> + + ); +} + +export default SearchEditMultipleAmountPage; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleBooleanPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleBooleanPage.tsx new file mode 100644 index 000000000000..579769c2ceb7 --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleBooleanPage.tsx @@ -0,0 +1,83 @@ +import {useRoute} from '@react-navigation/native'; +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import type {ListItem} from '@components/SelectionList/ListItem/types'; +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 CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import SCREENS from '@src/SCREENS'; + +type BooleanOption = ListItem & { + value: boolean; +}; + +function SearchEditMultipleBooleanPage() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const route = useRoute(); + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`, {canBeMissing: true}); + + const isBillableScreen = route.name === SCREENS.SEARCH.EDIT_MULTIPLE_BILLABLE_RHP; + const selectedValue = isBillableScreen ? draftTransaction?.billable : draftTransaction?.reimbursable; + const title = isBillableScreen ? translate('common.billable') : translate('common.reimbursable'); + const testID = isBillableScreen ? 'SearchEditMultipleBillablePage' : 'SearchEditMultipleReimbursablePage'; + + const items = useMemo( + () => [ + { + value: true, + keyForList: CONST.SEARCH.BOOLEAN.YES, + text: translate('common.yes'), + isSelected: selectedValue === true, + }, + { + value: false, + keyForList: CONST.SEARCH.BOOLEAN.NO, + text: translate('common.no'), + isSelected: selectedValue === false, + }, + ], + [selectedValue, translate], + ); + + const selectValue = (item: BooleanOption) => { + const shouldClear = selectedValue === item.value; + if (isBillableScreen) { + updateBulkEditDraftTransaction({billable: shouldClear ? null : item.value}); + } else { + updateBulkEditDraftTransaction({reimbursable: shouldClear ? null : item.value}); + } + Navigation.goBack(); + }; + + return ( + + + + + + + ); +} + +export default SearchEditMultipleBooleanPage; diff --git a/src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage.tsx b/src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage.tsx new file mode 100644 index 000000000000..7512909b0848 --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import CategoryPicker from '@components/CategoryPicker'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import type {ListItem} from '@components/SelectionListWithSections/types'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import {updateBulkEditDraftTransaction} from '@libs/actions/IOU'; +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 [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 selectedTransactionIDs = draftTransaction?.selectedTransactionIDs ?? []; + + const policyID = getSearchBulkEditPolicyID(selectedTransactionIDs, activePolicyID, allTransactions, allReports); + + const currentCategory = draftTransaction?.category ?? ''; + + const saveCategory = (item: ListItem) => { + const nextCategory = item.searchText ?? ''; + if (!nextCategory || nextCategory === currentCategory) { + updateBulkEditDraftTransaction({ + category: null, + }); + Navigation.goBack(); + return; + } + updateBulkEditDraftTransaction({ + category: nextCategory, + }); + Navigation.goBack(); + }; + + return ( + + + + + ); +} + +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..89b1a34dc4c3 --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleDatePage.tsx @@ -0,0 +1,77 @@ +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 ( + + + + + + + + + ); +} + +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..69da6931d2aa --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleDescriptionPage.tsx @@ -0,0 +1,87 @@ +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 {addErrorMessage} from '@libs/ErrorUtils'; +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 validate = (value: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + + if ((value.description?.length ?? 0) > CONST.DESCRIPTION_LIMIT) { + addErrorMessage(errors, INPUT_IDS.DESCRIPTION, translate('common.error.characterLimitExceedCounter', value.description?.length ?? 0, CONST.DESCRIPTION_LIMIT)); + } + + return errors; + }; + + const saveDescription = (value: FormOnyxValues) => { + const newDescription = value.description?.trim() ?? ''; + updateBulkEditDraftTransaction({ + comment: {comment: newDescription}, + }); + Navigation.goBack(); + }; + + return ( + + + + + + + + + ); +} + +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..55a2d23f4947 --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleMerchantPage.tsx @@ -0,0 +1,82 @@ +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 ( + + + + + + + + + ); +} + +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..d12240272e8b --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx @@ -0,0 +1,284 @@ +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 Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {clearBulkEditDraftTransaction, updateMultipleMoneyRequests} from '@libs/actions/IOU'; +import {convertToDisplayStringWithoutCurrency} from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {hasEnabledOptions} from '@libs/OptionsListUtils'; +import {getCleanedTagName, getTagLists, hasDependentTags as hasDependentTagsPolicyUtils} from '@libs/PolicyUtils'; +import {getIOUActionForTransactionID} from '@libs/ReportActionsUtils'; +import {canEditFieldOfMoneyRequest} from '@libs/ReportUtils'; +import {getSearchBulkEditPolicyID} from '@libs/SearchUIUtils'; +import {hasEnabledTags, shouldShowDependentTagList} from '@libs/TagsOptionsListUtils'; +import {getTagArrayFromName, getTaxName, isDistanceRequest, isManagedCardTransaction, isPerDiemRequest} from '@libs/TransactionUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; +import type {TransactionChanges} from '@src/types/onyx/Transaction'; +import getCommonDependentTag from './SearchEditMultipleUtils'; + +function SearchEditMultiplePage() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {clearSelectedTransactions} = 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 selectedTransactionIDs = draftTransaction?.selectedTransactionIDs ?? []; + + 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); + }); + + const policyID = getSearchBulkEditPolicyID(selectedTransactionIDs, activePolicyID, allTransactions, allReports); + + const policy = policyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] : undefined; + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, {canBeMissing: true}); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {canBeMissing: true}); + const policyTagLists = getTagLists(policyTags); + + const isTaxTrackingEnabled = !!policy?.tax?.trackingEnabled; + const areCategoriesEnabled = !!policy?.areCategoriesEnabled && hasEnabledOptions(policyCategories ?? {}); + const areTagsEnabled = !!policy?.areTagsEnabled && hasEnabledTags(policyTagLists); + + useEffect(() => { + return () => { + clearBulkEditDraftTransaction(); + }; + }, []); + + const save = () => { + if (!draftTransaction) { + return; + } + + const changes: TransactionChanges = {}; + if (draftTransaction.amount !== undefined) { + changes.amount = draftTransaction.amount; + } + 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.dismissToPreviousRHP(); + return; + } + + updateMultipleMoneyRequests(selectedTransactionIDs, changes, policy, allReports, allTransactions, allReportActions); + clearSelectedTransactions(true); + + Navigation.dismissToPreviousRHP(); + }; + + const currency = policy?.outputCurrency ?? CONST.CURRENCY.USD; + + // TODO: Currency editing and currency symbol should be handled in a separate PR + const selectedTransactionsList = selectedTransactionIDs.map((transactionID) => allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]); + const commonDependentTag = getCommonDependentTag(selectedTransactionsList); + const dependentTagSource = draftTransaction?.tag === undefined ? commonDependentTag : draftTransaction?.tag; + const tagsArray = getTagArrayFromName(draftTransaction?.tag ?? ''); + const hasDependentTags = hasDependentTagsPolicyUtils(policy, policyTags); + const tagFields: Array<{description: string; title: string; route: Route; disabled?: boolean}> = areTagsEnabled + ? policyTagLists.flatMap((tagList, tagListIndex) => { + const tagName = tagsArray.at(tagListIndex) ?? ''; + const tagTitle = tagName ? getCleanedTagName(tagName) : ''; + const description = policyTagLists.length > 1 ? tagList.name : translate('common.tag'); + let shouldShow = true; + + if (hasDependentTags) { + shouldShow = shouldShowDependentTagList(tagListIndex, dependentTagSource, tagList.tags); + } + + if (!shouldShow) { + return []; + } + + return [ + { + description: description || translate('common.tag'), + title: tagTitle, + route: ROUTES.SEARCH_EDIT_MULTIPLE_TAG_RHP.getRoute(tagListIndex), + disabled: false, + }, + ]; + }) + : []; + + const getBooleanTitle = (value?: boolean) => { + if (value === undefined) { + return ''; + } + return value ? translate('common.yes') : translate('common.no'); + }; + + const fields: Array<{description: string; title: string; route: Route; disabled?: boolean}> = [ + { + description: translate('iou.amount'), + title: draftTransaction?.amount !== undefined ? convertToDisplayStringWithoutCurrency(draftTransaction.amount, currency) : '', + 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, + }, + ...(areCategoriesEnabled + ? [ + { + description: translate('common.category'), + title: draftTransaction?.category ?? '', + route: ROUTES.SEARCH_EDIT_MULTIPLE_CATEGORY_RHP, + }, + ] + : []), + ...tagFields, + ...(isTaxTrackingEnabled + ? [ + { + description: translate('iou.taxRate'), + title: draftTransaction?.taxCode ? (getTaxName(policy, draftTransaction) ?? '') : '', + route: ROUTES.SEARCH_EDIT_MULTIPLE_TAX_RHP, + disabled: hasCustomUnitTransaction, + }, + ] + : []), + ...(areSelectedTransactionsBillable + ? [ + { + description: translate('common.billable'), + title: getBooleanTitle(draftTransaction?.billable), + route: ROUTES.SEARCH_EDIT_MULTIPLE_BILLABLE_RHP, + }, + ] + : []), + ...(areSelectedTransactionsReimbursable + ? [ + { + description: translate('common.reimbursable'), + title: getBooleanTitle(draftTransaction?.reimbursable), + route: ROUTES.SEARCH_EDIT_MULTIPLE_REIMBURSABLE_RHP, + }, + ] + : []), + ]; + + return ( + + + + + {translate('search.bulkActions.editMultipleDescription')} + {fields.map((field) => ( + Navigation.navigate(field.route)} + shouldShowRightIcon={!field.disabled} + disabled={field.disabled} + interactive={!field.disabled} + /> + ))} + +