Skip to content

Commit

Permalink
Merge pull request #33124 from software-mansion-labs/hold-requests/mo…
Browse files Browse the repository at this point in the history
…ney-request-page

[WAVE7] Hold Requests: Approving/Paying expense reports with held requests
  • Loading branch information
robertjchen authored Mar 28, 2024
2 parents 903fe8a + e538272 commit ef949e0
Show file tree
Hide file tree
Showing 12 changed files with 375 additions and 36 deletions.
101 changes: 101 additions & 0 deletions src/components/DecisionModal.tsx
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;
9 changes: 6 additions & 3 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type {ReactNode} from 'react';
import React from 'react';
import type {StyleProp, TextStyle} from 'react-native';
import type {StyleProp, TextStyle, ViewStyle} from 'react-native';
import {View} from 'react-native';
import useThemeStyles from '@hooks/useThemeStyles';
import EnvironmentBadge from './EnvironmentBadge';
Expand All @@ -18,12 +18,15 @@ type HeaderProps = {

/** Additional text styles */
textStyles?: StyleProp<TextStyle>;

/** Additional header container styles */
containerStyles?: StyleProp<ViewStyle>;
};

function Header({title = '', subtitle = '', textStyles = [], shouldShowEnvironmentBadge = false}: HeaderProps) {
function Header({title = '', subtitle = '', textStyles = [], containerStyles = [], shouldShowEnvironmentBadge = false}: HeaderProps) {
const styles = useThemeStyles();
return (
<View style={[styles.flex1, styles.flexRow]}>
<View style={[styles.flex1, styles.flexRow, containerStyles]}>
<View style={styles.mw100}>
{typeof title === 'string'
? Boolean(title) && (
Expand Down
57 changes: 48 additions & 9 deletions src/components/MoneyReportHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
};

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,
Expand Down Expand Up @@ -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>
Expand All @@ -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>
Expand All @@ -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}
Expand Down
78 changes: 78 additions & 0 deletions src/components/ProcessMoneyReportHoldMenu.tsx
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')}
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;
3 changes: 2 additions & 1 deletion src/components/ReportActionItem/MoneyReportView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function MoneyReportView({report, policy, shouldShowHorizontalRule}: MoneyReport
const formattedTotalAmount = CurrencyUtils.convertToDisplayString(totalDisplaySpend, report.currency);
const formattedOutOfPocketAmount = CurrencyUtils.convertToDisplayString(reimbursableSpend, report.currency);
const formattedCompanySpendAmount = CurrencyUtils.convertToDisplayString(nonReimbursableSpend, report.currency);
const isPartiallyPaid = Boolean(report?.pendingFields?.partial);

const subAmountTextStyles: StyleProp<TextStyle> = [
styles.taskTitleMenuItem,
Expand Down Expand Up @@ -109,7 +110,7 @@ function MoneyReportView({report, policy, shouldShowHorizontalRule}: MoneyReport
</Text>
</View>
<View style={[styles.flexRow, styles.justifyContentCenter]}>
{isSettled && (
{isSettled && !isPartiallyPaid && (
<View style={[styles.defaultCheckmarkWrapper, styles.mh2]}>
<Icon
src={Expensicons.Checkmark}
Expand Down
Loading

0 comments on commit ef949e0

Please sign in to comment.