Skip to content
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

feat: hold expense in one transaction view #42374

Merged
merged 18 commits into from
Jun 5, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 149 additions & 35 deletions src/components/MoneyReportHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import * as CurrencyUtils from '@libs/CurrencyUtils';
import * as HeaderUtils from '@libs/HeaderUtils';
import Navigation from '@libs/Navigation/Navigation';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
import * as TransactionUtils from '@libs/TransactionUtils';
Expand All @@ -20,20 +21,26 @@ import ROUTES from '@src/ROUTES';
import type * as OnyxTypes from '@src/types/onyx';
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type IconAsset from '@src/types/utils/IconAsset';
import Button from './Button';
import ConfirmModal from './ConfirmModal';
import HeaderWithBackButton from './HeaderWithBackButton';
import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';
import MoneyReportHeaderStatusBar from './MoneyReportHeaderStatusBar';
import type {MoneyRequestHeaderStatusBarProps} from './MoneyRequestHeaderStatusBar';
import MoneyRequestHeaderStatusBar from './MoneyRequestHeaderStatusBar';
import ProcessMoneyReportHoldMenu from './ProcessMoneyReportHoldMenu';
import ProcessMoneyRequestHoldMenu from './ProcessMoneyRequestHoldMenu';
import SettlementButton from './SettlementButton';

