diff --git a/src/components/Modal/Global/HoldMenuModalWrapper.tsx b/src/components/Modal/Global/HoldMenuModalWrapper.tsx new file mode 100644 index 000000000000..96738afeb862 --- /dev/null +++ b/src/components/Modal/Global/HoldMenuModalWrapper.tsx @@ -0,0 +1,88 @@ +import React, {useState} from 'react'; +import DecisionModal from '@components/DecisionModal'; +import useHoldMenuSubmit from '@hooks/useHoldMenuSubmit'; +import type {ActionHandledType} from '@hooks/useHoldMenuSubmit'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; +import type {ModalProps} from './ModalContext'; + +type HoldMenuModalWrapperProps = ModalProps & { + reportID: string | undefined; + chatReportID: string | undefined; + requestType: ActionHandledType; + paymentType?: PaymentMethodType; + methodID?: number; + nonHeldAmount?: string; + fullAmount: string; + hasNonHeldExpenses?: boolean; + transactionCount: number; + onConfirm?: (full: boolean) => void; +}; + +function HoldMenuModalWrapper({ + closeModal, + reportID, + chatReportID, + requestType, + paymentType, + methodID, + nonHeldAmount = '0', + fullAmount, + hasNonHeldExpenses, + transactionCount, + onConfirm, +}: HoldMenuModalWrapperProps) { + const [isVisible, setIsVisible] = useState(true); + const {translate} = useLocalize(); + // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`); + + const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); + const transactions = Object.values(reportTransactions); + + const {onSubmit, isApprove} = useHoldMenuSubmit({ + moneyRequestReport, + chatReport, + requestType, + paymentType, + methodID, + onClose: () => setIsVisible(false), + onConfirm, + transactions, + }); + + return ( + setIsVisible(false)} + isVisible={isVisible} + prompt={ + hasNonHeldExpenses + ? translate(isApprove ? 'iou.confirmApprovalAmount' : 'iou.confirmPayAmount') + : translate(isApprove ? 'iou.confirmApprovalAllHoldAmount' : 'iou.confirmPayAllHoldAmount', {count: transactionCount}) + } + firstOptionText={hasNonHeldExpenses ? `${translate(isApprove ? 'iou.approveOnly' : 'iou.payOnly')} ${nonHeldAmount}` : undefined} + secondOptionText={`${translate(isApprove ? 'iou.approve' : 'iou.pay')} ${fullAmount}`} + onFirstOptionSubmit={() => onSubmit(false)} + onSecondOptionSubmit={() => onSubmit(true)} + isSmallScreenWidth={isSmallScreenWidth} + onModalHide={() => { + if (isVisible) { + return; + } + closeModal({action: 'CLOSE'}); + }} + /> + ); +} + +export default HoldMenuModalWrapper; +export type {ActionHandledType, HoldMenuModalWrapperProps}; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 15a52207a74c..f436a183b1eb 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -4,7 +4,7 @@ import {hasSeenTourSelector} from '@selectors/Onboarding'; import passthroughPolicyTagListSelector from '@selectors/PolicyTagList'; import {validTransactionDraftsSelector} from '@selectors/TransactionDraft'; import truncate from 'lodash/truncate'; -import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useMemo, useRef} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {InteractionManager, View} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -13,7 +13,6 @@ import useConfirmModal from '@hooks/useConfirmModal'; import useConfirmPendingRTERAndProceed from '@hooks/useConfirmPendingRTERAndProceed'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDecisionModal from '@hooks/useDecisionModal'; import useDefaultExpensePolicy from '@hooks/useDefaultExpensePolicy'; import useDeleteTransactions from '@hooks/useDeleteTransactions'; import useDuplicateTransactionsAndViolations from '@hooks/useDuplicateTransactionsAndViolations'; @@ -53,7 +52,6 @@ import {deleteAppReport, exportReportToCSV, exportReportToPDF, exportToIntegrati import {getExportTemplates, queueExportSearchWithTemplate, search} from '@libs/actions/Search'; import initSplitExpense from '@libs/actions/SplitExpenses'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import getPlatform from '@libs/getPlatform'; import {getExistingTransactionID} from '@libs/IOUUtils'; import Log from '@libs/Log'; import {getAllNonDeletedTransactions, getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportUtils'; @@ -92,11 +90,9 @@ import { getIntegrationIcon, getIntegrationNameFromExportMessage as getIntegrationNameFromExportMessageUtils, getNextApproverAccountID, - getNonHeldAndFullAmount, getPolicyExpenseChat, getReasonAndReportActionThatRequiresAttention, hasHeldExpenses as hasHeldExpensesReportUtils, - hasOnlyHeldExpenses as hasOnlyHeldExpensesReportUtils, hasOnlyNonReimbursableTransactions, hasUpdatedTotal, hasViolations as hasViolationsReportUtils, @@ -158,9 +154,9 @@ import HeaderWithBackButton from './HeaderWithBackButton'; import {KYCWallContext} from './KYCWall/KYCWallContext'; import {useLockedAccountActions, useLockedAccountState} from './LockedAccountModalProvider'; import {ModalActions} from './Modal/Global/ModalContext'; -import MoneyReportHeaderEducationalModals from './MoneyReportHeaderEducationalModals'; -import type {RejectModalAction} from './MoneyReportHeaderEducationalModals'; import MoneyReportHeaderKYCDropdown from './MoneyReportHeaderKYCDropdown'; +import MoneyReportHeaderModals from './MoneyReportHeaderModals'; +import {useMoneyReportHeaderModals} from './MoneyReportHeaderModalsContext'; import MoneyReportHeaderPrimaryAction from './MoneyReportHeaderPrimaryAction'; import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar'; import MoneyReportHeaderStatusBarSection from './MoneyReportHeaderStatusBarSection'; @@ -168,9 +164,6 @@ import MoneyReportHeaderStatusBarSkeleton from './MoneyReportHeaderStatusBarSkel import MoneyRequestReportNavigation from './MoneyRequestReportView/MoneyRequestReportNavigation'; import {usePersonalDetails} from './OnyxListItemProvider'; import type {PopoverMenuItem} from './PopoverMenu'; -import type {ActionHandledType} from './ProcessMoneyReportHoldMenu'; -import ProcessMoneyReportHoldMenu from './ProcessMoneyReportHoldMenu'; -import ReportPDFDownloadModal from './ReportPDFDownloadModal'; import {useSearchActionsContext, useSearchStateContext} from './Search/SearchContext'; import type {PaymentActionParams} from './SettlementButton/types'; import Text from './Text'; @@ -186,7 +179,19 @@ type MoneyReportHeaderProps = { onBackButtonPress: () => void; }; -function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = false, onBackButtonPress}: MoneyReportHeaderProps) { +function MoneyReportHeader({reportID, shouldDisplayBackButton = false, onBackButtonPress}: MoneyReportHeaderProps) { + return ( + + + + ); +} + +function MoneyReportHeaderContent({reportID: reportIDProp, shouldDisplayBackButton = false, onBackButtonPress}: MoneyReportHeaderProps) { const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDProp}`); const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); const [reportMetadataInternal] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportIDProp}`); @@ -349,7 +354,6 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa }, [isExported, reportActions]); const transactionViolations = useTransactionViolations(transaction?.transactionID); - const [isPDFModalVisible, setIsPDFModalVisible] = useState(false); const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); const currentTransaction = transactions.at(0); @@ -365,23 +369,7 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa const {showConfirmModal} = useConfirmModal(); const {triggerExportOrConfirm} = useExportAgainModal(moneyRequestReport?.reportID, moneyRequestReport?.policyID); - const {showDecisionModal} = useDecisionModal(); - - const showOfflineModal = () => { - showDecisionModal({ - title: translate('common.youAppearToBeOffline'), - prompt: translate('common.offlinePrompt'), - secondOptionText: translate('common.buttonConfirm'), - }); - }; - - const showDownloadErrorModal = () => { - showDecisionModal({ - title: translate('common.downloadFailedTitle'), - prompt: translate('common.downloadFailedDescription'), - secondOptionText: translate('common.buttonConfirm'), - }); - }; + const {showOfflineModal, showDownloadErrorModal} = useMoneyReportHeaderModals(); const {isPaidAnimationRunning, isApprovedAnimationRunning, isSubmittingAnimationRunning, startAnimation, stopAnimation, startApprovedAnimation, startSubmittingAnimation} = usePaymentAnimations(); @@ -417,10 +405,6 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa dropdownMenuRef.current?.setIsMenuVisible(false); }, [isDuplicateReportActive]); - const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); - const [paymentType, setPaymentType] = useState(); - const [requestType, setRequestType] = useState(); - const [selectedVBBAToPayFromHoldMenu, setSelectedVBBAToPayFromHoldMenu] = useState(undefined); const canAllowSettlement = hasUpdatedTotal(moneyRequestReport, policy); const policyType = policy?.type; const connectedIntegration = getValidConnectedIntegration(policy); @@ -441,7 +425,6 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa [transactions, allTransactionViolations, email, accountID, moneyRequestReport, policy], ); - const hasOnlyHeldExpenses = hasOnlyHeldExpensesReportUtils(moneyRequestReport?.reportID); const isArchivedReport = useReportIsArchived(moneyRequestReport?.reportID); const isChatReportArchived = useReportIsArchived(chatReport?.reportID); @@ -497,9 +480,6 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa const [isDuplicateActive, temporarilyDisableDuplicateAction] = useThrottledButtonState(handleDuplicateReset); - const [isHoldEducationalModalVisible, setIsHoldEducationalModalVisible] = useState(false); - const [rejectModalAction, setRejectModalAction] = useState(null); - const {selectedTransactionIDs, currentSearchQueryJSON, currentSearchKey, currentSearchHash, currentSearchResults} = useSearchStateContext(); const {removeTransaction, clearSelectedTransactions} = useSearchActionsContext(); const shouldCalculateTotals = useSearchShouldCalculateTotals(currentSearchKey, currentSearchQueryJSON?.hash, true); @@ -508,7 +488,8 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa const shouldDisplayNarrowMoreButton = !shouldDisplayNarrowVersion || isWideRHPDisplayedOnWideLayout || isSuperWideRHPDisplayedOnWideLayout; - const {showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment, nonReimbursablePaymentErrorDecisionModal} = useNonReimbursablePaymentModal(moneyRequestReport, transactions); + const {showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment} = useNonReimbursablePaymentModal(moneyRequestReport, transactions); + const {openHoldMenu, openPDFDownload, openHoldEducational, openRejectModal} = useMoneyReportHeaderModals(); const showExportProgressModal = useCallback(() => { return showConfirmModal({ @@ -522,11 +503,7 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa const beginExportWithTemplate = useCallback( (templateName: string, templateType: string, transactionIDList: string[], policyID?: string) => { if (isOffline) { - showDecisionModal({ - title: translate('common.youAppearToBeOffline'), - prompt: translate('common.offlinePrompt'), - secondOptionText: translate('common.buttonConfirm'), - }); + showOfflineModal(); return; } @@ -549,7 +526,7 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa policyID, }); }, - [isOffline, moneyRequestReport, showExportProgressModal, clearSelectedTransactions, showDecisionModal, translate], + [isOffline, moneyRequestReport, showExportProgressModal, clearSelectedTransactions, showOfflineModal], ); const isOnSearch = route.name.toLowerCase().startsWith('search'); @@ -626,7 +603,6 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa } const shouldShowNextStep = isFromPaidPolicy && !isInvoiceReport && !shouldShowStatusBar; - const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = getNonHeldAndFullAmount(moneyRequestReport, shouldShowPayButton); const isAnyTransactionOnHold = hasHeldExpensesReportUtils(moneyRequestReport?.reportID); const {isDelegateAccessRestricted} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); @@ -652,20 +628,24 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa showNonReimbursablePaymentErrorModal(); return; } - setPaymentType(type); - setRequestType(CONST.IOU.REPORT_ACTION_TYPE.PAY); const isFromSelectionMode = isSelectionModePaymentRef.current; if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); } else if (isAnyTransactionOnHold) { - setSelectedVBBAToPayFromHoldMenu(type === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined); - if (getPlatform() === CONST.PLATFORM.IOS) { - // InteractionManager delays modal until current interaction completes, preventing visual glitches on iOS - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => setIsHoldMenuVisible(true)); - } else { - setIsHoldMenuVisible(true); - } + openHoldMenu({ + requestType: CONST.IOU.REPORT_ACTION_TYPE.PAY, + paymentType: type, + methodID, + onConfirm: () => { + if (isFromSelectionMode) { + clearSelectedTransactions(true); + return; + } + startAnimation(); + }, + }).then(() => { + isSelectionModePaymentRef.current = false; + }); } else if (isInvoiceReport) { if (!isFromSelectionMode) { startAnimation(); @@ -738,6 +718,7 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa showDelegateNoAccessModal, showNonReimbursablePaymentErrorModal, shouldBlockDirectPayment, + openHoldMenu, startAnimation, moneyRequestReport, nextStep, @@ -770,11 +751,19 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa const confirmApproval = useCallback( (skipAnimation = false) => { - setRequestType(CONST.IOU.REPORT_ACTION_TYPE.APPROVE); if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); } else if (isAnyTransactionOnHold) { - setIsHoldMenuVisible(true); + openHoldMenu({ + requestType: CONST.IOU.REPORT_ACTION_TYPE.APPROVE, + onConfirm: () => { + if (skipAnimation) { + clearSelectedTransactions(true); + return; + } + startApprovedAnimation(); + }, + }); } else { if (!skipAnimation) { startApprovedAnimation(); @@ -809,6 +798,7 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa isDelegateAccessRestricted, showDelegateNoAccessModal, isAnyTransactionOnHold, + openHoldMenu, startApprovedAnimation, moneyRequestReport, accountID, @@ -1089,11 +1079,7 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa return; } if (isOffline) { - showDecisionModal({ - title: translate('common.youAppearToBeOffline'), - prompt: translate('common.offlinePrompt'), - secondOptionText: translate('common.buttonConfirm'), - }); + showOfflineModal(); return; } exportReportToCSV( @@ -1101,13 +1087,7 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa reportID: moneyRequestReport.reportID, transactionIDList: transactionIDs, }, - () => { - showDecisionModal({ - title: translate('common.downloadFailedTitle'), - prompt: translate('common.downloadFailedDescription'), - secondOptionText: translate('common.buttonConfirm'), - }); - }, + showDownloadErrorModal, translate, ); }, @@ -1182,7 +1162,8 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa exportTemplates, beginExportWithTemplate, triggerExportOrConfirm, - showDecisionModal, + showOfflineModal, + showDownloadErrorModal, ]); const primaryActionComponent = ( @@ -1197,18 +1178,6 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa startAnimation={startAnimation} startApprovedAnimation={startApprovedAnimation} startSubmittingAnimation={startSubmittingAnimation} - onHoldMenuOpen={(type, payType, methodID) => { - setRequestType(type as ActionHandledType); - setPaymentType(payType); - setSelectedVBBAToPayFromHoldMenu(payType === CONST.IOU.PAYMENT_TYPE.VBBA ? methodID : undefined); - if (getPlatform() === CONST.PLATFORM.IOS) { - // InteractionManager delays modal until current interaction completes, preventing visual glitches on iOS - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => setIsHoldMenuVisible(true)); - } else { - setIsHoldMenuVisible(true); - } - }} onExportModalOpen={() => triggerExportOrConfirm(CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION)} /> ); @@ -1401,7 +1370,7 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa if (!moneyRequestReport?.reportID) { return; } - setIsPDFModalVisible(true); + openPDFDownload(); exportReportToPDF({reportID: moneyRequestReport.reportID}); }, }, @@ -1521,9 +1490,9 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa if (isDismissed || isChatReportDM) { changeMoneyRequestHoldStatus(requestParentReportAction, transaction, isOffline); } else if (isReportSubmitter) { - setIsHoldEducationalModalVisible(true); + openHoldEducational(); } else { - setRejectModalAction(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD); + openRejectModal(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD); } }, }, @@ -1966,7 +1935,7 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa if (dismissedRejectUseExplanation) { option.onSelected?.(); } else { - setRejectModalAction(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT_BULK); + openRejectModal(CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT_BULK); } }, }; @@ -1986,6 +1955,7 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa selectionModeReportLevelActions, isDelegateAccessRestricted, showDelegateNoAccessModal, + openRejectModal, ]); const shouldShowSelectedTransactionsButton = !!selectedTransactionsOptions.length && !transactionThreadReportID; @@ -2201,53 +2171,6 @@ function MoneyReportHeader({reportID: reportIDProp, shouldDisplayBackButton = fa )} - {isHoldMenuVisible && requestType !== undefined && ( - { - setSelectedVBBAToPayFromHoldMenu(undefined); - setIsHoldMenuVisible(false); - isSelectionModePaymentRef.current = false; - }} - isVisible={isHoldMenuVisible} - paymentType={paymentType} - methodID={paymentType === CONST.IOU.PAYMENT_TYPE.VBBA ? selectedVBBAToPayFromHoldMenu : undefined} - chatReport={chatReport} - moneyRequestReport={moneyRequestReport} - hasNonHeldExpenses={!hasOnlyHeldExpenses} - startAnimation={() => { - if (isSelectionModePaymentRef.current) { - clearSelectedTransactions(true); - return; - } - if (requestType === CONST.IOU.REPORT_ACTION_TYPE.APPROVE) { - startApprovedAnimation(); - } else { - startAnimation(); - } - }} - transactionCount={transactionIDs?.length ?? 0} - transactions={transactions} - onNonReimbursablePaymentError={showNonReimbursablePaymentErrorModal} - /> - )} - setIsHoldEducationalModalVisible(false)} - onRejectModalDismissed={() => setRejectModalAction(null)} - /> - {nonReimbursablePaymentErrorDecisionModal} - setIsPDFModalVisible(false)} - /> ); } diff --git a/src/components/MoneyReportHeaderEducationalModals.tsx b/src/components/MoneyReportHeaderEducationalModals.tsx index 3338737959ae..c829abec470b 100644 --- a/src/components/MoneyReportHeaderEducationalModals.tsx +++ b/src/components/MoneyReportHeaderEducationalModals.tsx @@ -1,11 +1,16 @@ import {shouldFailAllRequestsSelector} from '@selectors/Network'; -import React from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; +import React, {useImperativeHandle, useState} from 'react'; +import type {Ref} from 'react'; import type {ValueOf} from 'type-fest'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; +import usePaginatedReportActions from '@hooks/usePaginatedReportActions'; +import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport'; import {setNameValuePair} from '@libs/actions/User'; +import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils'; import Navigation from '@libs/Navigation/Navigation'; +import {getFilteredReportActionsForReportView, getOneTransactionThreadReportID, getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {changeMoneyRequestHoldStatus, rejectMoneyRequestReason} from '@libs/ReportUtils'; import {dismissRejectUseExplanation} from '@userActions/IOU'; import CONST from '@src/CONST'; @@ -19,30 +24,52 @@ type RejectModalAction = ValueOf< typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.HOLD | typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT | typeof CONST.REPORT.TRANSACTION_SECONDARY_ACTIONS.REJECT_BULK >; +type MoneyReportHeaderEducationalModalsHandle = { + openHoldEducational: () => void; + openRejectModal: (action: RejectModalAction) => void; +}; + type MoneyReportHeaderEducationalModalsProps = { - requestParentReportAction: OnyxTypes.ReportAction | null | undefined; - transaction: OnyxEntry; reportID: string | undefined; - isHoldEducationalVisible: boolean; - rejectModalAction: RejectModalAction | null; - onHoldEducationalDismissed: () => void; - onRejectModalDismissed: () => void; + ref: Ref; }; -function MoneyReportHeaderEducationalModals({ - requestParentReportAction, - transaction, - reportID, - isHoldEducationalVisible, - rejectModalAction, - onHoldEducationalDismissed, - onRejectModalDismissed, -}: MoneyReportHeaderEducationalModalsProps) { +function MoneyReportHeaderEducationalModals({reportID, ref}: MoneyReportHeaderEducationalModalsProps) { + const [isHoldEducationalModalVisible, setIsHoldEducationalModalVisible] = useState(false); + const [rejectModalAction, setRejectModalAction] = useState(null); + const {isOffline} = useNetwork(); const [shouldFailAllRequests] = useOnyx(ONYXKEYS.NETWORK, {selector: shouldFailAllRequestsSelector}); + // Fetch report data needed for educational modals + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReport?.chatReportID}`); + const {reportActions: unfilteredReportActions} = usePaginatedReportActions(moneyRequestReport?.reportID); + const reportActions = getFilteredReportActionsForReportView(unfilteredReportActions); + const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); + + // Derive transaction thread and parent action + const nonDeletedTransactions = getAllNonDeletedTransactions(reportTransactions, reportActions, isOffline, true); + const visibleTransactionsForThreadID = nonDeletedTransactions?.filter((t) => isOffline || t.pendingAction !== 'delete'); + const reportTransactionIDs = visibleTransactionsForThreadID?.map((t) => t.transactionID); + const transactionThreadReportID = getOneTransactionThreadReportID(moneyRequestReport, chatReport, reportActions ?? [], isOffline, reportTransactionIDs); + const [transactionThreadReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`); + + const requestParentReportAction = + reportActions && transactionThreadReport?.parentReportActionID + ? reportActions.find((action): action is OnyxTypes.ReportAction => action.reportActionID === transactionThreadReport.parentReportActionID) + : null; + + const iouTransactionID = isMoneyRequestAction(requestParentReportAction) ? getOriginalMessage(requestParentReportAction)?.IOUTransactionID : undefined; + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${getNonEmptyStringOnyxID(iouTransactionID)}`); + + useImperativeHandle(ref, () => ({ + openHoldEducational: () => setIsHoldEducationalModalVisible(true), + openRejectModal: (action: RejectModalAction) => setRejectModalAction(action), + })); + const dismissModalAndUpdateUseHold = () => { - onHoldEducationalDismissed(); + setIsHoldEducationalModalVisible(false); setNameValuePair(ONYXKEYS.NVP_DISMISSED_HOLD_USE_EXPLANATION, true, false, !shouldFailAllRequests); if (requestParentReportAction) { changeMoneyRequestHoldStatus(requestParentReportAction, transaction, isOffline); @@ -70,7 +97,7 @@ function MoneyReportHeaderEducationalModals({ rejectMoneyRequestReason(requestParentReportAction); } } - onRejectModalDismissed(); + setRejectModalAction(null); }; return ( @@ -81,7 +108,7 @@ function MoneyReportHeaderEducationalModals({ onConfirm={dismissRejectModalBasedOnAction} /> )} - {!!isHoldEducationalVisible && ( + {!!isHoldEducationalModalVisible && ( (null); + + // Fetch data from IDs + const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${getNonEmptyStringOnyxID(moneyRequestReport?.policyID)}`); + const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${moneyRequestReport?.chatReportID}`); + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + + const {transactions: reportTransactions} = useTransactionsAndViolationsForReport(moneyRequestReport?.reportID); + const transactions = Object.values(reportTransactions); + + // Derive data for hold menu + const canIOUBePaid = canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList); + const onlyShowPayElsewhere = !canIOUBePaid && canIOUBePaidAction(moneyRequestReport, chatReport, policy, bankAccountList, undefined, true); + const reportHasOnlyNonReimbursableTransactions = hasOnlyNonReimbursableTransactions(moneyRequestReport?.reportID, transactions); + const shouldShowPayButton = canIOUBePaid || onlyShowPayElsewhere || reportHasOnlyNonReimbursableTransactions; + const {nonHeldAmount, fullAmount, hasValidNonHeldAmount} = getNonHeldAndFullAmount(moneyRequestReport, shouldShowPayButton); + const hasOnlyHeldExpenses = hasOnlyHeldExpensesReportUtils(moneyRequestReport?.reportID); + const transactionIDs = transactions.map((t) => t.transactionID); + + // Imperative modals + const {showHoldMenu} = useHoldMenuModal(); + const {showDecisionModal} = useDecisionModal(); + const {translate} = useLocalize(); + + const showOfflineModal = () => { + showDecisionModal({ + title: translate('common.youAppearToBeOffline'), + prompt: translate('common.offlinePrompt'), + secondOptionText: translate('common.buttonConfirm'), + }); + }; + + const showDownloadErrorModal = () => { + showDecisionModal({ + title: translate('common.downloadFailedTitle'), + prompt: translate('common.downloadFailedDescription'), + secondOptionText: translate('common.buttonConfirm'), + }); + }; + + const openHoldMenu = ({requestType, paymentType, methodID, onConfirm}: HoldMenuParams): Promise => { + const open = () => + showHoldMenu({ + reportID: moneyRequestReport?.reportID, + chatReportID: chatReport?.reportID, + requestType, + paymentType, + methodID, + nonHeldAmount: !hasOnlyHeldExpenses && hasValidNonHeldAmount ? nonHeldAmount : undefined, + fullAmount, + hasNonHeldExpenses: !hasOnlyHeldExpenses, + transactionCount: transactionIDs.length, + onConfirm, + }); + + // On iOS, delay opening the hold menu until active touch interactions finish to prevent visual glitches + if (getPlatform() === CONST.PLATFORM.IOS) { + return new Promise((resolve) => { + // eslint-disable-next-line @typescript-eslint/no-deprecated -- InteractionManager is widely used across the codebase (120+ files) and kept alive via a dedicated RN patch + InteractionManager.runAfterInteractions(() => { + open().then(() => resolve()); + }); + }); + } + + return open().then(() => {}); + }; + + const contextValue = { + openHoldMenu, + openPDFDownload: () => setIsPDFModalVisible(true), + openHoldEducational: () => educationalModalsRef.current?.openHoldEducational(), + openRejectModal: (action: RejectModalAction) => educationalModalsRef.current?.openRejectModal(action), + showOfflineModal, + showDownloadErrorModal, + }; + + return ( + + {children} + + + + setIsPDFModalVisible(false)} + /> + + ); +} + +export default MoneyReportHeaderModals; diff --git a/src/components/MoneyReportHeaderModalsContext.tsx b/src/components/MoneyReportHeaderModalsContext.tsx new file mode 100644 index 000000000000..70d30fb29dd1 --- /dev/null +++ b/src/components/MoneyReportHeaderModalsContext.tsx @@ -0,0 +1,39 @@ +import {createContext, useContext} from 'react'; +import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; +import type {ActionHandledType} from './Modal/Global/HoldMenuModalWrapper'; +import type {RejectModalAction} from './MoneyReportHeaderEducationalModals'; + +type HoldMenuParams = { + requestType: ActionHandledType; + paymentType?: PaymentMethodType; + methodID?: number; + onConfirm?: (full: boolean) => void; +}; + +type MoneyReportHeaderModalsContextValue = { + openHoldMenu: (params: HoldMenuParams) => Promise; + openPDFDownload: () => void; + openHoldEducational: () => void; + openRejectModal: (action: RejectModalAction) => void; + showOfflineModal: () => void; + showDownloadErrorModal: () => void; +}; + +const defaultValue: MoneyReportHeaderModalsContextValue = { + openHoldMenu: () => Promise.resolve(), + openPDFDownload: () => {}, + openHoldEducational: () => {}, + openRejectModal: () => {}, + showOfflineModal: () => {}, + showDownloadErrorModal: () => {}, +}; + +const MoneyReportHeaderModalsContext = createContext(defaultValue); + +function useMoneyReportHeaderModals() { + return useContext(MoneyReportHeaderModalsContext); +} + +export default MoneyReportHeaderModalsContext; +export {useMoneyReportHeaderModals}; +export type {HoldMenuParams}; diff --git a/src/components/MoneyReportHeaderPrimaryAction/ApprovePrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/ApprovePrimaryAction.tsx index bda557fcb9bb..9b8a49c0c8a7 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/ApprovePrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/ApprovePrimaryAction.tsx @@ -5,16 +5,14 @@ import useOnyx from '@hooks/useOnyx'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getNextApproverAccountID, isReportOwner} from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import useConfirmApproval from './useConfirmApproval'; type ApprovePrimaryActionProps = { reportID: string | undefined; startApprovedAnimation: () => void; - onHoldMenuOpen: (requestType: string, paymentType?: PaymentMethodType) => void; }; -function ApprovePrimaryAction({reportID, startApprovedAnimation, onHoldMenuOpen}: ApprovePrimaryActionProps) { +function ApprovePrimaryAction({reportID, startApprovedAnimation}: ApprovePrimaryActionProps) { const {translate} = useLocalize(); const [moneyRequestReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); @@ -25,7 +23,7 @@ function ApprovePrimaryAction({reportID, startApprovedAnimation, onHoldMenuOpen} isReportOwner(moneyRequestReport) && (nextApproverAccountID === moneyRequestReport?.ownerAccountID || moneyRequestReport?.managerID === moneyRequestReport?.ownerAccountID); const isBlockSubmitDueToPreventSelfApproval = isSubmitterSameAsNextApprover && policy?.preventSelfApproval; - const confirmApproval = useConfirmApproval(reportID, startApprovedAnimation, onHoldMenuOpen); + const confirmApproval = useConfirmApproval(reportID, startApprovedAnimation); return (