-
Notifications
You must be signed in to change notification settings - Fork 3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[WAVE7] Hold Requests: Approving/Paying expense reports with held requests #33124
Changes from all commits
3f4df08
faebe01
f4297cd
317f5fc
da7e0a6
d38c0f6
818a841
ed3fe5e
275e17b
7301183
f28bd09
e237009
4e36be7
38f28b5
7fefad2
aa63896
cc5b1a0
adea766
7067e21
eb74222
19aa76d
32e7c7e
47c7c8d
c56681c
2fee6b7
7033f38
04636a8
b81c5df
20f568c
34e2d8c
d31c2a8
9eda512
beb1dc8
01e1bdb
7bd074d
3e90c40
2574741
75741ae
3c1edf5
14a7b65
c080092
9c4c695
914443e
d9a8fec
4734744
7428752
68aa153
46e626b
5c44e41
37311c3
69839f1
5c535dd
a8a414f
3cd3849
7f691ff
071ca7b
d35f1cb
a86501a
54d293b
9780889
cbb1a7b
6f99630
e538272
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,101 @@ | ||
import React from 'react'; | ||
import {View} from 'react-native'; | ||
import useLocalize from '@hooks/useLocalize'; | ||
import useTheme from '@hooks/useTheme'; | ||
import useThemeStyles from '@hooks/useThemeStyles'; | ||
import CONST from '@src/CONST'; | ||
import Button from './Button'; | ||
import Header from './Header'; | ||
import Icon from './Icon'; | ||
import * as Expensicons from './Icon/Expensicons'; | ||
import Modal from './Modal'; | ||
import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; | ||
import Text from './Text'; | ||
import Tooltip from './Tooltip'; | ||
|
||
type DecisionModalProps = { | ||
/** Title describing purpose of modal */ | ||
title: string; | ||
|
||
/** Modal subtitle/description */ | ||
prompt?: string; | ||
|
||
/** Text content used in first button */ | ||
firstOptionText?: string; | ||
|
||
/** Text content used in second button */ | ||
secondOptionText: string; | ||
|
||
/** onSubmit callback fired after clicking on first button */ | ||
onFirstOptionSubmit: () => void; | ||
|
||
/** onSubmit callback fired after clicking on second button */ | ||
onSecondOptionSubmit: () => void; | ||
|
||
/** Is the window width narrow, like on a mobile device? */ | ||
isSmallScreenWidth: boolean; | ||
|
||
/** Callback for closing modal */ | ||
onClose: () => void; | ||
|
||
/** Whether modal is visible */ | ||
isVisible: boolean; | ||
}; | ||
|
||
function DecisionModal({title, prompt = '', firstOptionText, secondOptionText, onFirstOptionSubmit, onSecondOptionSubmit, isSmallScreenWidth, onClose, isVisible}: DecisionModalProps) { | ||
const {translate} = useLocalize(); | ||
const theme = useTheme(); | ||
const styles = useThemeStyles(); | ||
|
||
return ( | ||
<Modal | ||
onClose={onClose} | ||
isVisible={isVisible} | ||
type={isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CONFIRM} | ||
> | ||
<View style={[styles.m5]}> | ||
<View> | ||
<View style={[styles.flexRow, styles.mb4]}> | ||
<Header | ||
title={title} | ||
containerStyles={[styles.alignItemsCenter]} | ||
/> | ||
<Tooltip text={translate('common.close')}> | ||
<PressableWithoutFeedback | ||
onPress={onClose} | ||
style={[styles.touchableButtonImage]} | ||
accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON} | ||
accessibilityLabel={translate('common.close')} | ||
> | ||
<Icon | ||
src={Expensicons.Close} | ||
fill={theme.icon} | ||
/> | ||
</PressableWithoutFeedback> | ||
</Tooltip> | ||
</View> | ||
|
||
<Text>{prompt}</Text> | ||
</View> | ||
{firstOptionText && ( | ||
<Button | ||
success | ||
style={[styles.mt4]} | ||
onPress={onFirstOptionSubmit} | ||
pressOnEnter | ||
text={firstOptionText} | ||
/> | ||
)} | ||
<Button | ||
style={[styles.mt3, styles.noSelect]} | ||
onPress={onSecondOptionSubmit} | ||
text={secondOptionText} | ||
/> | ||
</View> | ||
</Modal> | ||
); | ||
} | ||
|
||
DecisionModal.displayName = 'DecisionModal'; | ||
|
||
export default DecisionModal; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -14,16 +14,15 @@ import CONST from '@src/CONST'; | |
import ONYXKEYS from '@src/ONYXKEYS'; | ||
import ROUTES from '@src/ROUTES'; | ||
import type * as OnyxTypes from '@src/types/onyx'; | ||
import type DeepValueOf from '@src/types/utils/DeepValueOf'; | ||
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; | ||
import Button from './Button'; | ||
import ConfirmModal from './ConfirmModal'; | ||
import HeaderWithBackButton from './HeaderWithBackButton'; | ||
import * as Expensicons from './Icon/Expensicons'; | ||
import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar'; | ||
import ProcessMoneyReportHoldMenu from './ProcessMoneyReportHoldMenu'; | ||
import SettlementButton from './SettlementButton'; | ||
|
||
type PaymentType = DeepValueOf<typeof CONST.IOU.PAYMENT_TYPE>; | ||
|
||
type MoneyReportHeaderOnyxProps = { | ||
/** The chat report this report is linked to */ | ||
chatReport: OnyxEntry<OnyxTypes.Report>; | ||
|
@@ -49,6 +48,9 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money | |
const {windowWidth, isSmallScreenWidth} = useWindowDimensions(); | ||
const {reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(moneyRequestReport); | ||
const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID); | ||
const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false); | ||
const [paymentType, setPaymentType] = useState<PaymentMethodType>(); | ||
const [requestType, setRequestType] = useState<'pay' | 'approve'>(); | ||
const canAllowSettlement = ReportUtils.hasUpdatedTotal(moneyRequestReport); | ||
const policyType = policy?.type; | ||
const isPayer = ReportUtils.isPayer(session, moneyRequestReport); | ||
|
@@ -78,8 +80,32 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money | |
const shouldShowAnyButton = shouldShowSettlementButton || shouldShowApproveButton || shouldShowSubmitButton || shouldShowNextStep; | ||
const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport); | ||
const formattedAmount = CurrencyUtils.convertToDisplayString(reimbursableSpend, moneyRequestReport.currency); | ||
const [nonHeldAmount, fullAmount] = ReportUtils.getNonHeldAndFullAmount(moneyRequestReport); | ||
const displayedAmount = ReportUtils.hasHeldExpenses(moneyRequestReport.reportID) && canAllowSettlement ? nonHeldAmount : formattedAmount; | ||
const isMoreContentShown = shouldShowNextStep || (shouldShowAnyButton && isSmallScreenWidth); | ||
|
||
const confirmPayment = (type?: PaymentMethodType | undefined) => { | ||
if (!type) { | ||
return; | ||
} | ||
setPaymentType(type); | ||
setRequestType('pay'); | ||
if (ReportUtils.hasHeldExpenses(moneyRequestReport.reportID)) { | ||
setIsHoldMenuVisible(true); | ||
} else if (chatReport) { | ||
IOU.payMoneyRequest(type, chatReport, moneyRequestReport, false); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
} | ||
}; | ||
|
||
const confirmApproval = () => { | ||
setRequestType('approve'); | ||
if (ReportUtils.hasHeldExpenses(moneyRequestReport.reportID)) { | ||
setIsHoldMenuVisible(true); | ||
} else { | ||
IOU.approveMoneyRequest(moneyRequestReport, true); | ||
} | ||
}; | ||
|
||
// The submit button should be success green colour only if the user is submitter and the policy does not have Scheduled Submit turned on | ||
const isWaitingForSubmissionFromCurrentUser = useMemo( | ||
() => chatReport?.isOwnPolicyExpenseChat && !policy?.harvesting?.enabled, | ||
|
@@ -115,18 +141,18 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money | |
<View style={styles.pv2}> | ||
<SettlementButton | ||
currency={moneyRequestReport.currency} | ||
confirmApproval={confirmApproval} | ||
policyID={moneyRequestReport.policyID} | ||
chatReportID={chatReport?.reportID} | ||
iouReport={moneyRequestReport} | ||
// @ts-expect-error TODO: Remove this once IOU (https://github.com/Expensify/App/issues/24926) is migrated to TypeScript. | ||
onPress={(paymentType: PaymentType) => IOU.payMoneyRequest(paymentType, chatReport, moneyRequestReport)} | ||
onPress={confirmPayment} | ||
enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS} | ||
addBankAccountRoute={bankAccountRoute} | ||
shouldHidePaymentOptions={!shouldShowPayButton} | ||
shouldShowApproveButton={shouldShowApproveButton} | ||
shouldDisableApproveButton={shouldDisableApproveButton} | ||
style={[styles.pv2]} | ||
formattedAmount={formattedAmount} | ||
formattedAmount={!ReportUtils.hasOnlyHeldExpenses(moneyRequestReport.reportID) ? displayedAmount : ''} | ||
isDisabled={!canAllowSettlement} | ||
/> | ||
</View> | ||
|
@@ -149,17 +175,17 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money | |
<View style={[styles.ph5, styles.pb2]}> | ||
<SettlementButton | ||
currency={moneyRequestReport.currency} | ||
confirmApproval={confirmApproval} | ||
policyID={moneyRequestReport.policyID} | ||
chatReportID={moneyRequestReport.chatReportID} | ||
iouReport={moneyRequestReport} | ||
// @ts-expect-error TODO: Remove this once IOU (https://github.com/Expensify/App/issues/24926) is migrated to TypeScript. | ||
onPress={(paymentType: PaymentType) => IOU.payMoneyRequest(paymentType, chatReport, moneyRequestReport)} | ||
onPress={confirmPayment} | ||
enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS} | ||
addBankAccountRoute={bankAccountRoute} | ||
shouldHidePaymentOptions={!shouldShowPayButton} | ||
shouldShowApproveButton={shouldShowApproveButton} | ||
formattedAmount={!ReportUtils.hasOnlyHeldExpenses(moneyRequestReport.reportID) ? displayedAmount : ''} | ||
shouldDisableApproveButton={shouldDisableApproveButton} | ||
formattedAmount={formattedAmount} | ||
isDisabled={!canAllowSettlement} | ||
/> | ||
</View> | ||
|
@@ -182,6 +208,19 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money | |
</View> | ||
)} | ||
</View> | ||
{isHoldMenuVisible && requestType !== undefined && ( | ||
<ProcessMoneyReportHoldMenu | ||
nonHeldAmount={!ReportUtils.hasOnlyHeldExpenses(moneyRequestReport.reportID) ? nonHeldAmount : undefined} | ||
requestType={requestType} | ||
fullAmount={fullAmount} | ||
isSmallScreenWidth={isSmallScreenWidth} | ||
onClose={() => setIsHoldMenuVisible(false)} | ||
isVisible={isHoldMenuVisible} | ||
paymentType={paymentType} | ||
chatReport={chatReport} | ||
moneyRequestReport={moneyRequestReport} | ||
/> | ||
)} | ||
<ConfirmModal | ||
title={translate('iou.cancelPayment')} | ||
isVisible={isConfirmModalVisible} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,78 @@ | ||
import React from 'react'; | ||
import type {OnyxEntry} from 'react-native-onyx'; | ||
import useLocalize from '@hooks/useLocalize'; | ||
import * as IOU from '@userActions/IOU'; | ||
import type * as OnyxTypes from '@src/types/onyx'; | ||
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; | ||
import DecisionModal from './DecisionModal'; | ||
|
||
type ProcessMoneyReportHoldMenuProps = { | ||
/** The chat report this report is linked to */ | ||
chatReport: OnyxEntry<OnyxTypes.Report>; | ||
|
||
/** Full amount of expense report to pay */ | ||
fullAmount: string; | ||
|
||
/** Is the window width narrow, like on a mobile device? */ | ||
isSmallScreenWidth: boolean; | ||
|
||
/** Whether modal is visible */ | ||
isVisible: boolean; | ||
|
||
/** The report currently being looked at */ | ||
moneyRequestReport: OnyxTypes.Report; | ||
|
||
/** Not held amount of expense report */ | ||
nonHeldAmount?: string; | ||
|
||
/** Callback for closing modal */ | ||
onClose: () => void; | ||
|
||
/** Type of payment */ | ||
paymentType?: PaymentMethodType; | ||
|
||
/** Type of action handled */ | ||
requestType?: 'pay' | 'approve'; | ||
}; | ||
|
||
function ProcessMoneyReportHoldMenu({ | ||
requestType, | ||
nonHeldAmount, | ||
fullAmount, | ||
isSmallScreenWidth = false, | ||
onClose, | ||
isVisible, | ||
paymentType, | ||
chatReport, | ||
moneyRequestReport, | ||
}: ProcessMoneyReportHoldMenuProps) { | ||
const {translate} = useLocalize(); | ||
const isApprove = requestType === 'approve'; | ||
|
||
const onSubmit = (full: boolean) => { | ||
if (isApprove) { | ||
IOU.approveMoneyRequest(moneyRequestReport, full); | ||
} else if (chatReport && paymentType) { | ||
IOU.payMoneyRequest(paymentType, chatReport, moneyRequestReport, full); | ||
} | ||
onClose(); | ||
}; | ||
|
||
return ( | ||
<DecisionModal | ||
title={translate(isApprove ? 'iou.confirmApprove' : 'iou.confirmPay')} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. NAB: just making a note for later cleanup- we can probably move these string constants to https://github.com/Expensify/App/blob/main/src/CONST.ts |
||
onClose={onClose} | ||
isVisible={isVisible} | ||
prompt={translate(isApprove ? 'iou.confirmApprovalAmount' : 'iou.confirmPayAmount')} | ||
firstOptionText={nonHeldAmount ? `${translate(isApprove ? 'iou.approveOnly' : 'iou.payOnly')} ${nonHeldAmount}` : undefined} | ||
secondOptionText={`${translate(isApprove ? 'iou.approve' : 'iou.pay')} ${fullAmount}`} | ||
onFirstOptionSubmit={() => onSubmit(false)} | ||
onSecondOptionSubmit={() => onSubmit(true)} | ||
isSmallScreenWidth={isSmallScreenWidth} | ||
/> | ||
); | ||
} | ||
|
||
ProcessMoneyReportHoldMenu.displayName = 'ProcessMoneyReportHoldMenu'; | ||
|
||
export default ProcessMoneyReportHoldMenu; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@BartoszGrajdek Is there a reason that the
canAllowSettlement
condition is required here?