diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 3c5bdb4b1be7..4ee9d947ec86 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -850,6 +850,7 @@ const CONST = { PERSONAL_CARD_IMPORT: 'personalCardImport', SUGGESTED_FOLLOWUPS: 'suggestedFollowups', FREEZE_CARD: 'freezeCard', + BULK_EDIT: 'bulkEdit', NEW_MANUAL_EXPENSE_FLOW: 'newManualExpenseFlow', }, BUTTON_STATES: { @@ -3248,6 +3249,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', // This is the transactionID used when going through the distance split expense flow so that it mimics a draft transaction OPTIMISTIC_DISTANCE_SPLIT_TRANSACTION_ID: '2', // Note: These payment types are used when building IOU reportAction message values in the server and should @@ -4556,6 +4559,7 @@ const CONST = { TAX_RATE: 'taxRate', TAX_AMOUNT: 'taxAmount', REIMBURSABLE: 'reimbursable', + BILLABLE: 'billable', REPORT: 'report', }, FOOTER: { @@ -7357,6 +7361,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 6f1a861e3cc9..63544894ebd5 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1047,6 +1047,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', @@ -1187,6 +1193,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 a93593e4d813..e4ad270ed6d8 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -243,6 +243,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 2f9f07fed9e3..f4e989a0b943 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -98,6 +98,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 1dc34d4459d4..2625d923429d 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -77,7 +77,7 @@ import { isTrackExpenseReportNew, shouldEnableNegative, } from '@libs/ReportUtils'; -import {hasEnabledTags} from '@libs/TagsOptionsListUtils'; +import {hasEnabledTags, shouldShowDependentTagList} from '@libs/TagsOptionsListUtils'; import { getBillable, getCurrency, @@ -87,7 +87,6 @@ import { getOriginalAmountForDisplay, getOriginalTransactionWithSplitInfo, getReimbursable, - getTagArrayFromName, getTagForDisplay, getTaxName, hasMissingSmartscanFields, @@ -738,30 +737,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 8e7b6e800f17..ce898c868b9c 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}`); @@ -119,7 +135,8 @@ function TaxPicker({selectedTaxRate = '', policyID, transactionID, onSubmit, act const currentTaxRateValue = taxCode ? taxRates?.taxes?.[taxCode]?.value : undefined; const hasMatchingTaxValue = taxValue === undefined || currentTaxRateValue === taxValue; - if (isSameTaxCode && hasMatchingTaxValue) { + // If deselection is not allowed and the same option is selected, just dismiss + if (!allowDeselect && isSameTaxCode && hasMatchingTaxValue) { onDismiss(); return; } diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index c1b5970f2482..0fef9201cbba 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -37,6 +37,7 @@ import Navigation from '@libs/Navigation/Navigation'; import {getConnectedIntegration} from '@libs/PolicyUtils'; import {getSecondaryExportReportActions, isMergeActionForSelectedTransactions} from '@libs/ReportSecondaryActionUtils'; import { + canEditMultipleTransactions, getIntegrationIcon, getReportOrDraftReport, isBusinessInvoiceRoom, @@ -49,7 +50,7 @@ import {navigateToSearchRHP, shouldShowDeleteOption} from '@libs/SearchUIUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; import {hasTransactionBeenRejected, isDeletedTransaction} from '@libs/TransactionUtils'; import variables from '@styles/variables'; -import {canIOUBePaid, dismissRejectUseExplanation} from '@userActions/IOU'; +import {canIOUBePaid, dismissRejectUseExplanation, initBulkEditDraftTransaction} from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -63,6 +64,7 @@ import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; import useLocalize from './useLocalize'; import useNetwork from './useNetwork'; import useOnyx from './useOnyx'; +import usePermissions from './usePermissions'; import usePersonalPolicy from './usePersonalPolicy'; import useSelfDMReport from './useSelfDMReport'; import useTheme from './useTheme'; @@ -98,6 +100,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const {accountID} = currentUserPersonalDetails; const allTransactions = useAllTransactions(); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); const [allReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS); const selfDMReport = useSelfDMReport(); const [lastPaymentMethods] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD); @@ -110,6 +113,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); const personalPolicy = usePersonalPolicy(); const [userBillingGraceEndPeriods] = useOnyx(ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_USER_BILLING_GRACE_PERIOD_END); + const {isBetaEnabled} = usePermissions(); const [ownerBillingGraceEndPeriod] = useOnyx(ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END); const undeleteTransactions = useUndeleteTransactions(); @@ -671,7 +675,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { ); const onBulkPaySelectedRef = useRef(onBulkPaySelected); - onBulkPaySelectedRef.current = onBulkPaySelected; + useEffect(() => { + onBulkPaySelectedRef.current = onBulkPaySelected; + }); const stableOnBulkPaySelected = useCallback((paymentMethod?: PaymentMethodType, additionalData?: BulkPaySelectionData) => { onBulkPaySelectedRef.current?.(paymentMethod, additionalData); }, []); @@ -886,6 +892,30 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return [exportButtonOption]; } + const isExpenseReportSearch = typeExpenseReport || searchResults?.search.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT; + const selectedTransactionsList = Object.values(selectedTransactions) + .map((transaction) => transaction.transaction) + .filter((transaction): transaction is Transaction => !!transaction); + const canEditMultiple = + canEditMultipleTransactions(selectedTransactionsList, allReportActions, allReports, policies, isExpenseReportSearch, searchResults?.data) && isBetaEnabled(CONST.BETAS.BULK_EDIT); + + if (canEditMultiple) { + options.push({ + icon: expensifyIcons.Pencil, + text: translate('search.bulkActions.editMultiple'), + value: CONST.SEARCH.BULK_ACTION_TYPES.EDIT, + shouldCloseModalOnSelect: true, + onSelected: () => { + const selectedTransactionIDs = Object.keys(selectedTransactions).filter((transactionID) => { + const selectedTransaction = selectedTransactions[transactionID]; + return !!selectedTransaction?.transaction?.transactionID || !!allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + }); + initBulkEditDraftTransaction(selectedTransactionIDs); + Navigation.navigate(ROUTES.SEARCH_EDIT_MULTIPLE_TRANSACTIONS_RHP); + }, + }); + } + const areSelectedTransactionsIncludedInReports = selectedTransactionsKeys.every((id) => selectedTransactions[id].reportID ? selectedReportIDs.includes(selectedTransactions[id].reportID) : true, ); @@ -1190,6 +1220,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { expensifyIcons.ArrowCollapse, expensifyIcons.DocumentMerge, expensifyIcons.ArrowSplit, + expensifyIcons.Pencil, expensifyIcons.Trashcan, expensifyIcons.RotateLeft, expensifyIcons.Exclamation, @@ -1205,6 +1236,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { selectedTransactionReportIDs, selectedPolicyIDs, policies, + allReportActions, integrationsExportTemplates, csvExportLayouts, allReports, @@ -1242,6 +1274,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { userBillingGraceEndPeriods, ownerBillingGraceEndPeriod, currentSearchKey, + allTransactions, + isBetaEnabled, + shouldShowBusinessBankAccountOptions, ]); const handleOfflineModalClose = useCallback(() => { diff --git a/src/hooks/useSearchBulkEditPolicyID.ts b/src/hooks/useSearchBulkEditPolicyID.ts new file mode 100644 index 000000000000..8c112083cdca --- /dev/null +++ b/src/hooks/useSearchBulkEditPolicyID.ts @@ -0,0 +1,29 @@ +import {useSearchStateContext} from '@components/Search/SearchContext'; +import {getSearchBulkEditPolicyID} from '@libs/SearchUIUtils'; +import {withSnapshotReports, withSnapshotTransactions} from '@pages/Search/SearchEditMultiple/SearchEditMultipleUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useOnyx from './useOnyx'; + +/** + * Resolves the bulk-edit policyID from the selected transactions, using + * snapshot-merged collections so that expenses only present in the search + * snapshot (e.g. after a hard refresh) are still resolved correctly. + */ +function useSearchBulkEditPolicyID(): string | undefined { + const {currentSearchResults} = useSearchStateContext(); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`); + const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + + const snapshotData = currentSearchResults?.data; + const mergedTransactions = withSnapshotTransactions(allTransactions, snapshotData); + const mergedReports = withSnapshotReports(allReports, snapshotData); + + const selectedTransactionIDs = draftTransaction?.selectedTransactionIDs ?? []; + + return getSearchBulkEditPolicyID(selectedTransactionIDs, activePolicyID, mergedTransactions, mergedReports); +} + +export default useSearchBulkEditPolicyID; diff --git a/src/hooks/useSelectedTransactionsActions.ts b/src/hooks/useSelectedTransactionsActions.ts index a3acb09805fa..725ef47c0038 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 {useSearchActionsContext, useSearchStateContext} from '@components/Search/SearchContext'; +import {initBulkEditDraftTransaction} from '@libs/actions/IOU'; import {unholdRequest} from '@libs/actions/IOU/Hold'; import {setupMergeTransactionDataAndNavigate} from '@libs/actions/MergeTransaction'; import {exportReportToCSV} from '@libs/actions/Report'; @@ -16,6 +17,7 @@ import { canDeleteCardTransactionByLiabilityType, canDeleteTransaction, canEditFieldOfMoneyRequest, + canEditMultipleTransactions, canHoldUnholdReportAction, canRejectReportAction, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, @@ -40,6 +42,7 @@ import {useMemoizedLazyExpensifyIcons} from './useLazyAsset'; import useLocalize from './useLocalize'; import useNetworkWithOfflineStatus from './useNetworkWithOfflineStatus'; import useOnyx from './useOnyx'; +import usePermissions from './usePermissions'; import useReportIsArchived from './useReportIsArchived'; // We do not use PRIMARY_REPORT_ACTIONS or SECONDARY_REPORT_ACTIONS because they weren't meant to be used in this situation. `value` property of returned options is later ignored. @@ -78,15 +81,31 @@ function useSelectedTransactionsActions({ const {selectedTransactionIDs, currentSearchHash, selectedTransactions: selectedTransactionsMeta} = useSearchStateContext(); const {clearSelectedTransactions} = useSearchActionsContext(); const allTransactions = useAllTransactions(); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH); const [integrationsExportTemplates] = useOnyx(ONYXKEYS.NVP_INTEGRATION_SERVER_EXPORT_TEMPLATES); const [csvExportLayouts] = useOnyx(ONYXKEYS.NVP_CSV_EXPORT_LAYOUTS); const [allTransactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS); - 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 {isBetaEnabled} = usePermissions(); const {deleteTransactions} = useDeleteTransactions({report, reportActions, policy}); const {login, accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); const selectedTransactionsList = selectedTransactionIDs.reduce((acc, transactionID) => { @@ -186,6 +205,20 @@ function useSelectedTransactionsActions({ let computedOptions: Array> = []; if (selectedTransactionIDs.length) { const options = []; + + const canEditMultiple = canEditMultipleTransactions(selectedTransactionsList, allReportActions, allReports, allPolicies) && isBetaEnabled(CONST.BETAS.BULK_EDIT); + + 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; @@ -412,6 +445,7 @@ function useSelectedTransactionsActions({ }, }); } + computedOptions = options; } diff --git a/src/languages/de.ts b/src/languages/de.ts index 7d46462206c2..ab0adfbaa4d9 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7346,6 +7346,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 a9a620fdc593..18a5d044b3be 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7342,6 +7342,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 5f0a8e211129..073133b087fd 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7245,6 +7245,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 5cb6ed391df5..a9c40a417604 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7370,6 +7370,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 2eaf50b46502..8cc03982939e 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7334,6 +7334,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 6395dccd9dda..b0470bf7968b 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7248,6 +7248,9 @@ ${reportName} topMerchants: '上位加盟店', groupedExpenses: 'グループ化された経費', bulkActions: { + editMultiple: '複数を編集', + editMultipleTitle: '複数の経費を編集', + editMultipleDescription: '変更は選択したすべての経費に適用され、以前に設定された値は上書きされます。', approve: '承認', pay: '支払う', delete: '削除', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 2582ad6bb143..c9128518e31f 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7312,6 +7312,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 3bcc856b9c75..df8dfcdc16bb 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7301,6 +7301,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 d6595356844c..556692aa27b1 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7304,6 +7304,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 f7e2e47f4500..676f74aaa912 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7124,6 +7124,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 310b54a942c7..8f114b9a35e4 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -206,6 +206,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', @@ -766,6 +767,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 edd20ed68091..6455a9ee08c5 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -999,6 +999,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', @@ -1094,6 +1096,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) routes: CONST.RED_BRICK_ROAD_PENDING_ACTION, routeDistanceMeters: 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 43f7871b74ba..6555935a32f2 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -991,6 +991,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 d0998e2be621..ce4c91abbe33 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1912,6 +1912,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 f26ca0601a91..723d54312ae0 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -55,6 +55,7 @@ import type { ReportMetadata, ReportNameValuePairs, ReportViolationName, + SearchResults, Task, Transaction, TransactionViolation, @@ -4846,6 +4847,71 @@ 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, + searchSnapshotData?: SearchResults['data'], +): boolean { + if (areReportsSelected) { + return false; + } + + if (selectedTransactions.length < 2) { + return false; + } + + for (const transaction of selectedTransactions) { + const isUnreportedExpense = !transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + if (isUnreportedExpense) { + continue; + } + + // Do not allow editing split expenses in bulk + if (transaction.comment?.source === CONST.IOU.TYPE.SPLIT || transaction.comment?.splits) { + return false; + } + + const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction.reportID}` as const; + const actionsForReport = {...(searchSnapshotData?.[reportActionsKey] ?? {}), ...(reportActions?.[reportActionsKey] ?? {})}; + const reportAction = getIOUActionForTransactionID(Object.values(actionsForReport), transaction.transactionID); + const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}` as const; + const report = reports?.[reportKey] ?? (searchSnapshotData?.[reportKey] as OnyxEntry); + 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, fieldToEdit: field, transaction, report, policy})); + + if (!isTransactionEditable) { + return false; + } + } + + return true; +} + /** * Checks if the current user can edit the provided property of an expense * @@ -4880,6 +4946,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, transaction)) { @@ -4894,6 +4961,10 @@ function canEditFieldOfMoneyRequest({ const iouMessage = getOriginalMessage(reportAction); const moneyRequestReport = report ?? (iouMessage?.IOUReportID ? (getReport(iouMessage?.IOUReportID, allReports) ?? ({} as Report)) : ({} as Report)); + 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; } @@ -13420,6 +13491,7 @@ export { canHoldUnholdReportAction, canEditReportPolicy, canEditFieldOfMoneyRequest, + canEditMultipleTransactions, canEditMoneyRequest, canEditReportAction, canEditReportDescription, diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 1ddfa1ec6ab9..5cb7a778b9de 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -4959,6 +4959,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: HasFilterValues | undefined, type: SearchDataTypes | undefined, translate: LocalizedTranslate): HasFilterValues | undefined { if (!hasValues || !type) { return undefined; @@ -5090,6 +5124,7 @@ function applySelectionToItem( } export { + getSearchBulkEditPolicyID, getSuggestedSearches, getListItem, getSections, 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 7b22962ecd85..729025f48128 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -38,7 +38,7 @@ import type { UpdateMoneyRequestParams, } from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; -import {convertToDisplayString} from '@libs/CurrencyUtils'; +import {convertToBackendAmount, convertToDisplayString, getCurrencyDecimals} from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import {registerDeferredWrite} from '@libs/deferredLayoutWrite'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; @@ -89,6 +89,7 @@ import { import { getAllReportActions, getIOUActionForReportID, + getIOUActionForTransactionID, getLastVisibleAction, getLastVisibleMessage, getOriginalMessage, @@ -133,6 +134,7 @@ import { buildOptimisticSubmittedReportAction, buildOptimisticUnapprovedReportAction, canBeAutoReimbursed, + canEditFieldOfMoneyRequest, canSubmitAndIsAwaitingForCurrentUser, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, findSelfDMReportID, @@ -200,6 +202,7 @@ import {endSubmitFollowUpActionSpan, setPendingSubmitFollowUpAction} from '@libs import { allHavePendingRTERViolation, buildOptimisticTransaction, + calculateTaxAmount, getAmount, getCategoryTaxCodeAndAmount, getClearedPendingFields, @@ -207,6 +210,7 @@ import { getDistanceInMeters, getMerchant, getRateID, + getTaxValue, getUpdatedTransaction, getWaypoints, hasAnyTransactionWithoutRTERViolation, @@ -239,7 +243,8 @@ import {buildPolicyData, generatePolicyID} from '@userActions/Policy/Policy'; import type {BuildPolicyDataKeys} from '@userActions/Policy/Policy'; import {buildOptimisticPolicyRecentlyUsedTags} from '@userActions/Policy/Tag'; import type {GuidedSetupData} from '@userActions/Report'; -import {buildInviteToRoomOnyxData, completeOnboarding, notifyNewAction, optimisticReportLastData} from '@userActions/Report'; +import {buildInviteToRoomOnyxData, completeOnboarding, createTransactionThreadReport, notifyNewAction, optimisticReportLastData} from '@userActions/Report'; +import {resolveDetachReceiptConflicts} from '@userActions/RequestConflictUtils'; import {mergeTransactionIdsHighlightOnSearchRoute, sanitizeWaypointsForAPI, stringifyWaypointsForAPI} from '@userActions/Transaction'; import {getRemoveDraftTransactionsByIDsData, removeDraftTransaction, removeDraftTransactionsByIDs} from '@userActions/TransactionEdit'; import {getOnboardingMessages} from '@userActions/Welcome/OnboardingFlow'; @@ -266,7 +271,6 @@ import type {SearchDataTypes} from '@src/types/onyx/SearchResults'; import type {Comment, Receipt, ReceiptSource, Routes, SplitShares, TransactionChanges, TransactionCustomUnit, WaypointCollection} from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {resolveDetachReceiptConflicts} from '../RequestConflictUtils'; type IOURequestType = ValueOf; @@ -4040,6 +4044,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; @@ -4255,53 +4309,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( { @@ -13194,6 +13211,522 @@ 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; +} + +type UpdateMultipleMoneyRequestsParams = { + transactionIDs: string[]; + changes: TransactionChanges; + policy: OnyxEntry; + reports: OnyxCollection; + transactions: OnyxCollection; + reportActions: OnyxCollection; + policyCategories: OnyxCollection; + policyTags: OnyxCollection; + hash?: number; + allPolicies?: OnyxCollection; +}; + +function updateMultipleMoneyRequests({ + transactionIDs, + changes, + policy, + reports, + transactions, + reportActions, + policyCategories, + policyTags, + hash, + allPolicies, +}: UpdateMultipleMoneyRequestsParams) { + // 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}`] ?? undefined; + const baseIouReport = iouReport?.reportID ? (optimisticReportsByID[iouReport.reportID] ?? iouReport) : iouReport; + const isFromExpenseReport = isExpenseReport(baseIouReport); + + const transactionReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transaction.reportID}`] ?? {}; + let reportAction = getIOUActionForTransactionID(Object.values(transactionReportActions), transactionID); + + // Track expenses created via self DM are stored with reportID = UNREPORTED_REPORT_ID ('0') + // because they have never been submitted to a report. As a result, the lookup above returns + // nothing — the IOU action is stored under the self DM report (chat with yourself, unique + // per user) rather than under transaction.reportID. Without this fallback we cannot resolve + // the transaction thread, which means no optimistic MODIFIED_EXPENSE comment would be + // generated during bulk edit for these expenses. + if (!reportAction && (!transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID)) { + const selfDMReportID = findSelfDMReportID(reports); + if (selfDMReportID) { + const selfDMReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReportID}`] ?? {}; + reportAction = getIOUActionForTransactionID(Object.values(selfDMReportActions), transactionID); + } + } + + let transactionThreadReportID = transaction.transactionThreadReportID ?? reportAction?.childReportID; + let transactionThread = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; + + // Offline-created expenses can be missing a transaction thread until it's opened once. + // Ensure the thread exists before adding optimistic MODIFIED_EXPENSE actions so + // bulk-edit comments are visible immediately while still offline. + let didCreateThreadInThisIteration = false; + if (!transactionThreadReportID && iouReport?.reportID) { + const optimisticTransactionThread = createTransactionThreadReport(undefined, currentUserEmail, userAccountID, iouReport, reportAction, transaction); + if (optimisticTransactionThread?.reportID) { + transactionThreadReportID = optimisticTransactionThread.reportID; + transactionThread = optimisticTransactionThread; + didCreateThreadInThisIteration = true; + } + } + + const isUnreportedExpense = !transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; + // Category, tag, tax, and billable only apply to expense/invoice reports and unreported (track) expenses. + // For plain IOU transactions these fields are not applicable and must be silently skipped. + const supportsExpenseFields = isUnreportedExpense || isFromExpenseReport || isInvoiceReportReportUtils(baseIouReport ?? undefined); + // Use the transaction's own policy for all per-transaction checks (permissions, tax, change-diffing). + // Falls back to the shared bulk-edit policy when the transaction's workspace cannot be resolved. + const transactionPolicy = (iouReport?.policyID ? allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${iouReport.policyID}`] : undefined) ?? policy; + const canEditField = (field: ValueOf) => { + // Unreported (track) expenses have no report, so there is no reportAction to validate against. + // They are never approved or settled, so all bulk-editable fields are allowed. + if (isUnreportedExpense) { + return true; + } + + return canEditFieldOfMoneyRequest({reportAction, fieldToEdit: field, transaction, report: iouReport, policy: transactionPolicy}); + }; + + 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 !== undefined && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.CATEGORY)) { + transactionChanges.category = changes.category; + } + if (changes.tag && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.TAG)) { + transactionChanges.tag = changes.tag; + } + if (changes.comment && canEditField(CONST.EDIT_REQUEST_FIELD.DESCRIPTION)) { + transactionChanges.comment = getParsedComment(changes.comment); + } + if (changes.taxCode && supportsExpenseFields && canEditField(CONST.EDIT_REQUEST_FIELD.TAX_RATE)) { + transactionChanges.taxCode = changes.taxCode; + const taxValue = getTaxValue(transactionPolicy, transaction, changes.taxCode); + const decimals = getCurrencyDecimals(getCurrency(transaction)); + const effectiveAmount = transactionChanges.amount !== undefined ? Math.abs(transactionChanges.amount) : Math.abs(getAmount(transaction)); + const taxAmount = calculateTaxAmount(taxValue, effectiveAmount, decimals); + transactionChanges.taxAmount = convertToBackendAmount(taxAmount); + } + + if (changes.billable !== undefined && supportsExpenseFields && 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, transactionPolicy); + + 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 !== undefined) { + 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.taxAmount !== undefined) { + updates.taxAmount = transactionChanges.taxAmount; + } + 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: Array< + OnyxUpdate< + typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS | typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS + > + > = []; + const successData: Array< + OnyxUpdate< + typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS | typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS + > + > = []; + const failureData: Array< + OnyxUpdate< + typeof ONYXKEYS.COLLECTION.TRANSACTION | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS | typeof ONYXKEYS.COLLECTION.REPORT | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS + > + > = []; + const snapshotOptimisticData: Array> = []; + const snapshotFailureData: Array> = []; + + // 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(Object.keys(pendingFields).map((field) => [field, getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage')])); + + // Build updated transaction + const updatedTransaction = getUpdatedTransaction({ + transaction, + transactionChanges, + isFromExpenseReport, + policy: transactionPolicy, + }); + const isTransactionOnHold = isOnHold(transaction); + + // Optimistically update violations so they disappear immediately when the edited field resolves them. + // Skip for unreported expenses: they have no iouReport context so isSelfDM() returns false, + // which would incorrectly trigger policy-required violations (e.g. missingCategory). + let optimisticViolationsData: ReturnType | undefined; + let currentTransactionViolations: OnyxTypes.TransactionViolation[] | undefined; + if (transactionPolicy && !isUnreportedExpense) { + currentTransactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`] ?? []; + let optimisticViolations = + transactionChanges.amount !== undefined || transactionChanges.created || transactionChanges.currency + ? currentTransactionViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.DUPLICATED_TRANSACTION) + : currentTransactionViolations; + optimisticViolations = + transactionChanges.category !== undefined && transactionChanges.category === '' + ? optimisticViolations.filter((violation) => violation.name !== CONST.VIOLATIONS.CATEGORY_OUT_OF_POLICY) + : optimisticViolations; + const transactionPolicyTagList = policyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${transactionPolicy?.id}`] ?? {}; + const transactionPolicyCategories = policyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${transactionPolicy?.id}`] ?? {}; + optimisticViolationsData = ViolationsUtils.getViolationsOnyxData( + updatedTransaction, + optimisticViolations, + transactionPolicy, + transactionPolicyTagList, + transactionPolicyCategories, + hasDependentTags(transactionPolicy, transactionPolicyTagList), + isInvoiceReportReportUtils(iouReport), + isSelfDM(iouReport), + iouReport, + isFromExpenseReport, + ); + optimisticData.push(optimisticViolationsData); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: currentTransactionViolations, + }); + } + + // Optimistic transaction update + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...updatedTransaction, + pendingFields, + isLoading: false, + errorFields: null, + }, + }); + + // Optimistically update the search snapshot so the search list reflects the + // new values immediately (the snapshot is the exclusive data source for search + // result rendering and is not automatically updated by the TRANSACTION write above). + if (hash) { + snapshotOptimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}` as const, + value: { + // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 + data: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: updatedTransaction, + ...(optimisticViolationsData && {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]: optimisticViolationsData.value}), + }, + }, + }); + snapshotFailureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${hash}` as const, + value: { + // @ts-expect-error - will be solved in https://github.com/Expensify/App/issues/73830 + data: { + [`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]: transaction, + ...(currentTransactionViolations && {[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]: currentTransactionViolations}), + }, + }, + }); + } + + // 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, + transactionPolicy, + updatedTransaction, + ); + + const {updatedMoneyRequestReport, isTotalIndeterminate} = getUpdatedMoneyRequestReportData( + baseIouReport, + updatedTransaction, + transaction, + isTransactionOnHold, + transactionPolicy, + optimisticReportAction?.actorAccountID, + transactionChanges, + ); + + 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 + let backfilledCreatedActionID: string | undefined; + if (transactionThreadReportID) { + // Backfill a CREATED action for threads never opened locally so + // MoneyRequestView renders and the skeleton doesn't loop offline. + // Skip when the thread was just created above (openReport handles it). + const threadReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`] ?? {}; + const hasCreatedAction = didCreateThreadInThisIteration || Object.values(threadReportActions).some((action) => action?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED); + const optimisticCreatedValue: Record> = {}; + if (!hasCreatedAction) { + const optimisticCreatedAction = buildOptimisticCreatedReportAction(CONST.REPORT.OWNER_EMAIL_FAKE); + optimisticCreatedAction.pendingAction = null; + backfilledCreatedActionID = optimisticCreatedAction.reportActionID; + optimisticCreatedValue[optimisticCreatedAction.reportActionID] = optimisticCreatedAction; + } + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`, + value: { + ...optimisticCreatedValue, + [modifiedExpenseReportActionID]: { + ...optimisticReportAction, + reportActionID: modifiedExpenseReportActionID, + }, + }, + }); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: { + lastReadTime: optimisticReportAction.created, + reportID: transactionThreadReportID, + }, + }); + 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}, + // Remove the backfilled CREATED action so it doesn't duplicate one from OpenReport + ...(backfilledCreatedActionID ? {[backfilledCreatedActionID]: 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: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericEditFailureMessage'), + }, + ...(backfilledCreatedActionID ? {[backfilledCreatedActionID]: null} : {}), + }, + }); + } + + const params = { + transactionID, + reportActionID: modifiedExpenseReportActionID, + updates: JSON.stringify(updates), + }; + + API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST, params, { + optimisticData: [...optimisticData, ...snapshotOptimisticData] as Array< + OnyxUpdate< + | typeof ONYXKEYS.COLLECTION.TRANSACTION + | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.SNAPSHOT + | typeof ONYXKEYS.COLLECTION.REPORT + | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS + > + >, + successData, + failureData: [...failureData, ...snapshotFailureData] as Array< + OnyxUpdate< + | typeof ONYXKEYS.COLLECTION.TRANSACTION + | typeof ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS + | typeof ONYXKEYS.COLLECTION.SNAPSHOT + | typeof ONYXKEYS.COLLECTION.REPORT + | typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS + > + >, + }); + } +} + +/** + * 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.set(`${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); +} + function rejectExpenseReport( report: OnyxTypes.Report, targetAccountID: number, @@ -13478,6 +14011,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..99d0ff3c5ef7 --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleAmountPage.tsx @@ -0,0 +1,171 @@ +import {useFocusEffect} from '@react-navigation/native'; +import React, {useMemo, useRef, useState} from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import {useSearchStateContext} 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 {isInvoiceReport, isIOUReport, 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'; +import {withSnapshotReports, withSnapshotTransactions} from './SearchEditMultipleUtils'; + +type CurrentMoney = {amount: string; currency: string}; + +function SearchEditMultipleAmountPage() { + const {translate} = useLocalize(); + const {currentSearchResults} = useSearchStateContext(); + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + + const snapshotData = currentSearchResults?.data; + const mergedTransactions = withSnapshotTransactions(allTransactions, snapshotData); + const mergedReports = withSnapshotReports(allReports, snapshotData); + + const textInput = useRef(null); + const focusTimeoutRef = useRef(null); + + const selectedTransactionIDs = useMemo(() => draftTransaction?.selectedTransactionIDs ?? [], [draftTransaction?.selectedTransactionIDs]); + + const policyID = getSearchBulkEditPolicyID(selectedTransactionIDs, activePolicyID, mergedTransactions, mergedReports); + 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 = mergedTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + if (!transaction) { + return false; + } + + // Self-DM track expenses have no IOU report — they always support negative amounts + if (!transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { + return true; + } + + const transactionReport = mergedReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`]; + const iouReport = + transactionReport?.type === CONST.REPORT.TYPE.CHAT && transactionReport?.parentReportID + ? mergedReports?.[`${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, mergedTransactions, mergedReports, policies]); + const isP2P = selectedTransactionIDs.some((transactionID) => { + const transaction = mergedTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + if (!transaction) { + return false; + } + const report = mergedReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`]; + return isIOUReport(report); + }); + + const isAnyInvoice = selectedTransactionIDs.some((transactionID) => { + const transaction = mergedTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + if (!transaction) { + return false; + } + const transactionReport = mergedReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transaction.reportID}`]; + const iouReport = + transactionReport?.type === CONST.REPORT.TYPE.CHAT && transactionReport?.parentReportID + ? mergedReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionReport.parentReportID}`] + : transactionReport; + return isInvoiceReport(iouReport); + }); + + 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..f467fdf001c5 --- /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}`); + + 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..c5c5ae732d1d --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultipleCategoryPage.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import CategoryPicker from '@components/CategoryPicker'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import type {ListItem} from '@components/SelectionList/types'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useSearchBulkEditPolicyID from '@hooks/useSearchBulkEditPolicyID'; +import {updateBulkEditDraftTransaction} from '@libs/actions/IOU'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +function SearchEditMultipleCategoryPage() { + const {translate} = useLocalize(); + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`); + + const policyID = useSearchBulkEditPolicyID(); + + 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..d0188800f5e5 --- /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}`); + + 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..d23491a63c28 --- /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}`); + + 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..b60ce401bc53 --- /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 {isInvalidMerchantValue, 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}`); + + const currentMerchant = draftTransaction?.merchant ?? ''; + + const validate = (value: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + const {isValid, byteLength} = isValidInputLength(value.merchant ?? '', CONST.MERCHANT_NAME_MAX_BYTES); + + const trimmedMerchant = (value.merchant ?? '').trim(); + if (trimmedMerchant && isInvalidMerchantValue(trimmedMerchant)) { + errors.merchant = translate('iou.error.invalidMerchant'); + } else 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..7b977e78bc30 --- /dev/null +++ b/src/pages/Search/SearchEditMultiple/SearchEditMultiplePage.tsx @@ -0,0 +1,317 @@ +import React, {useEffect} from 'react'; +import {View} from 'react-native'; +import type {ValueOf} from 'type-fest'; +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 {useSearchActionsContext, useSearchStateContext} 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 {canEditFieldOfMoneyRequest, isInvoiceReport, isIOUReport} from '@libs/ReportUtils'; +import {getSearchBulkEditPolicyID} from '@libs/SearchUIUtils'; +import {hasEnabledTags, shouldShowDependentTagList} from '@libs/TagsOptionsListUtils'; +import {getTagArrayFromName, getTaxName, isDistanceRequest, isManagedCardTransaction, isPerDiemRequest, isTimeRequest} 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 {getTransactionEditContext, withSnapshotReportActions, withSnapshotReports, withSnapshotTransactions} from './SearchEditMultipleUtils'; + +function SearchEditMultiplePage() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {currentSearchHash, currentSearchResults} = useSearchStateContext(); + const {clearSelectedTransactions} = useSearchActionsContext(); + const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_BULK_EDIT_TRANSACTION_ID}`); + const [allTransactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT); + const [allReportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS); + const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); + const [allPolicyCategories] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CATEGORIES); + + const snapshotData = currentSearchResults?.data; + const mergedTransactions = withSnapshotTransactions(allTransactions, snapshotData); + const mergedReportActions = withSnapshotReportActions(allReportActions, snapshotData); + const mergedReports = withSnapshotReports(allReports, snapshotData); + + const selectedTransactionIDs = draftTransaction?.selectedTransactionIDs ?? []; + + const selectedTransactionContexts = selectedTransactionIDs.flatMap((transactionID) => { + const context = getTransactionEditContext(transactionID, mergedTransactions, mergedReports, mergedReportActions, policies); + return context ? [context] : []; + }); + + const hasCustomUnitTransaction = selectedTransactionContexts.some(({transaction}) => isDistanceRequest(transaction) || isPerDiemRequest(transaction)); + + const hasPerDiemOrTimeTransaction = selectedTransactionContexts.some(({transaction}) => isPerDiemRequest(transaction) || isTimeRequest(transaction)); + + const isFieldDisabledForAnyTransaction = (field: ValueOf) => + selectedTransactionContexts.some(({transaction, report, reportAction, transactionPolicy}) => { + // Unreported expenses have no report actions yet but are always editable + if (!transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { + return false; + } + return !canEditFieldOfMoneyRequest({reportAction, fieldToEdit: field, transaction, report, policy: transactionPolicy}); + }); + + const hasPartiallyEditableTransaction = isFieldDisabledForAnyTransaction(CONST.EDIT_REQUEST_FIELD.AMOUNT); + + const hasPartiallyEditableMerchantTransaction = + isFieldDisabledForAnyTransaction(CONST.EDIT_REQUEST_FIELD.MERCHANT) || selectedTransactionContexts.some(({transaction}) => isDistanceRequest(transaction)); + + const hasPartiallyEditableTaxRateTransaction = + isFieldDisabledForAnyTransaction(CONST.EDIT_REQUEST_FIELD.TAX_RATE) || selectedTransactionContexts.some(({transaction}) => isDistanceRequest(transaction)); + + const hasPartiallyEditableDateTransaction = isFieldDisabledForAnyTransaction(CONST.EDIT_REQUEST_FIELD.DATE); + + const areSelectedTransactionsBillable = selectedTransactionContexts.every(({transaction, transactionPolicy}) => { + // Unreported expenses have no policy yet but billable is always applicable + if (!transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { + return true; + } + return transactionPolicy?.disabledFields?.defaultBillable === false || !!transaction.billable; + }); + + const areSelectedTransactionsReimbursable = selectedTransactionContexts.every(({transaction, report, transactionPolicy}) => { + // Unreported expenses have no policy yet but reimbursable is always applicable + if (!transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { + return true; + } + return !isIOUReport(report) && !isInvoiceReport(report) && transactionPolicy?.disabledFields?.reimbursable === false && !isManagedCardTransaction(transaction); + }); + + const policyID = getSearchBulkEditPolicyID(selectedTransactionIDs, activePolicyID, mergedTransactions, mergedReports); + + const policy = policyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] : undefined; + const policyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]; + const policyTags = allPolicyTags?.[`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`]; + const policyTagLists = getTagLists(policyTags); + + const isTaxTrackingEnabled = !hasPerDiemOrTimeTransaction && selectedTransactionContexts.every(({transactionPolicy}) => !!transactionPolicy?.tax?.trackingEnabled); + const areSelectedTransactionsExpenses = selectedTransactionContexts.every(({transaction, report}) => { + if (!transaction.reportID || transaction.reportID === CONST.REPORT.UNREPORTED_REPORT_ID) { + return true; + } + return !isIOUReport(report); + }); + const areCategoriesEnabled = areSelectedTransactionsExpenses && !!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 (typeof draftTransaction.billable === 'boolean') { + changes.billable = draftTransaction.billable; + } + if (typeof draftTransaction.reimbursable === 'boolean') { + changes.reimbursable = draftTransaction.reimbursable; + } + + if (Object.keys(changes).length === 0) { + Navigation.dismissToPreviousRHP(); + return; + } + + updateMultipleMoneyRequests({ + transactionIDs: selectedTransactionIDs, + changes, + policy, + reports: mergedReports, + transactions: mergedTransactions, + reportActions: mergedReportActions, + policyCategories: allPolicyCategories, + policyTags: allPolicyTags, + hash: currentSearchHash, + allPolicies: policies, + }); + // Bulk edit can start from report (ID-based selection) or search (map-based selection), + // so clear both stores to keep deselection behavior consistent. + clearSelectedTransactions(true); + clearSelectedTransactions(); + + Navigation.dismissToPreviousRHP(); + }; + + const currency = policy?.outputCurrency ?? CONST.CURRENCY.USD; + + // TODO: Currency editing and currency symbol should be handled in a separate PR + 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 = tagList.name || translate('common.tag'); + let shouldShow = true; + + if (hasDependentTags) { + shouldShow = shouldShowDependentTagList(tagListIndex, draftTransaction?.tag, 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; shouldParseTitle?: 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, + shouldParseTitle: true, + }, + { + description: translate('common.merchant'), + title: draftTransaction?.merchant ?? '', + route: ROUTES.SEARCH_EDIT_MULTIPLE_MERCHANT_RHP, + disabled: hasPartiallyEditableMerchantTransaction, + }, + { + description: translate('common.date'), + title: draftTransaction?.created ?? '', + route: ROUTES.SEARCH_EDIT_MULTIPLE_DATE_RHP, + disabled: hasPartiallyEditableDateTransaction, + }, + ...(areCategoriesEnabled + ? [ + { + description: translate('common.category'), + title: draftTransaction?.category ?? '', + route: ROUTES.SEARCH_EDIT_MULTIPLE_CATEGORY_RHP, + }, + ] + : []), + ...tagFields, + ...(isTaxTrackingEnabled + ? [ + { + description: policy?.taxRates?.name ?? translate('common.tax'), + title: draftTransaction?.taxCode ? (getTaxName(policy, draftTransaction) ?? '') : '', + route: ROUTES.SEARCH_EDIT_MULTIPLE_TAX_RHP, + disabled: hasPartiallyEditableTaxRateTransaction, + }, + ] + : []), + ...(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} + shouldParseTitle={field.shouldParseTitle} + /> + ))} + +