type MoneyReportHeaderOnyxProps = {
/** The chat report this report is linked to */
chatReport: OnyxEntry<OnyxTypes.Report>;

/** All the data for the transaction in one transaction view */
transaction: OnyxEntry<OnyxTypes.Transaction>;

/** The next step for the report */
nextStep: OnyxEntry<OnyxTypes.ReportNextStep>;

Expand All @@ -42,6 +49,9 @@ type MoneyReportHeaderOnyxProps = {

/** The transaction thread report associated with the current report, if any */
transactionThreadReport: OnyxEntry<OnyxTypes.Report>;

/** Whether we should show the Hold Interstitial explaining the feature */
shownHoldUseExplanation: OnyxEntry<boolean>;
};

type MoneyReportHeaderProps = MoneyReportHeaderOnyxProps & {
Expand Down Expand Up @@ -69,31 +79,40 @@ function MoneyReportHeader({
session,
policy,
chatReport,
transaction,
nextStep,
report: moneyRequestReport,
transactionThreadReport,
reportActions,
shouldUseNarrowLayout = false,
shownHoldUseExplanation = false,
onBackButtonPress,
}: MoneyReportHeaderProps) {
const styles = useThemeStyles();
const theme = useTheme();
const [isDeleteRequestModalVisible, setIsDeleteRequestModalVisible] = useState(false);
const [shouldShowHoldMenu, setShouldShowHoldMenu] = useState(false);
const {translate} = useLocalize();
const {windowWidth} = useWindowDimensions();
const {reimbursableSpend} = ReportUtils.getMoneyRequestSpendBreakdown(moneyRequestReport);
const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID);
const isApproved = ReportUtils.isReportApproved(moneyRequestReport);
const isOnHold = TransactionUtils.isOnHold(transaction);
const requestParentReportAction = useMemo(() => {
if (!reportActions || !transactionThreadReport?.parentReportActionID) {
return null;
}
return reportActions.find((action) => action.reportActionID === transactionThreadReport.parentReportActionID);
}, [reportActions, transactionThreadReport?.parentReportActionID]);
const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction);
const isDeletedParentAction = ReportActionsUtils.isDeletedAction(requestParentReportAction as OnyxTypes.ReportAction);
const canHoldOrUnholdRequest = !isEmptyObject(transaction) && !isSettled && !isApproved && !isDeletedParentAction;

// Only the requestor can delete the request, admins can only edit it.
const isActionOwner =
typeof requestParentReportAction?.actorAccountID === 'number' && typeof session?.accountID === 'number' && requestParentReportAction.actorAccountID === session?.accountID;
const isPolicyAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN;
const isApprover = ReportUtils.isMoneyRequestReport(moneyRequestReport) && moneyRequestReport?.managerID !== null && session?.accountID === moneyRequestReport?.managerID;
const canDeleteRequest =
isActionOwner && (ReportUtils.canAddOrDeleteTransactions(moneyRequestReport) || ReportUtils.isTrackExpenseReport(transactionThreadReport)) && !isDeletedParentAction;
const [isHoldMenuVisible, setIsHoldMenuVisible] = useState(false);
Expand All @@ -105,7 +124,7 @@ function MoneyReportHeader({
const isDraft = ReportUtils.isOpenExpenseReport(moneyRequestReport);
const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false);

const transactionIDs = TransactionUtils.getAllReportTransactions(moneyRequestReport?.reportID).map((transaction) => transaction.transactionID);
const transactionIDs = TransactionUtils.getAllReportTransactions(moneyRequestReport?.reportID).map((t) => t.transactionID);
const allHavePendingRTERViolation = TransactionUtils.allHavePendingRTERViolation(transactionIDs);

const cancelPayment = useCallback(() => {
Expand All @@ -124,18 +143,23 @@ function MoneyReportHeader({

const shouldShowSettlementButton = !ReportUtils.isInvoiceReport(moneyRequestReport) && (shouldShowPayButton || shouldShowApproveButton) && !allHavePendingRTERViolation;

const shouldShowSubmitButton = isDraft && reimbursableSpend !== 0 && !allHavePendingRTERViolation;
const hasOnlyHeldExpenses = ReportUtils.hasOnlyHeldExpenses(moneyRequestReport.reportID);
const shouldShowSubmitButton = isDraft && reimbursableSpend !== 0 && !allHavePendingRTERViolation && !hasOnlyHeldExpenses;
const shouldDisableSubmitButton = shouldShowSubmitButton && !ReportUtils.isAllowedToSubmitDraftExpenseReport(moneyRequestReport);
const shouldShowMarkAsCashButton = isDraft && allHavePendingRTERViolation;
const shouldShowMarkAsCashButton = isDraft && allHavePendingRTERViolation && !hasOnlyHeldExpenses;
const isFromPaidPolicy = policyType === CONST.POLICY.TYPE.TEAM || policyType === CONST.POLICY.TYPE.CORPORATE;
const shouldShowNextStep = !ReportUtils.isClosedExpenseReportWithNoExpenses(moneyRequestReport) && isFromPaidPolicy && !!nextStep?.message?.length && !allHavePendingRTERViolation;
const shouldShowNextStep =
!ReportUtils.isClosedExpenseReportWithNoExpenses(moneyRequestReport) && isFromPaidPolicy && !!nextStep?.message?.length && !allHavePendingRTERViolation && !hasOnlyHeldExpenses;
const shouldShowAnyButton = shouldShowSettlementButton || shouldShowApproveButton || shouldShowSubmitButton || shouldShowNextStep || allHavePendingRTERViolation;
const bankAccountRoute = ReportUtils.getBankAccountRoute(chatReport);
const formattedAmount = CurrencyUtils.convertToDisplayString(reimbursableSpend, moneyRequestReport.currency);
const [nonHeldAmount, fullAmount] = ReportUtils.getNonHeldAndFullAmount(moneyRequestReport, policy);
const displayedAmount = ReportUtils.hasHeldExpenses(moneyRequestReport.reportID) && canAllowSettlement ? nonHeldAmount : formattedAmount;
const isMoreContentShown = shouldShowNextStep || (shouldShowAnyButton && shouldUseNarrowLayout);

// Shows border if no buttons or banners are showing below the header
const shouldShowBorderBottom = !(shouldShowAnyButton && shouldUseNarrowLayout) && !(shouldShowNextStep && !shouldUseNarrowLayout) && !allHavePendingRTERViolation && !hasOnlyHeldExpenses;

const confirmPayment = (type?: PaymentMethodType | undefined) => {
if (!type) {
return;
Expand Down Expand Up @@ -181,13 +205,90 @@ function MoneyReportHeader({
TransactionActions.markAsCash(iouTransactionID, reportID);
}, [requestParentReportAction, transactionThreadReport?.reportID]);

const changeMoneyRequestStatus = () => {
if (!transactionThreadReport) {
return;
}
const iouTransactionID = requestParentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? requestParentReportAction.originalMessage?.IOUTransactionID ?? '' : '';

if (isOnHold) {
IOU.unholdRequest(iouTransactionID, transactionThreadReport.reportID);
} else {
const activeRoute = encodeURIComponent(Navigation.getActiveRouteWithoutParams());
Navigation.navigate(ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(policy?.type ?? CONST.POLICY.TYPE.PERSONAL, iouTransactionID, transactionThreadReport.reportID, activeRoute));
}
};

const getStatusIcon: (src: IconAsset) => React.ReactNode = (src) => (
<Icon
src={src}
height={variables.iconSizeSmall}
width={variables.iconSizeSmall}
fill={theme.icon}
/>
);

const getStatusBarProps: () => MoneyRequestHeaderStatusBarProps | undefined = () => {
if (hasOnlyHeldExpenses) {
return {title: translate('iou.hold'), description: translate('iou.expensesOnHold'), danger: true, shouldShowBorderBottom: true};
}
if (allHavePendingRTERViolation) {
return {title: getStatusIcon(Expensicons.Hourglass), description: translate('iou.pendingMatchWithCreditCardDescription'), shouldShowBorderBottom: true};
}
};

const statusBarProps = getStatusBarProps();

// 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,
[chatReport?.isOwnPolicyExpenseChat, policy?.harvesting?.enabled],
);

const threeDotsMenuItems = [HeaderUtils.getPinMenuItem(moneyRequestReport)];
if (canHoldOrUnholdRequest) {
const isRequestIOU = chatReport?.type === 'iou';
const isHoldCreator = ReportUtils.isHoldCreator(transaction, moneyRequestReport?.reportID) && isRequestIOU;
const isTrackExpenseReport = ReportUtils.isTrackExpenseReport(moneyRequestReport);
const canModifyStatus = !isTrackExpenseReport && (isPolicyAdmin || isActionOwner || isApprover);
if (isOnHold && (isHoldCreator || (!isRequestIOU && canModifyStatus))) {
threeDotsMenuItems.push({
icon: Expensicons.Stopwatch,
text: translate('iou.unholdExpense'),
onSelected: () => changeMoneyRequestStatus(),
});
}
if (!isOnHold && (isRequestIOU || canModifyStatus) && !isScanning) {
threeDotsMenuItems.push({
icon: Expensicons.Stopwatch,
text: translate('iou.hold'),
onSelected: () => changeMoneyRequestStatus(),
});
}
}

useEffect(() => {
setShouldShowHoldMenu(isOnHold && !shownHoldUseExplanation);
}, [isOnHold, shownHoldUseExplanation]);

useEffect(() => {
if (!shouldShowHoldMenu) {
return;
}

if (shouldUseNarrowLayout) {
if (Navigation.getActiveRoute().slice(1) === ROUTES.PROCESS_MONEY_REQUEST_HOLD) {
Navigation.goBack();
}
} else {
Navigation.navigate(ROUTES.PROCESS_MONEY_REQUEST_HOLD);
}
}, [shouldUseNarrowLayout, shouldShowHoldMenu]);

const handleHoldRequestClose = () => {
IOU.setShownHoldUseExplanation();
};

if (isPayer && isSettled && ReportUtils.isExpenseReport(moneyRequestReport)) {
threeDotsMenuItems.push({
icon: Expensicons.Trashcan,
Expand Down Expand Up @@ -223,8 +324,7 @@ function MoneyReportHeader({
policy={policy}
shouldShowBackButton={shouldUseNarrowLayout}
onBackButtonPress={onBackButtonPress}
// Shows border if no buttons or next steps are showing below the header
shouldShowBorderBottom={!(shouldShowAnyButton && shouldUseNarrowLayout) && !(shouldShowNextStep && !shouldUseNarrowLayout) && !allHavePendingRTERViolation}
shouldShowBorderBottom={shouldShowBorderBottom}
shouldShowThreeDotsButton
threeDotsMenuItems={threeDotsMenuItems}
threeDotsAnchorPosition={styles.threeDotsPopoverOffsetNoCloseButton(windowWidth)}
Expand All @@ -244,7 +344,7 @@ function MoneyReportHeader({
shouldShowApproveButton={shouldShowApproveButton}
shouldDisableApproveButton={shouldDisableApproveButton}
style={[styles.pv2]}
formattedAmount={!ReportUtils.hasOnlyHeldExpenses(moneyRequestReport.reportID) ? displayedAmount : ''}
formattedAmount={!hasOnlyHeldExpenses ? displayedAmount : ''}
isDisabled={!canAllowSettlement}
/>
</View>
Expand Down Expand Up @@ -273,33 +373,25 @@ function MoneyReportHeader({
</View>
)}
</HeaderWithBackButton>
{allHavePendingRTERViolation && (
<View>
{shouldShowMarkAsCashButton && shouldUseNarrowLayout && (
<View style={[styles.ph5, styles.pb3]}>
<Button
medium
success
text={translate('iou.markAsCash')}
style={[styles.w100, styles.pr0]}
onPress={markAsCash}
/>
</View>
)}
<MoneyRequestHeaderStatusBar
title={
<Icon
src={Expensicons.Hourglass}
height={variables.iconSizeSmall}
width={variables.iconSizeSmall}
fill={theme.icon}
/>
}
description={translate('iou.pendingMatchWithCreditCardDescription')}
shouldShowBorderBottom
{shouldShowMarkAsCashButton && shouldUseNarrowLayout && (
<View style={[styles.ph5, styles.pb3]}>
<Button
medium
success
text={translate('iou.markAsCash')}
style={[styles.w100, styles.pr0]}
onPress={markAsCash}
/>
</View>
)}
{statusBarProps && (
<MoneyRequestHeaderStatusBar
title={statusBarProps.title}
description={statusBarProps.description}
danger={statusBarProps.danger}
shouldShowBorderBottom={statusBarProps.shouldShowBorderBottom}
/>
)}
<View style={isMoreContentShown ? [styles.dFlex, styles.flexColumn, styles.borderBottom] : []}>
{shouldShowSettlementButton && shouldUseNarrowLayout && (
<View style={[styles.ph5, styles.pb2]}>
Expand All @@ -314,7 +406,7 @@ function MoneyReportHeader({
addBankAccountRoute={bankAccountRoute}
shouldHidePaymentOptions={!shouldShowPayButton}
shouldShowApproveButton={shouldShowApproveButton}
formattedAmount={!ReportUtils.hasOnlyHeldExpenses(moneyRequestReport.reportID) ? displayedAmount : ''}
formattedAmount={!hasOnlyHeldExpenses ? displayedAmount : ''}
shouldDisableApproveButton={shouldDisableApproveButton}
isDisabled={!canAllowSettlement}
/>
Expand All @@ -340,7 +432,7 @@ function MoneyReportHeader({
</View>
{isHoldMenuVisible && requestType !== undefined && (
<ProcessMoneyReportHoldMenu
nonHeldAmount={!ReportUtils.hasOnlyHeldExpenses(moneyRequestReport.reportID) ? nonHeldAmount : undefined}
nonHeldAmount={!hasOnlyHeldExpenses ? nonHeldAmount : undefined}
requestType={requestType}
fullAmount={fullAmount}
isSmallScreenWidth={shouldUseNarrowLayout}
Expand Down Expand Up @@ -371,13 +463,35 @@ function MoneyReportHeader({
cancelText={translate('common.cancel')}
danger
/>
{shouldUseNarrowLayout && shouldShowHoldMenu && (
<ProcessMoneyRequestHoldMenu
onClose={handleHoldRequestClose}
onConfirm={handleHoldRequestClose}
isVisible={shouldShowHoldMenu}
/>
)}
</View>
);
}

MoneyReportHeader.displayName = 'MoneyReportHeader';

export default withOnyx<MoneyReportHeaderProps, MoneyReportHeaderOnyxProps>({
const MoneyReportHeaderWithTransaction = withOnyx<MoneyReportHeaderProps, Pick<MoneyReportHeaderOnyxProps, 'transaction' | 'shownHoldUseExplanation'>>({
transaction: {
key: ({transactionThreadReport, reportActions}) => {
const requestParentReportAction = (
transactionThreadReport?.parentReportActionID && reportActions ? reportActions.find((action) => action.reportActionID === transactionThreadReport.parentReportActionID) : {}
) as OnyxTypes.ReportAction & OnyxTypes.OriginalMessageIOU;
return `${ONYXKEYS.COLLECTION.TRANSACTION}${requestParentReportAction?.originalMessage?.IOUTransactionID ?? 0}`;
},
},
shownHoldUseExplanation: {
key: ONYXKEYS.NVP_HOLD_USE_EXPLAINED,
initWithStoredValues: true,
},
})(MoneyReportHeader);

export default withOnyx<Omit<MoneyReportHeaderProps, 'transaction' | 'shownHoldUseExplanation'>, Omit<MoneyReportHeaderOnyxProps, 'transaction' | 'shownHoldUseExplanation'>>({
chatReport: {
key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.chatReportID}`,
},
Expand All @@ -390,4 +504,4 @@ export default withOnyx<MoneyReportHeaderProps, MoneyReportHeaderOnyxProps>({
session: {
key: ONYXKEYS.SESSION,
},
})(MoneyReportHeader);
})(MoneyReportHeaderWithTransaction);
1 change: 1 addition & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -750,6 +750,7 @@ export default {
reason: 'Reason',
holdReasonRequired: 'A reason is required when holding.',
expenseOnHold: 'This expense was put on hold. Review the comments for next steps.',
expensesOnHold: 'All expenses were put on hold. Review the comments for next steps.',
confirmApprove: 'Confirm approval amount',
confirmApprovalAmount: "Approve what's not on hold, or approve the entire report.",
confirmPay: 'Confirm payment amount',
Expand Down
1 change: 1 addition & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,7 @@ export default {
reason: 'Razón',
holdReasonRequired: 'Se requiere una razón para bloquear.',
expenseOnHold: 'Este gasto está bloqueado. Revisa los comentarios para saber como proceder.',
expensesOnHold: 'Todos los gastos quedaron bloqueado. Revisa los comentarios para saber como proceder.',
confirmApprove: 'Confirmar importe a aprobar',
confirmApprovalAmount: 'Aprueba lo que no está bloqueado, o aprueba todo el informe.',
confirmPay: 'Confirmar importe de pago',
Expand Down
Loading