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;