diff --git a/assets/images/stopwatch.svg b/assets/images/stopwatch.svg
new file mode 100644
index 000000000000..d27d6b0b7c36
--- /dev/null
+++ b/assets/images/stopwatch.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/CONST.ts b/src/CONST.ts
index fae319325f13..3936abafb923 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -562,6 +562,7 @@ const CONST = {
CHRONOSOOOLIST: 'CHRONOSOOOLIST',
CLOSED: 'CLOSED',
CREATED: 'CREATED',
+ HOLD: 'HOLD',
IOU: 'IOU',
MARKEDREIMBURSED: 'MARKEDREIMBURSED',
MODIFIEDEXPENSE: 'MODIFIEDEXPENSE',
@@ -648,6 +649,7 @@ const CONST = {
REMOVE_FROM_ROOM: 'REMOVEFROMROOM',
LEAVE_ROOM: 'LEAVEROOM',
},
+ UNHOLD: 'UNHOLD',
},
THREAD_DISABLED: ['CREATED'],
},
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 0208e7bcd3e5..9d35994875e1 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -172,6 +172,9 @@ const ONYXKEYS = {
/** Whether the user has tried focus mode yet */
NVP_TRY_FOCUS_MODE: 'tryFocusMode',
+ /** Whether the user has been shown the hold educational interstitial yet */
+ NVP_HOLD_USE_EXPLAINED: 'holdUseExplained',
+
/** Boolean flag used to display the focus mode notification */
FOCUS_MODE_NOTIFICATION: 'focusModeNotification',
@@ -355,6 +358,8 @@ const ONYXKEYS = {
MONEY_REQUEST_AMOUNT_FORM_DRAFT: 'moneyRequestAmountFormDraft',
MONEY_REQUEST_DATE_FORM: 'moneyRequestCreatedForm',
MONEY_REQUEST_DATE_FORM_DRAFT: 'moneyRequestCreatedFormDraft',
+ MONEY_REQUEST_HOLD_FORM: 'moneyHoldReasonForm',
+ MONEY_REQUEST_HOLD_FORM_DRAFT: 'moneyHoldReasonFormDraft',
NEW_CONTACT_METHOD_FORM: 'newContactMethodForm',
NEW_CONTACT_METHOD_FORM_DRAFT: 'newContactMethodFormDraft',
WAYPOINT_FORM: 'waypointForm',
@@ -410,6 +415,7 @@ type OnyxFormValuesMapping = {
[ONYXKEYS.FORMS.MONEY_REQUEST_MERCHANT_FORM]: FormTypes.Form;
[ONYXKEYS.FORMS.MONEY_REQUEST_AMOUNT_FORM]: FormTypes.Form;
[ONYXKEYS.FORMS.MONEY_REQUEST_DATE_FORM]: FormTypes.Form;
+ [ONYXKEYS.FORMS.MONEY_REQUEST_HOLD_FORM]: FormTypes.MoneyRequestHoldReasonForm;
[ONYXKEYS.FORMS.NEW_CONTACT_METHOD_FORM]: FormTypes.Form;
[ONYXKEYS.FORMS.WAYPOINT_FORM]: FormTypes.Form;
[ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: FormTypes.Form;
@@ -499,6 +505,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE]: OnyxTypes.BlockedFromConcierge;
[ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID]: string;
[ONYXKEYS.NVP_TRY_FOCUS_MODE]: boolean;
+ [ONYXKEYS.NVP_HOLD_USE_EXPLAINED]: boolean;
[ONYXKEYS.FOCUS_MODE_NOTIFICATION]: boolean;
[ONYXKEYS.NVP_LAST_PAYMENT_METHOD]: OnyxTypes.LastPaymentMethod;
[ONYXKEYS.NVP_RECENT_WAYPOINTS]: OnyxTypes.RecentWaypoint[];
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 03682a19bff4..559c49ff3e2e 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -288,6 +288,10 @@ const ROUTES = {
route: ':iouType/new/category/:reportID?',
getRoute: (iouType: string, reportID = '') => `${iouType}/new/category/${reportID}` as const,
},
+ MONEY_REQUEST_HOLD_REASON: {
+ route: ':iouType/edit/reason/:transactionID?',
+ getRoute: (iouType: string, transactionID: string, reportID: string, backTo: string) => `${iouType}/edit/reason/${transactionID}?backTo=${backTo}&reportID=${reportID}` as const,
+ },
MONEY_REQUEST_MERCHANT: {
route: ':iouType/new/merchant/:reportID?',
getRoute: (iouType: string, reportID = '') => `${iouType}/new/merchant/${reportID}` as const,
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 949adcba4d44..cfb2fe1725d2 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -129,6 +129,7 @@ const SCREENS = {
SCAN_TAB: 'scan',
DISTANCE_TAB: 'distance',
CREATE: 'Money_Request_Create',
+ HOLD: 'Money_Request_Hold_Reason',
STEP_CONFIRMATION: 'Money_Request_Step_Confirmation',
START: 'Money_Request_Start',
STEP_AMOUNT: 'Money_Request_Step_Amount',
diff --git a/src/components/HoldBanner.tsx b/src/components/HoldBanner.tsx
new file mode 100644
index 000000000000..af77d9076629
--- /dev/null
+++ b/src/components/HoldBanner.tsx
@@ -0,0 +1,22 @@
+import React from 'react';
+import {View} from 'react-native';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Text from './Text';
+import TextPill from './TextPill';
+
+function HoldBanner() {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ return (
+
+ {translate('iou.hold')}
+ {translate('iou.requestOnHold')}
+
+ );
+}
+
+HoldBanner.displayName = 'HoldBanner';
+
+export default HoldBanner;
diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts
index 06619e2e215f..ced18fdf6ba6 100644
--- a/src/components/Icon/Expensicons.ts
+++ b/src/components/Icon/Expensicons.ts
@@ -128,6 +128,7 @@ import Linkedin from '@assets/images/social-linkedin.svg';
import Podcast from '@assets/images/social-podcast.svg';
import Twitter from '@assets/images/social-twitter.svg';
import Youtube from '@assets/images/social-youtube.svg';
+import Stopwatch from '@assets/images/stopwatch.svg';
import Sync from '@assets/images/sync.svg';
import Task from '@assets/images/task.svg';
import ThreeDots from '@assets/images/three-dots.svg';
@@ -270,6 +271,7 @@ export {
Scan,
Send,
Shield,
+ Stopwatch,
Sync,
Task,
ThumbsUp,
diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx
index 21fe4bbab009..1a5a2621e43e 100644
--- a/src/components/MoneyRequestHeader.tsx
+++ b/src/components/MoneyRequestHeader.tsx
@@ -19,9 +19,11 @@ import type {Policy, Report, ReportAction, ReportActions, Session, Transaction}
import type {OriginalMessageIOU} from '@src/types/onyx/OriginalMessage';
import ConfirmModal from './ConfirmModal';
import HeaderWithBackButton from './HeaderWithBackButton';
+import HoldBanner from './HoldBanner';
import * as Expensicons from './Icon/Expensicons';
import MoneyRequestHeaderStatusBar from './MoneyRequestHeaderStatusBar';
import {usePersonalDetails} from './OnyxProvider';
+import ProcessMoneyRequestHoldMenu from './ProcessMoneyRequestHoldMenu';
type MoneyRequestHeaderOnyxProps = {
/** Session info for the currently logged in user. */
@@ -36,6 +38,9 @@ type MoneyRequestHeaderOnyxProps = {
/** All report actions */
// eslint-disable-next-line react/no-unused-prop-types
parentReportActions: OnyxEntry;
+
+ /** Whether we should show the Hold Interstitial explaining the feature */
+ shownHoldUseExplanation: OnyxEntry;
};
type MoneyRequestHeaderProps = MoneyRequestHeaderOnyxProps & {
@@ -49,18 +54,22 @@ type MoneyRequestHeaderProps = MoneyRequestHeaderOnyxProps & {
parentReportAction: ReportAction & OriginalMessageIOU;
};
-function MoneyRequestHeader({session, parentReport, report, parentReportAction, transaction, policy}: MoneyRequestHeaderProps) {
+function MoneyRequestHeader({session, parentReport, report, parentReportAction, transaction, shownHoldUseExplanation = false, policy}: MoneyRequestHeaderProps) {
const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
const styles = useThemeStyles();
const {translate} = useLocalize();
const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false);
+ const [shouldShowHoldMenu, setShouldShowHoldMenu] = useState(false);
const moneyRequestReport = parentReport;
const isSettled = ReportUtils.isSettled(moneyRequestReport?.reportID);
const isApproved = ReportUtils.isReportApproved(moneyRequestReport);
+ const isOnHold = TransactionUtils.isOnHold(transaction);
const {isSmallScreenWidth, windowWidth} = useWindowDimensions();
// Only the requestor can take delete the request, admins can only edit it.
const isActionOwner = typeof parentReportAction?.actorAccountID === 'number' && typeof session?.accountID === 'number' && parentReportAction.actorAccountID === session?.accountID;
+ const isPolicyAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN;
+ const isApprover = ReportUtils.isMoneyRequestReport(moneyRequestReport) && (session?.accountID ?? null) === moneyRequestReport?.managerID;
const deleteTransaction = useCallback(() => {
IOU.deleteMoneyRequest(parentReportAction?.originalMessage?.IOUTransactionID ?? '', parentReportAction, true);
@@ -69,6 +78,8 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction,
const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction);
const isPending = TransactionUtils.isExpensifyCardTransaction(transaction) && TransactionUtils.isPending(transaction);
+
+ const isRequestModifiable = !isSettled && !isApproved && !ReportActionsUtils.isDeletedAction(parentReportAction);
const canModifyRequest = isActionOwner && !isSettled && !isApproved && !ReportActionsUtils.isDeletedAction(parentReportAction);
let canDeleteRequest = canModifyRequest;
@@ -77,6 +88,15 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction,
canDeleteRequest = canDeleteRequest && (ReportUtils.isDraftExpenseReport(moneyRequestReport) || PolicyUtils.isPolicyAdmin(policy));
}
+ const changeMoneyRequestStatus = () => {
+ if (isOnHold) {
+ IOU.unholdRequest(parentReportAction?.originalMessage?.IOUTransactionID ?? '', report?.reportID);
+ } else {
+ const activeRoute = encodeURIComponent(Navigation.getActiveRouteWithoutParams());
+ Navigation.navigate(ROUTES.MONEY_REQUEST_HOLD_REASON.getRoute(policy?.type, parentReportAction?.originalMessage?.IOUTransactionID ?? '', report?.reportID, activeRoute));
+ }
+ };
+
useEffect(() => {
if (canDeleteRequest) {
return;
@@ -84,7 +104,50 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction,
setIsDeleteModalVisible(false);
}, [canDeleteRequest]);
+
const threeDotsMenuItems = [HeaderUtils.getPinMenuItem(report)];
+ if (isRequestModifiable) {
+ const isRequestIOU = parentReport?.type === 'iou';
+ const isHoldCreator = ReportUtils.isHoldCreator(transaction, report?.reportID) && isRequestIOU;
+ const canModifyStatus = isPolicyAdmin || isActionOwner || isApprover;
+ if (isOnHold && (isHoldCreator || (!isRequestIOU && canModifyStatus))) {
+ threeDotsMenuItems.push({
+ icon: Expensicons.Stopwatch,
+ text: translate('iou.unholdRequest'),
+ onSelected: () => changeMoneyRequestStatus(),
+ });
+ }
+ if (!isOnHold && (isRequestIOU || canModifyStatus)) {
+ threeDotsMenuItems.push({
+ icon: Expensicons.Stopwatch,
+ text: translate('iou.holdRequest'),
+ onSelected: () => changeMoneyRequestStatus(),
+ });
+ }
+ }
+
+ useEffect(() => {
+ setShouldShowHoldMenu(isOnHold && !shownHoldUseExplanation);
+ }, [isOnHold, shownHoldUseExplanation]);
+
+ useEffect(() => {
+ if (!shouldShowHoldMenu) {
+ return;
+ }
+
+ if (isSmallScreenWidth) {
+ if (Navigation.getActiveRoute().slice(1) === ROUTES.PROCESS_MONEY_REQUEST_HOLD) {
+ Navigation.goBack();
+ }
+ } else {
+ Navigation.navigate(ROUTES.PROCESS_MONEY_REQUEST_HOLD);
+ }
+ }, [isSmallScreenWidth, shouldShowHoldMenu]);
+
+ const handleHoldRequestClose = () => {
+ IOU.setShownHoldUseExplanation();
+ };
+
if (canModifyRequest) {
if (!TransactionUtils.hasReceipt(transaction)) {
threeDotsMenuItems.push({
@@ -115,7 +178,7 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction,
<>
)}
+ {isOnHold && }
+ {isSmallScreenWidth && shouldShowHoldMenu && (
+
+ )}
>
);
}
MoneyRequestHeader.displayName = 'MoneyRequestHeader';
-const MoneyRequestHeaderWithTransaction = withOnyx>({
+const MoneyRequestHeaderWithTransaction = withOnyx>({
transaction: {
key: ({report, parentReportActions}) => {
const parentReportAction = (report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : {}) as ReportAction & OriginalMessageIOU;
return `${ONYXKEYS.COLLECTION.TRANSACTION}${parentReportAction.originalMessage.IOUTransactionID ?? 0}`;
},
},
+ shownHoldUseExplanation: {
+ key: ONYXKEYS.NVP_HOLD_USE_EXPLAINED,
+ initWithStoredValues: true,
+ },
})(MoneyRequestHeader);
-export default withOnyx, Omit>({
+export default withOnyx, Omit>({
session: {
key: ONYXKEYS.SESSION,
},
diff --git a/src/components/ProcessMoneyRequestHoldMenu.tsx b/src/components/ProcessMoneyRequestHoldMenu.tsx
index e3f6190d2a1b..13fa07a4918b 100644
--- a/src/components/ProcessMoneyRequestHoldMenu.tsx
+++ b/src/components/ProcessMoneyRequestHoldMenu.tsx
@@ -1,5 +1,4 @@
-import type {RefObject} from 'react';
-import React from 'react';
+import React, {useRef} from 'react';
import {View} from 'react-native';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -25,22 +24,20 @@ type ProcessMoneyRequestHoldMenuProps = {
anchorPosition?: PopoverAnchorPosition;
/** The anchor alignment of the popover menu */
- anchorAlignment: AnchorAlignment;
-
- /** The anchor ref of the popover menu */
- anchorRef: RefObject;
+ anchorAlignment?: AnchorAlignment;
};
-function ProcessMoneyRequestHoldMenu({isVisible, onClose, onConfirm, anchorPosition, anchorAlignment, anchorRef}: ProcessMoneyRequestHoldMenuProps) {
+function ProcessMoneyRequestHoldMenu({isVisible, onClose, onConfirm, anchorPosition, anchorAlignment}: ProcessMoneyRequestHoldMenuProps) {
const {translate} = useLocalize();
const styles = useThemeStyles();
+ const popoverRef = useRef(null);
return (
;
+ textStyles?: StyleProp;
children: React.ReactNode;
};
function TextPill({color, textStyles, children}: TextPillProps) {
const styles = useThemeStyles();
-
- return {children};
+ return (
+
+ {children}
+
+ );
}
TextPill.displayName = 'TextPill';
diff --git a/src/languages/en.ts b/src/languages/en.ts
index f33ddde1fb53..c3f531549832 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -666,6 +666,18 @@ export default {
waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `Started settling up, payment is held until ${submitterDisplayName} enables their Wallet`,
enableWallet: 'Enable Wallet',
hold: 'Hold',
+ holdRequest: 'Hold Request',
+ unholdRequest: 'Unhold Request',
+ explainHold: "Explain why you're holding this request.",
+ reason: 'Reason',
+ holdReasonRequired: 'A reason is required when holding.',
+ requestOnHold: 'This request was put on hold. Review the comments for next steps.',
+ confirmApprove: 'Confirm what to approve',
+ confirmApprovalAmount: 'Approve the entire report total or only the amount not on hold.',
+ confirmPay: 'Confirm what to pay',
+ confirmPayAmount: 'Pay all out-of-pocket spend or only the amount not on hold.',
+ payOnly: 'Pay only',
+ approveOnly: 'Approve only',
holdEducationalTitle: 'This request is on',
whatIsHoldTitle: 'What is hold?',
whatIsHoldExplain: 'Hold is our way of streamlining financial collaboration. "Reject" is so harsh!',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 6bc225fbea28..e9a188b1f395 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -660,14 +660,26 @@ export default {
},
waitingOnEnabledWallet: ({submitterDisplayName}: WaitingOnBankAccountParams) => `Inició el pago, pero no se procesará hasta que ${submitterDisplayName} active su Billetera`,
enableWallet: 'Habilitar Billetera',
- hold: 'Hold',
- holdEducationalTitle: 'Esta solicitud está en',
- whatIsHoldTitle: '¿Qué es Hold?',
- whatIsHoldExplain: 'Hold es nuestra forma de agilizar la colaboración financiera. ¡"Rechazar" es tan duro!',
- holdIsTemporaryTitle: 'Hold suele ser temporal',
- holdIsTemporaryExplain: 'Debido a que hold se utiliza para aclarar confusión o aclarar un detalle importante antes del pago, no es permanente.',
+ holdRequest: 'Bloquear solicitud de dinero',
+ unholdRequest: 'Desbloquear solicitud de dinero',
+ explainHold: 'Explica la razón para bloquear esta solicitud.',
+ reason: 'Razón',
+ holdReasonRequired: 'Se requiere una razón para bloquear.',
+ requestOnHold: 'Este solicitud está bloqueada. Revisa los comentarios para saber como proceder.',
+ confirmApprove: 'Confirma que quieres aprobar',
+ confirmApprovalAmount: 'Aprobar el total o solo la parte no bloqueada.',
+ confirmPay: 'Confirma que quieres pagar',
+ confirmPayAmount: 'Pagar todos los gastos por cuenta propia o solo el monto no bloqueado.',
+ payOnly: 'Solo pagar',
+ approveOnly: 'Solo aprobar',
+ hold: 'Bloqueada',
+ holdEducationalTitle: 'Esta solicitud está',
+ whatIsHoldTitle: '¿Qué es Bloquear?',
+ whatIsHoldExplain: 'Bloquear es nuestra forma de agilizar la colaboración financiera. ¡"Rechazar" es tan duro!',
+ holdIsTemporaryTitle: 'Bloquear suele ser temporal',
+ holdIsTemporaryExplain: 'Se utiliza bloquear para aclarar confusión o aclarar un detalle importante antes del pago, no es permanente.',
deleteHoldTitle: 'Eliminar lo que no se pagará',
- deleteHoldExplain: 'En el raro caso de que algo se ponga en hold y no se pague, la persona que solicita el pago debe eliminarlo.',
+ deleteHoldExplain: 'En el raro caso de que algo se bloquear y no se pague, la persona que solicita el pago debe eliminarlo.',
set: 'estableció',
changed: 'cambió',
removed: 'eliminó',
diff --git a/src/libs/API/parameters/HoldMoneyRequestParams.ts b/src/libs/API/parameters/HoldMoneyRequestParams.ts
new file mode 100644
index 000000000000..93cb1bd6c524
--- /dev/null
+++ b/src/libs/API/parameters/HoldMoneyRequestParams.ts
@@ -0,0 +1,7 @@
+type HoldMoneyRequestParams = {
+ transactionID: string;
+ comment: string;
+ reportActionID: string;
+};
+
+export default HoldMoneyRequestParams;
diff --git a/src/libs/API/parameters/UnHoldMoneyRequestParams.ts b/src/libs/API/parameters/UnHoldMoneyRequestParams.ts
new file mode 100644
index 000000000000..24b8803e320c
--- /dev/null
+++ b/src/libs/API/parameters/UnHoldMoneyRequestParams.ts
@@ -0,0 +1,6 @@
+type UnHoldMoneyRequestParams = {
+ transactionID: string;
+ reportActionID: string;
+};
+
+export default UnHoldMoneyRequestParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index ada3b84e6cf4..b9042dcc1215 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -139,6 +139,8 @@ export type {default as ReplaceReceiptParams} from './ReplaceReceiptParams';
export type {default as SubmitReportParams} from './SubmitReportParams';
export type {default as DetachReceiptParams} from './DetachReceiptParams';
export type {default as PayMoneyRequestParams} from './PayMoneyRequestParams';
+export type {default as HoldMoneyRequestParams} from './HoldMoneyRequestParams';
+export type {default as UnHoldMoneyRequestParams} from './UnHoldMoneyRequestParams';
export type {default as CancelPaymentParams} from './CancelPaymentParams';
export type {default as AcceptACHContractForBankAccount} from './AcceptACHContractForBankAccount';
export type {default as UpdateWorkspaceDescriptionParams} from './UpdateWorkspaceDescriptionParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index 97e0fdd064c5..7c7695ebef57 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -126,6 +126,8 @@ const WRITE_COMMANDS = {
UPDATE_MONEY_REQUEST_CATEGORY: 'UpdateMoneyRequestCategory',
UPDATE_MONEY_REQUEST_DESCRIPTION: 'UpdateMoneyRequestDescription',
UPDATE_MONEY_REQUEST_AMOUNT_AND_CURRENCY: 'UpdateMoneyRequestAmountAndCurrency',
+ HOLD_MONEY_REQUEST: 'HoldRequest',
+ UNHOLD_MONEY_REQUEST: 'UnHoldRequest',
UPDATE_DISTANCE_REQUEST: 'UpdateDistanceRequest',
REQUEST_MONEY: 'RequestMoney',
SPLIT_BILL: 'SplitBill',
@@ -266,6 +268,8 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DISTANCE]: Parameters.UpdateMoneyRequestParams;
[WRITE_COMMANDS.UPDATE_MONEY_REQUEST_CATEGORY]: Parameters.UpdateMoneyRequestParams;
[WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DESCRIPTION]: Parameters.UpdateMoneyRequestParams;
+ [WRITE_COMMANDS.HOLD_MONEY_REQUEST]: Parameters.HoldMoneyRequestParams;
+ [WRITE_COMMANDS.UNHOLD_MONEY_REQUEST]: Parameters.UnHoldMoneyRequestParams;
[WRITE_COMMANDS.UPDATE_DISTANCE_REQUEST]: Parameters.UpdateMoneyRequestParams;
[WRITE_COMMANDS.UPDATE_MONEY_REQUEST_AMOUNT_AND_CURRENCY]: Parameters.UpdateMoneyRequestParams;
[WRITE_COMMANDS.REQUEST_MONEY]: Parameters.RequestMoneyParams;
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
index a585cddcdbf5..24e23ebcf760 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
@@ -99,6 +99,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsPage').default as React.ComponentType,
[SCREENS.MONEY_REQUEST.CONFIRMATION]: () => require('../../../pages/iou/steps/MoneyRequestConfirmPage').default as React.ComponentType,
[SCREENS.MONEY_REQUEST.CURRENCY]: () => require('../../../pages/iou/IOUCurrencySelection').default as React.ComponentType,
+ [SCREENS.MONEY_REQUEST.HOLD]: () => require('../../../pages/iou/HoldReasonPage').default as React.ComponentType,
[SCREENS.MONEY_REQUEST.CATEGORY]: () => require('../../../pages/iou/MoneyRequestCategoryPage').default as React.ComponentType,
[SCREENS.IOU_SEND.ADD_BANK_ACCOUNT]: () => require('../../../pages/AddPersonalBankAccountPage').default as React.ComponentType,
[SCREENS.IOU_SEND.ADD_DEBIT_CARD]: () => require('../../../pages/settings/Wallet/AddDebitCardPage').default as React.ComponentType,
diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts
index a1579832ca54..c8808b15b686 100644
--- a/src/libs/Navigation/linkingConfig/config.ts
+++ b/src/libs/Navigation/linkingConfig/config.ts
@@ -395,6 +395,7 @@ const config: LinkingOptions['config'] = {
[SCREENS.MONEY_REQUEST.STEP_DATE]: ROUTES.MONEY_REQUEST_STEP_DATE.route,
[SCREENS.MONEY_REQUEST.STEP_DESCRIPTION]: ROUTES.MONEY_REQUEST_STEP_DESCRIPTION.route,
[SCREENS.MONEY_REQUEST.STEP_DISTANCE]: ROUTES.MONEY_REQUEST_STEP_DISTANCE.route,
+ [SCREENS.MONEY_REQUEST.HOLD]: ROUTES.MONEY_REQUEST_HOLD_REASON.route,
[SCREENS.MONEY_REQUEST.STEP_MERCHANT]: ROUTES.MONEY_REQUEST_STEP_MERCHANT.route,
[SCREENS.MONEY_REQUEST.STEP_PARTICIPANTS]: ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.route,
[SCREENS.MONEY_REQUEST.STEP_SCAN]: ROUTES.MONEY_REQUEST_STEP_SCAN.route,
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index b5da21c0f67e..76af7c22793b 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -195,6 +195,11 @@ type OptimisticSubmittedReportAction = Pick<
'actionName' | 'actorAccountID' | 'automatic' | 'avatar' | 'isAttachment' | 'originalMessage' | 'message' | 'person' | 'reportActionID' | 'shouldShow' | 'created' | 'pendingAction'
>;
+type OptimisticHoldReportAction = Pick<
+ ReportAction,
+ 'actionName' | 'actorAccountID' | 'automatic' | 'avatar' | 'isAttachment' | 'originalMessage' | 'message' | 'person' | 'reportActionID' | 'shouldShow' | 'created' | 'pendingAction'
+>;
+
type OptimisticCancelPaymentReportAction = Pick<
ReportAction,
'actionName' | 'actorAccountID' | 'message' | 'originalMessage' | 'person' | 'reportActionID' | 'shouldShow' | 'created' | 'pendingAction'
@@ -2627,7 +2632,6 @@ function getParentNavigationSubtitle(report: OnyxEntry): ParentNavigatio
/**
* Navigate to the details page of a given report
- *
*/
function navigateToDetailsPage(report: OnyxEntry) {
const participantAccountIDs = report?.participantAccountIDs ?? [];
@@ -3492,6 +3496,72 @@ function buildOptimisticRenamedRoomReportAction(newName: string, oldName: string
};
}
+/**
+ * Returns the necessary reportAction onyx data to indicate that the transaction has been put on hold optimistically
+ * @param [created] - Action created time
+ */
+function buildOptimisticHoldReportAction(comment: string, created = DateUtils.getDBTime()): OptimisticHoldReportAction {
+ return {
+ reportActionID: NumberUtils.rand64(),
+ actionName: CONST.REPORT.ACTIONS.TYPE.HOLD,
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ actorAccountID: currentUserAccountID,
+ message: [
+ {
+ type: CONST.REPORT.MESSAGE.TYPE.TEXT,
+ style: 'normal',
+ text: `held this money request with the comment: ${comment}`,
+ },
+ {
+ type: CONST.REPORT.MESSAGE.TYPE.COMMENT,
+ text: comment,
+ },
+ ],
+ person: [
+ {
+ type: CONST.REPORT.MESSAGE.TYPE.TEXT,
+ style: 'strong',
+ text: allPersonalDetails?.[currentUserAccountID ?? '']?.displayName ?? currentUserEmail,
+ },
+ ],
+ automatic: false,
+ avatar: allPersonalDetails?.[currentUserAccountID ?? '']?.avatar ?? UserUtils.getDefaultAvatarURL(currentUserAccountID),
+ created,
+ shouldShow: true,
+ };
+}
+
+/**
+ * Returns the necessary reportAction onyx data to indicate that the transaction has been removed from hold optimistically
+ * @param [created] - Action created time
+ */
+function buildOptimisticUnHoldReportAction(created = DateUtils.getDBTime()): OptimisticSubmittedReportAction {
+ return {
+ reportActionID: NumberUtils.rand64(),
+ actionName: CONST.REPORT.ACTIONS.TYPE.UNHOLD,
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ actorAccountID: currentUserAccountID,
+ message: [
+ {
+ type: CONST.REPORT.MESSAGE.TYPE.TEXT,
+ style: 'normal',
+ text: `unheld this money request`,
+ },
+ ],
+ person: [
+ {
+ type: CONST.REPORT.MESSAGE.TYPE.TEXT,
+ style: 'normal',
+ text: allPersonalDetails?.[currentUserAccountID ?? '']?.displayName ?? currentUserEmail,
+ },
+ ],
+ automatic: false,
+ avatar: allPersonalDetails?.[currentUserAccountID ?? '']?.avatar ?? UserUtils.getDefaultAvatarURL(currentUserAccountID),
+ created,
+ shouldShow: true,
+ };
+}
+
/**
* Returns the necessary reportAction onyx data to indicate that a task report has been edited
*/
@@ -4787,6 +4857,14 @@ function navigateToPrivateNotes(report: OnyxEntry, session: OnyxEntry, reportID: string): boolean {
+ const holdReportAction = ReportActionsUtils.getReportAction(reportID, `${transaction?.comment?.hold ?? ''}`);
+ return isActionCreator(holdReportAction);
+}
+
/**
* Checks if thread replies should be displayed
*/
@@ -5100,8 +5178,11 @@ export {
hasViolations,
navigateToPrivateNotes,
canEditWriteCapability,
+ isHoldCreator,
hasSmartscanError,
shouldAutoFocusOnKeyPress,
+ buildOptimisticHoldReportAction,
+ buildOptimisticUnHoldReportAction,
shouldDisplayThreadReplies,
shouldDisableThread,
doesReportBelongToWorkspace,
diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts
index 6eb1a848a4b4..65013fcf0afb 100644
--- a/src/libs/TransactionUtils.ts
+++ b/src/libs/TransactionUtils.ts
@@ -536,6 +536,17 @@ function getRecentTransactions(transactions: Record, size = 2):
.slice(0, size);
}
+/**
+ * Check if transaction is on hold
+ */
+function isOnHold(transaction: OnyxEntry): boolean {
+ if (!transaction) {
+ return false;
+ }
+
+ return !!transaction.comment?.hold;
+}
+
/**
* Checks if any violations for the provided transaction are of type 'violation'
*/
@@ -594,6 +605,7 @@ export {
isCardTransaction,
isPending,
isPosted,
+ isOnHold,
getWaypoints,
isAmountMissing,
isMerchantMissing,
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index 41f7e4101d2a..6f0c417a3305 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -3850,6 +3850,10 @@ function setMoneyRequestParticipants(participants: Participant[], isSplitRequest
Onyx.merge(ONYXKEYS.IOU, {participants, isSplitRequest});
}
+function setShownHoldUseExplanation() {
+ Onyx.set(ONYXKEYS.NVP_HOLD_USE_EXPLAINED, true);
+}
+
function setUpDistanceTransaction() {
const transactionID = NumberUtils.rand64();
Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
@@ -3906,6 +3910,102 @@ function getIOUReportID(iou?: OnyxTypes.IOU, route?: MoneyRequestRoute): string
return route?.params.reportID || iou?.participants?.[0]?.reportID || '';
}
+/**
+ * Put money request on HOLD
+ */
+function putOnHold(transactionID: string, comment: string, reportID: string) {
+ const createdReportAction = ReportUtils.buildOptimisticHoldReportAction(comment);
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
+ value: {
+ [createdReportAction.reportActionID]: createdReportAction as ReportAction,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ comment: {
+ hold: createdReportAction.reportActionID,
+ },
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [];
+
+ const failureData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ comment: {
+ hold: null,
+ },
+ },
+ },
+ ];
+
+ API.write(
+ 'HoldRequest',
+ {
+ transactionID,
+ comment,
+ reportActionID: createdReportAction.reportActionID,
+ },
+ {optimisticData, successData, failureData},
+ );
+}
+
+/**
+ * Remove money request from HOLD
+ */
+function unholdRequest(transactionID: string, reportID: string) {
+ const createdReportAction = ReportUtils.buildOptimisticUnHoldReportAction();
+
+ const optimisticData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`,
+ value: {
+ [createdReportAction.reportActionID]: createdReportAction as ReportAction,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ comment: {
+ hold: null,
+ },
+ },
+ },
+ ];
+
+ const successData: OnyxUpdate[] = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ comment: {
+ hold: null,
+ },
+ },
+ },
+ ];
+
+ API.write(
+ 'UnHoldRequest',
+ {
+ transactionID,
+ reportActionID: createdReportAction.reportActionID,
+ },
+ {optimisticData, successData, failureData: []},
+ );
+}
// eslint-disable-next-line rulesdir/no-negated-variables
function navigateToStartStepIfScanFileCannotBeRead(
receiptFilename: string,
@@ -3978,6 +4078,7 @@ export {
setMoneyRequestTaxAmount,
setMoneyRequestTaxRate,
setUpDistanceTransaction,
+ setShownHoldUseExplanation,
navigateToNextPage,
updateMoneyRequestDate,
updateMoneyRequestBillable,
@@ -3991,6 +4092,8 @@ export {
detachReceipt,
getIOUReportID,
editMoneyRequest,
+ putOnHold,
+ unholdRequest,
cancelPayment,
navigateToStartStepIfScanFileCannotBeRead,
savePreferredPaymentMethod,
diff --git a/src/pages/ProcessMoneyRequestHoldPage.js b/src/pages/ProcessMoneyRequestHoldPage.tsx
similarity index 92%
rename from src/pages/ProcessMoneyRequestHoldPage.js
rename to src/pages/ProcessMoneyRequestHoldPage.tsx
index c9de16f874a2..1a607ef71cf5 100644
--- a/src/pages/ProcessMoneyRequestHoldPage.js
+++ b/src/pages/ProcessMoneyRequestHoldPage.tsx
@@ -8,13 +8,14 @@ import TextPill from '@components/TextPill';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import Navigation from '@libs/Navigation/Navigation';
+import * as IOU from '@userActions/IOU';
function ProcessMoneyRequestHoldPage() {
const styles = useThemeStyles();
const {translate} = useLocalize();
const onConfirm = useCallback(() => {
- // Currently only goes back, this will be changed after backends for hold will be merged
+ IOU.setShownHoldUseExplanation();
Navigation.goBack();
}, []);
@@ -34,6 +35,7 @@ function ProcessMoneyRequestHoldPage() {
title={translate('common.back')}
footer={footerComponent}
onBackButtonPress={() => Navigation.goBack()}
+ testID={ProcessMoneyRequestHoldPage.displayName}
>
diff --git a/src/pages/home/report/ReportActionItemFragment.tsx b/src/pages/home/report/ReportActionItemFragment.tsx
index c89cb3a4e543..e762f07cf20f 100644
--- a/src/pages/home/report/ReportActionItemFragment.tsx
+++ b/src/pages/home/report/ReportActionItemFragment.tsx
@@ -50,6 +50,9 @@ type ReportActionItemFragmentProps = {
/** Whether the report action type is 'APPROVED' or 'SUBMITTED'. Used to style system messages from Old Dot */
isApprovedOrSubmittedReportAction?: boolean;
+ /** Whether the report action type is 'UNHOLD' or 'HOLD'. Used to style messages related to hold requests */
+ isHoldReportAction?: boolean;
+
/** Used to format RTL display names in Old Dot system messages e.g. Arabic */
isFragmentContainingDisplayName?: boolean;
@@ -71,6 +74,7 @@ function ReportActionItemFragment({
actorIcon = {},
isThreadParentMessage = false,
isApprovedOrSubmittedReportAction = false,
+ isHoldReportAction = false,
isFragmentContainingDisplayName = false,
displayAsGroup = false,
moderationDecision,
@@ -117,14 +121,29 @@ function ReportActionItemFragment({
);
}
case 'TEXT': {
- return isApprovedOrSubmittedReportAction ? (
-
- {isFragmentContainingDisplayName ? convertToLTR(fragment.text) : fragment.text}
-
- ) : (
+ if (isApprovedOrSubmittedReportAction) {
+ return (
+
+ {isFragmentContainingDisplayName ? convertToLTR(fragment.text) : fragment.text}
+
+ );
+ }
+
+ if (isHoldReportAction) {
+ return (
+
+ {isFragmentContainingDisplayName ? convertToLTR(fragment.text) : fragment.text}
+
+ );
+ }
+
+ return (
type === action.actionName);
+ const isHoldReportAction = [CONST.REPORT.ACTIONS.TYPE.HOLD, CONST.REPORT.ACTIONS.TYPE.UNHOLD].some((type) => type === action.actionName);
+
/**
* Get the ReportActionItemFragments
* @param shouldWrapInText determines whether the fragments are wrapped in a Text component
@@ -83,6 +85,7 @@ function ReportActionItemMessage({action, displayAsGroup, reportID, style, isHid
style={style}
displayAsGroup={displayAsGroup}
isApprovedOrSubmittedReportAction={isApprovedOrSubmittedReportAction}
+ isHoldReportAction={isHoldReportAction}
// Since system messages from Old Dot begin with the person who performed the action,
// the first fragment will contain the person's display name and their email. We'll use this
// to decide if the fragment should be from left to right for RTL display names e.g. Arabic for proper
diff --git a/src/pages/iou/HoldReasonPage.tsx b/src/pages/iou/HoldReasonPage.tsx
new file mode 100644
index 000000000000..da3b98583630
--- /dev/null
+++ b/src/pages/iou/HoldReasonPage.tsx
@@ -0,0 +1,99 @@
+import type {RouteProp} from '@react-navigation/native';
+import React, {useCallback} 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 Text from '@components/Text';
+import TextInput from '@components/TextInput';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
+import * as ValidationUtils from '@libs/ValidationUtils';
+import * as IOU from '@userActions/IOU';
+import type ONYXKEYS from '@src/ONYXKEYS';
+import type {Route} from '@src/ROUTES';
+import INPUT_IDS from '@src/types/form/MoneyRequestHoldReasonForm';
+
+type HoldReasonPageRouteParams = {
+ /** ID of the transaction the page was opened for */
+ transactionID: string;
+
+ /** ID of the report that user is providing hold reason to */
+ reportID: string;
+
+ /** Link to previous page */
+ backTo: Route;
+};
+
+type HoldReasonPageProps = {
+ /** Navigation route context info provided by react navigation */
+ route: RouteProp<{params: HoldReasonPageRouteParams}>;
+};
+
+function HoldReasonPage({route}: HoldReasonPageProps) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ const {transactionID, reportID, backTo} = route.params;
+
+ const navigateBack = () => {
+ Navigation.navigate(backTo);
+ };
+
+ const onSubmit = (values: FormOnyxValues) => {
+ IOU.putOnHold(transactionID, values.comment, reportID);
+ navigateBack();
+ };
+
+ const validate = useCallback((values: FormOnyxValues) => {
+ const errors: FormInputErrors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.COMMENT]);
+
+ if (!values.comment) {
+ errors.comment = 'common.error.fieldRequired';
+ }
+
+ return errors;
+ }, []);
+
+ return (
+
+
+
+ {translate('iou.explainHold')}
+
+
+
+
+
+ );
+}
+
+HoldReasonPage.displayName = 'MoneyRequestHoldReasonPage';
+
+export default HoldReasonPage;
diff --git a/src/types/form/MoneyRequestHoldReasonForm.ts b/src/types/form/MoneyRequestHoldReasonForm.ts
new file mode 100644
index 000000000000..0d80810eaf69
--- /dev/null
+++ b/src/types/form/MoneyRequestHoldReasonForm.ts
@@ -0,0 +1,12 @@
+import type Form from './Form';
+
+const INPUT_IDS = {
+ COMMENT: 'comment',
+} as const;
+
+type MoneyRequestHoldReasonForm = Form<{
+ [INPUT_IDS.COMMENT]: string;
+}>;
+
+export type {MoneyRequestHoldReasonForm};
+export default INPUT_IDS;
diff --git a/src/types/form/index.ts b/src/types/form/index.ts
index d9263991023c..f188dee6ab4f 100644
--- a/src/types/form/index.ts
+++ b/src/types/form/index.ts
@@ -12,6 +12,7 @@ export type {MoneyRequestAmountForm} from './MoneyRequestAmountForm';
export type {MoneyRequestCreatedForm} from './MoneyRequestCreatedForm';
export type {MoneyRequestDescriptionForm} from './MoneyRequestDescriptionForm';
export type {MoneyRequestMerchantForm} from './MoneyRequestMerchantForm';
+export type {MoneyRequestHoldReasonForm} from './MoneyRequestHoldReasonForm';
export type {NewContactMethodForm} from './NewContactMethodForm';
export type {NewRoomForm} from './NewRoomForm';
export type {NewTaskForm} from './NewTaskForm';
diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts
index 423fd85038f8..06c2d2e6abce 100644
--- a/src/types/onyx/OriginalMessage.ts
+++ b/src/types/onyx/OriginalMessage.ts
@@ -11,6 +11,8 @@ type OriginalMessageActionName =
| 'CHRONOSOOOLIST'
| 'CLOSED'
| 'CREATED'
+ | 'HOLD'
+ | 'UNHOLD'
| 'IOU'
| 'MODIFIEDEXPENSE'
| 'REIMBURSEMENTQUEUED'
@@ -29,6 +31,16 @@ type OriginalMessageApproved = {
};
type OriginalMessageSource = 'Chronos' | 'email' | 'ios' | 'android' | 'web' | '';
+type OriginalMessageHold = {
+ actionName: typeof CONST.REPORT.ACTIONS.TYPE.HOLD;
+ originalMessage: unknown;
+};
+
+type OriginalMessageUnHold = {
+ actionName: typeof CONST.REPORT.ACTIONS.TYPE.UNHOLD;
+ originalMessage: unknown;
+};
+
type IOUDetails = {
amount: number;
comment?: string;
@@ -274,6 +286,8 @@ type OriginalMessage =
| OriginalMessageSubmitted
| OriginalMessageClosed
| OriginalMessageCreated
+ | OriginalMessageHold
+ | OriginalMessageUnHold
| OriginalMessageRenamed
| OriginalMessageChronosOOOList
| OriginalMessageReportPreview
diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts
index b559346a48de..c3499b96fcb9 100644
--- a/src/types/onyx/Transaction.ts
+++ b/src/types/onyx/Transaction.ts
@@ -22,6 +22,7 @@ type WaypointCollection = Record;
type Comment = {
comment?: string;
+ hold?: string;
waypoints?: WaypointCollection;
isLoading?: boolean;
type?: string;