diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index bd1c387ca49a..f7c4a11bc52f 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -249,6 +249,7 @@ const ONYXKEYS = { REPORT_USER_IS_LEAVING_ROOM: 'reportUserIsLeavingRoom_', SECURITY_GROUP: 'securityGroup_', TRANSACTION: 'transactions_', + SPLIT_TRANSACTION_DRAFT: 'splitTransactionDraft_', PRIVATE_NOTES_DRAFT: 'privateNotesDraft_', // Manual request tab selector diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 3ef5ce44c4da..7127c1483c26 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -171,6 +171,14 @@ export default { route: 'r/:reportID/split/:reportActionID', getRoute: (reportID: string, reportActionID: string) => `r/${reportID}/split/${reportActionID}`, }, + EDIT_SPLIT_BILL: { + route: `r/:reportID/split/:reportActionID/edit/:field`, + getRoute: (reportID: string, reportActionID: string, field: ValueOf) => `r/${reportID}/split/${reportActionID}/edit/${field}`, + }, + EDIT_SPLIT_BILL_CURRENCY: { + route: 'r/:reportID/split/:reportActionID/edit/currency', + getRoute: (reportID: string, reportActionID: string, currency: string, backTo: string) => `r/${reportID}/split/${reportActionID}/edit/currency?currency=${currency}&backTo=${backTo}`, + }, TASK_TITLE: { route: 'r/:reportID/title', getRoute: (reportID: string) => `r/${reportID}/title`, diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index 7f7c1e465d3b..1b471f257965 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -141,8 +141,8 @@ const propTypes = { /** Whether the money request is a distance request */ isDistanceRequest: PropTypes.bool, - /** Whether the receipt associated with this report is being scanned */ - isScanning: PropTypes.bool, + /** Whether we should show the amount, date, and merchant fields. */ + shouldShowSmartScanFields: PropTypes.bool, /** A flag for verifying that the current report is a sub-report of a workspace chat */ isPolicyExpenseChat: PropTypes.bool, @@ -182,17 +182,19 @@ const defaultProps = { transaction: {}, mileageRate: {unit: CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate: 0, currency: 'USD'}, isDistanceRequest: false, - isScanning: false, + shouldShowSmartScanFields: true, isPolicyExpenseChat: false, }; function MoneyRequestConfirmationList(props) { // Destructure functions from props to pass it as a dependecy to useCallback/useMemo hooks. // Prop functions pass props itself as a "this" value to the function which means they change every time props change. - const {onSendMoney, onConfirm, onSelectParticipant, transaction} = props; + const {onSendMoney, onConfirm, onSelectParticipant} = props; const {translate, toLocaleDigit} = useLocalize(); + const transaction = props.isEditingSplitBill ? props.draftTransaction || props.transaction : props.transaction; const isTypeRequest = props.iouType === CONST.IOU.MONEY_REQUEST_TYPE.REQUEST; + const isSplitBill = props.iouType === CONST.IOU.MONEY_REQUEST_TYPE.SPLIT; const isTypeSend = props.iouType === CONST.IOU.MONEY_REQUEST_TYPE.SEND; const {unit, rate, currency} = props.mileageRate; @@ -203,15 +205,11 @@ function MoneyRequestConfirmationList(props) { const shouldShowCategories = props.isPolicyExpenseChat && Permissions.canUseCategories(props.betas) && (props.iouCategory || OptionsListUtils.hasEnabledOptions(_.values(props.policyCategories))); - // A flag for showing SmartScan fields: date, merchant, and amount, only when we don't have a receiptPath (e.g. manual request) - // or in the split details page which is ReadOnly - const shouldShowSmartScanFields = (!props.receiptPath || props.isReadOnly) && !props.isScanning; - // A flag and a toggler for showing the rest of the form fields const [shouldExpandFields, toggleShouldExpandFields] = useReducer((state) => !state, false); // Do not hide fields in case of send money request - const shouldShowAllFields = props.isDistanceRequest || shouldExpandFields || !shouldShowSmartScanFields || isTypeSend; + const shouldShowAllFields = props.isDistanceRequest || shouldExpandFields || !props.shouldShowSmartScanFields || isTypeSend || props.isEditingSplitBill; // In Send Money flow, we don't allow the Merchant or Date to be edited. const shouldShowDate = shouldShowAllFields && !isTypeSend; @@ -239,10 +237,30 @@ function MoneyRequestConfirmationList(props) { const isFocused = useIsFocused(); const [formError, setFormError] = useState(''); + + const [didConfirm, setDidConfirm] = useState(false); + const [didConfirmSplit, setDidConfirmSplit] = useState(false); + + const shouldDisplayFieldError = useMemo(() => { + if (!props.isEditingSplitBill) { + return false; + } + + return (props.hasSmartScanFailed && TransactionUtils.hasMissingSmartscanFields(transaction)) || (didConfirmSplit && TransactionUtils.areRequiredFieldsEmpty(transaction)); + }, [props.isEditingSplitBill, props.hasSmartScanFailed, transaction, didConfirmSplit]); + useEffect(() => { + if (shouldDisplayFieldError && props.hasSmartScanFailed) { + setFormError('iou.receiptScanningFailed'); + return; + } + if (shouldDisplayFieldError && didConfirmSplit) { + setFormError('iou.error.genericSmartscanFailureMessage'); + return; + } // reset the form error whenever the screen gains or loses focus setFormError(''); - }, [isFocused]); + }, [isFocused, transaction, shouldDisplayFieldError, props.hasSmartScanFailed, didConfirmSplit]); useEffect(() => { if (!shouldCalculateDistanceAmount) { @@ -269,25 +287,28 @@ function MoneyRequestConfirmationList(props) { [props.iouAmount, props.iouCurrencyCode], ); - const [didConfirm, setDidConfirm] = useState(false); + // If completing a split bill fails, set didConfirm to false to allow the user to edit the fields again + if (props.isEditingSplitBill && didConfirm) { + setDidConfirm(false); + } const splitOrRequestOptions = useMemo(() => { let text; - if (props.receiptPath && props.hasMultipleParticipants && props.iouAmount === 0) { + if (isSplitBill && props.iouAmount === 0) { text = translate('iou.split'); - } else if (props.receiptPath || isDistanceRequestWithoutRoute) { + } else if ((props.receiptPath && isTypeRequest) || isDistanceRequestWithoutRoute) { text = translate('iou.request'); } else { - const translationKey = props.hasMultipleParticipants ? 'iou.splitAmount' : 'iou.requestAmount'; + const translationKey = isSplitBill ? 'iou.splitAmount' : 'iou.requestAmount'; text = translate(translationKey, {amount: formattedAmount}); } return [ { text: text[0].toUpperCase() + text.slice(1), - value: props.hasMultipleParticipants ? CONST.IOU.MONEY_REQUEST_TYPE.SPLIT : CONST.IOU.MONEY_REQUEST_TYPE.REQUEST, + value: props.iouType, }, ]; - }, [props.hasMultipleParticipants, props.iouAmount, props.receiptPath, translate, formattedAmount, isDistanceRequestWithoutRoute]); + }, [isSplitBill, isTypeRequest, props.iouType, props.iouAmount, props.receiptPath, formattedAmount, isDistanceRequestWithoutRoute, translate]); const selectedParticipants = useMemo(() => _.filter(props.selectedParticipants, (participant) => participant.selected), [props.selectedParticipants]); const payeePersonalDetails = useMemo(() => props.payeePersonalDetails || props.currentUserPersonalDetails, [props.payeePersonalDetails, props.currentUserPersonalDetails]); @@ -424,11 +445,28 @@ function MoneyRequestConfirmationList(props) { return; } + if (props.isEditingSplitBill && TransactionUtils.areRequiredFieldsEmpty(transaction)) { + setDidConfirmSplit(true); + setFormError('iou.error.genericSmartscanFailureMessage'); + return; + } + setDidConfirm(true); onConfirm(selectedParticipants); } }, - [selectedParticipants, onSendMoney, onConfirm, props.iouType, props.isDistanceRequest, isDistanceRequestWithoutRoute, props.iouCurrencyCode, props.iouAmount], + [ + selectedParticipants, + onSendMoney, + onConfirm, + props.isEditingSplitBill, + props.iouType, + props.isDistanceRequest, + isDistanceRequestWithoutRoute, + props.iouCurrencyCode, + props.iouAmount, + transaction, + ], ); const footerContent = useMemo(() => { @@ -518,15 +556,27 @@ function MoneyRequestConfirmationList(props) { isAuthTokenRequired={!_.isEmpty(receiptThumbnail)} /> )} - {shouldShowSmartScanFields && ( + {props.shouldShowSmartScanFields && ( !props.isDistanceRequest && Navigation.navigate(ROUTES.MONEY_REQUEST_AMOUNT.getRoute(props.iouType, props.reportID))} + interactive={!props.isReadOnly} + onPress={() => { + if (props.isDistanceRequest) { + return; + } + if (props.isEditingSplitBill) { + Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.AMOUNT)); + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_AMOUNT.getRoute(props.iouType, props.reportID)); + }} style={[styles.moneyRequestMenuItem, styles.mt2]} titleStyle={styles.moneyRequestConfirmationAmount} - disabled={didConfirm || props.isReadOnly} + disabled={didConfirm} + brickRoadIndicator={shouldDisplayFieldError && !transaction.modifiedAmount ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + error={shouldDisplayFieldError && !transaction.modifiedAmount ? translate('common.error.enterAmount') : ''} /> )} Navigation.navigate(ROUTES.MONEY_REQUEST_DESCRIPTION.getRoute(props.iouType, props.reportID))} + onPress={() => { + if (props.isEditingSplitBill) { + Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.DESCRIPTION)); + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_DESCRIPTION.getRoute(props.iouType, props.reportID)); + }} style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} - disabled={didConfirm || props.isReadOnly} + disabled={didConfirm} + interactive={!props.isReadOnly} numberOfLinesTitle={2} /> {!shouldShowAllFields && ( @@ -559,13 +616,22 @@ function MoneyRequestConfirmationList(props) { <> {shouldShowDate && ( Navigation.navigate(ROUTES.MONEY_REQUEST_DATE.getRoute(props.iouType, props.reportID))} - disabled={didConfirm || props.isReadOnly} + onPress={() => { + if (props.isEditingSplitBill) { + Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.DATE)); + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_DATE.getRoute(props.iouType, props.reportID)); + }} + disabled={didConfirm} + interactive={!props.isReadOnly} + brickRoadIndicator={shouldDisplayFieldError && _.isEmpty(transaction.modifiedCreated) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} + error={shouldDisplayFieldError && _.isEmpty(transaction.modifiedCreated) ? translate('common.error.enterDate') : ''} /> )} {props.isDistanceRequest && ( @@ -576,18 +642,36 @@ function MoneyRequestConfirmationList(props) { style={[styles.moneyRequestMenuItem]} titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_DISTANCE.getRoute(props.iouType, props.reportID))} - disabled={didConfirm || props.isReadOnly || !isTypeRequest} + disabled={didConfirm || !isTypeRequest} + interactive={!props.isReadOnly} /> )} {shouldShowMerchant && ( Navigation.navigate(ROUTES.MONEY_REQUEST_MERCHANT.getRoute(props.iouType, props.reportID))} - disabled={didConfirm || props.isReadOnly} + onPress={() => { + if (props.isEditingSplitBill) { + Navigation.navigate(ROUTES.EDIT_SPLIT_BILL.getRoute(props.reportID, props.reportActionID, CONST.EDIT_REQUEST_FIELD.MERCHANT)); + return; + } + Navigation.navigate(ROUTES.MONEY_REQUEST_MERCHANT.getRoute(props.iouType, props.reportID)); + }} + disabled={didConfirm} + interactive={!props.isReadOnly} + brickRoadIndicator={ + shouldDisplayFieldError && (transaction.modifiedMerchant === '' || transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT) + ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR + : '' + } + error={ + shouldDisplayFieldError && (transaction.modifiedMerchant === '' || transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT) + ? translate('common.error.enterMerchant') + : '' + } /> )} {shouldShowCategories && ( @@ -597,7 +681,8 @@ function MoneyRequestConfirmationList(props) { description={translate('common.category')} onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_CATEGORY.getRoute(props.iouType, props.reportID))} style={[styles.moneyRequestMenuItem]} - disabled={didConfirm || props.isReadOnly} + disabled={didConfirm} + interactive={!props.isReadOnly} /> )} {shouldShowTags && ( @@ -607,7 +692,8 @@ function MoneyRequestConfirmationList(props) { description={policyTagListName} onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_TAG.getRoute(props.iouType, props.reportID))} style={[styles.moneyRequestMenuItem]} - disabled={didConfirm || props.isReadOnly} + disabled={didConfirm} + interactive={!props.isReadOnly} /> )} {shouldShowBillable && ( @@ -648,6 +734,9 @@ export default compose( key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, selector: DistanceRequestUtils.getDefaultMileageRate, }, + draftTransaction: { + key: ({transactionID}) => `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, + }, transaction: { key: ({transactionID}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, }, diff --git a/src/languages/en.ts b/src/languages/en.ts index 305ea5bf95bd..f4c7bdc6ee60 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -533,6 +533,7 @@ export default { receiptMissingDetails: 'Receipt missing details', receiptStatusTitle: 'Scanning…', receiptStatusText: "Only you can see this receipt when it's scanning. Check back later or enter the details now.", + receiptScanningFailed: 'Receipt scanning failed. Enter the details manually.', requestCount: ({count, scanningReceipts = 0}: RequestCountParams) => `${count} requests${scanningReceipts > 0 ? `, ${scanningReceipts} scanning` : ''}`, deleteRequest: 'Delete request', deleteConfirmation: 'Are you sure that you want to delete this request?', diff --git a/src/languages/es.ts b/src/languages/es.ts index 147a0fc79851..1bbb056e82ef 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -525,6 +525,7 @@ export default { receiptMissingDetails: 'Recibo con campos vacíos', receiptStatusTitle: 'Escaneando…', receiptStatusText: 'Solo tú puedes ver este recibo cuando se está escaneando. Vuelve más tarde o introduce los detalles ahora.', + receiptScanningFailed: 'El escaneo de recibo ha fallado. Introduce los detalles manualmente.', requestCount: ({count, scanningReceipts = 0}: RequestCountParams) => `${count} solicitudes${scanningReceipts > 0 ? `, ${scanningReceipts} escaneando` : ''}`, deleteRequest: 'Eliminar pedido', deleteConfirmation: '¿Estás seguro de que quieres eliminar este pedido?', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index ea557709da3c..2d0fdd281422 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -53,6 +53,8 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator({ const SplitDetailsModalStackNavigator = createModalStackNavigator({ SplitDetails_Root: () => require('../../../pages/iou/SplitBillDetailsPage').default, + SplitDetails_Edit_Request: () => require('../../../pages/EditSplitBillPage').default, + SplitDetails_Edit_Currency: () => require('../../../pages/iou/IOUCurrencySelection').default, }); const DetailsModalStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index 60182b925a32..fde5fe400c76 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -358,6 +358,8 @@ export default { SplitDetails: { screens: { SplitDetails_Root: ROUTES.SPLIT_BILL_DETAILS.route, + SplitDetails_Edit_Request: ROUTES.EDIT_SPLIT_BILL.route, + SplitDetails_Edit_Currency: ROUTES.EDIT_SPLIT_BILL_CURRENCY.route, }, }, Task_Details: { diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index 2b7729abc442..a3c8f289d1c5 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -84,20 +84,25 @@ function hasReceipt(transaction: Transaction | undefined | null): boolean { } function areRequiredFieldsEmpty(transaction: Transaction): boolean { - return ( + const isMerchantEmpty = + transaction.merchant === CONST.TRANSACTION.UNKNOWN_MERCHANT || transaction.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || transaction.merchant === ''; + + const isModifiedMerchantEmpty = + !transaction.modifiedMerchant || transaction.modifiedMerchant === CONST.TRANSACTION.UNKNOWN_MERCHANT || transaction.modifiedMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT || - (transaction.modifiedMerchant === '' && - (transaction.merchant === CONST.TRANSACTION.UNKNOWN_MERCHANT || transaction.merchant === '' || transaction.merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT)) || - (transaction.modifiedAmount === 0 && transaction.amount === 0) || - (transaction.modifiedCreated === '' && transaction.created === '') - ); + transaction.modifiedMerchant === ''; + + const isModifiedAmountEmpty = !transaction.modifiedAmount || transaction.modifiedAmount === 0; + const isModifiedCreatedEmpty = !transaction.modifiedCreated || transaction.modifiedCreated === ''; + + return (isModifiedMerchantEmpty && isMerchantEmpty) || (isModifiedAmountEmpty && transaction.amount === 0) || (isModifiedCreatedEmpty && transaction.created === ''); } /** * Given the edit made to the money request, return an updated transaction object. */ -function getUpdatedTransaction(transaction: Transaction, transactionChanges: TransactionChanges, isFromExpenseReport: boolean): Transaction { +function getUpdatedTransaction(transaction: Transaction, transactionChanges: TransactionChanges, isFromExpenseReport: boolean, shouldUpdateReceiptState = true): Transaction { // Only changing the first level fields so no need for deep clone now const updatedTransaction = {...transaction}; let shouldStopSmartscan = false; @@ -144,7 +149,13 @@ function getUpdatedTransaction(transaction: Transaction, transactionChanges: Tra updatedTransaction.tag = transactionChanges.tag; } - if (shouldStopSmartscan && transaction?.receipt && Object.keys(transaction.receipt).length > 0 && transaction?.receipt?.state !== CONST.IOU.RECEIPT_STATE.OPEN) { + if ( + shouldUpdateReceiptState && + shouldStopSmartscan && + transaction?.receipt && + Object.keys(transaction.receipt).length > 0 && + transaction?.receipt?.state !== CONST.IOU.RECEIPT_STATE.OPEN + ) { updatedTransaction.receipt.state = CONST.IOU.RECEIPT_STATE.OPEN; } @@ -438,6 +449,7 @@ export { isPending, isPosted, getWaypoints, + areRequiredFieldsEmpty, hasMissingSmartscanFields, getWaypointIndex, waypointHasValidAddress, diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index bc6ee9be4e0b..c469ed02a084 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -53,6 +53,15 @@ Onyx.connect({ }, }); +let allDraftSplitTransactions; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT, + waitForCollectionCallback: true, + callback: (val) => { + allDraftSplitTransactions = val || {}; + }, +}); + let allRecentlyUsedTags = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS, @@ -1489,6 +1498,229 @@ function startSplitBill(participants, currentUserLogin, currentUserAccountID, co Report.notifyNewAction(splitChatReport.chatReportID, currentUserAccountID); } +/** Used for editing a split bill while it's still scanning or when SmartScan fails, it completes a split bill started by startSplitBill above. + * + * @param {number} chatReportID - The group chat or workspace reportID + * @param {Object} reportAction - The split action that lives in the chatReport above + * @param {Object} updatedTransaction - The updated **draft** split transaction + * @param {Number} sessionAccountID - accountID of the current user + * @param {String} sessionEmail - email of the current user + */ +function completeSplitBill(chatReportID, reportAction, updatedTransaction, sessionAccountID, sessionEmail) { + const currentUserEmailForIOUSplit = OptionsListUtils.addSMSDomainIfPhoneNumber(sessionEmail); + const {transactionID} = updatedTransaction; + const unmodifiedTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + + // Save optimistic updated transaction and action + const optimisticData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...updatedTransaction, + receipt: { + state: CONST.IOU.RECEIPT_STATE.OPEN, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + value: { + [reportAction.reportActionID]: { + lastModified: DateUtils.getDBTime(), + whisperedToAccountIDs: [], + }, + }, + }, + ]; + + const successData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: {pendingAction: null}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, + value: null, + }, + ]; + + const failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...unmodifiedTransaction, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReportID}`, + value: { + [reportAction.reportActionID]: { + ...reportAction, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), + }, + }, + }, + ]; + + const splitParticipants = updatedTransaction.comment.splits; + const {modifiedAmount: amount, modifiedCurrency: currency} = updatedTransaction; + + // Exclude the current user when calculating the split amount, `calculateAmount` takes it into account + const splitAmount = IOUUtils.calculateAmount(splitParticipants.length - 1, amount, currency, false); + + const splits = [{email: currentUserEmailForIOUSplit}]; + _.each(splitParticipants, (participant) => { + // Skip creating the transaction for the current user + if (participant.email === currentUserEmailForIOUSplit) { + return; + } + const isPolicyExpenseChat = !_.isEmpty(participant.policyID); + + if (!isPolicyExpenseChat) { + // In case this is still the optimistic accountID saved in the splits array, return early as we cannot know + // if there is an existing chat between the split creator and this participant + // Instead, we will rely on Auth generating the report IDs and the user won't see any optimistic chats or reports created + const participantPersonalDetails = allPersonalDetails[participant.accountID] || {}; + if (!participantPersonalDetails || participantPersonalDetails.isOptimisticPersonalDetail) { + splits.push({ + email: participant.email, + }); + return; + } + } + + let oneOnOneChatReport; + let isNewOneOnOneChatReport = false; + if (isPolicyExpenseChat) { + // The workspace chat reportID is saved in the splits array when starting a split bill with a workspace + oneOnOneChatReport = allReports[`${ONYXKEYS.COLLECTION.REPORT}${participant.chatReportID}`]; + } else { + const existingChatReport = ReportUtils.getChatByParticipants([participant.accountID]); + isNewOneOnOneChatReport = !existingChatReport; + oneOnOneChatReport = existingChatReport || ReportUtils.buildOptimisticChatReport([participant.accountID]); + } + + let oneOnOneIOUReport = lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${oneOnOneChatReport.iouReportID}`, undefined); + const shouldCreateNewOneOnOneIOUReport = + _.isUndefined(oneOnOneIOUReport) || (isPolicyExpenseChat && ReportUtils.isControlPolicyExpenseReport(oneOnOneIOUReport) && ReportUtils.isReportApproved(oneOnOneIOUReport)); + + if (shouldCreateNewOneOnOneIOUReport) { + oneOnOneIOUReport = isPolicyExpenseChat + ? ReportUtils.buildOptimisticExpenseReport(oneOnOneChatReport.reportID, participant.policyID, sessionAccountID, splitAmount, currency) + : ReportUtils.buildOptimisticIOUReport(sessionAccountID, participant.accountID, splitAmount, oneOnOneChatReport.reportID, currency); + } else if (isPolicyExpenseChat) { + // Because of the Expense reports are stored as negative values, we subtract the total from the amount + oneOnOneIOUReport.total -= splitAmount; + } else { + oneOnOneIOUReport = IOUUtils.updateIOUOwnerAndTotal(oneOnOneIOUReport, sessionAccountID, splitAmount, currency); + } + + const oneOnOneTransaction = TransactionUtils.buildOptimisticTransaction( + isPolicyExpenseChat ? -splitAmount : splitAmount, + currency, + oneOnOneIOUReport.reportID, + updatedTransaction.comment.comment, + updatedTransaction.modifiedCreated, + CONST.IOU.MONEY_REQUEST_TYPE.SPLIT, + transactionID, + updatedTransaction.modifiedMerchant, + {...updatedTransaction.receipt, state: CONST.IOU.RECEIPT_STATE.OPEN}, + updatedTransaction.filename, + ); + + const oneOnOneCreatedActionForChat = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmailForIOUSplit); + const oneOnOneCreatedActionForIOU = ReportUtils.buildOptimisticCreatedReportAction(currentUserEmailForIOUSplit); + const oneOnOneIOUAction = ReportUtils.buildOptimisticIOUReportAction( + CONST.IOU.REPORT_ACTION_TYPE.CREATE, + splitAmount, + currency, + updatedTransaction.comment.comment, + [participant], + oneOnOneTransaction.transactionID, + '', + oneOnOneIOUReport.reportID, + ); + + let oneOnOneReportPreviewAction = ReportActionsUtils.getReportPreviewAction(oneOnOneChatReport.reportID, oneOnOneIOUReport.reportID); + if (oneOnOneReportPreviewAction) { + oneOnOneReportPreviewAction = ReportUtils.updateReportPreview(oneOnOneIOUReport, oneOnOneReportPreviewAction); + } else { + oneOnOneReportPreviewAction = ReportUtils.buildOptimisticReportPreview(oneOnOneChatReport, oneOnOneIOUReport, '', oneOnOneTransaction); + } + + const [oneOnOneOptimisticData, oneOnOneSuccessData, oneOnOneFailureData] = buildOnyxDataForMoneyRequest( + oneOnOneChatReport, + oneOnOneIOUReport, + oneOnOneTransaction, + oneOnOneCreatedActionForChat, + oneOnOneCreatedActionForIOU, + oneOnOneIOUAction, + {}, + oneOnOneReportPreviewAction, + {}, + {}, + isNewOneOnOneChatReport, + shouldCreateNewOneOnOneIOUReport, + ); + + splits.push({ + email: participant.email, + accountID: participant.accountID, + policyID: participant.policyID, + iouReportID: oneOnOneIOUReport.reportID, + chatReportID: oneOnOneChatReport.reportID, + transactionID: oneOnOneTransaction.transactionID, + reportActionID: oneOnOneIOUAction.reportActionID, + createdChatReportActionID: oneOnOneCreatedActionForChat.reportActionID, + createdIOUReportActionID: oneOnOneCreatedActionForIOU.reportActionID, + reportPreviewReportActionID: oneOnOneReportPreviewAction.reportActionID, + }); + + optimisticData.push(...oneOnOneOptimisticData); + successData.push(...oneOnOneSuccessData); + failureData.push(...oneOnOneFailureData); + }); + + API.write( + 'CompleteSplitBill', + { + transactionID, + amount: updatedTransaction.modifiedAmount, + currency: updatedTransaction.modifiedCurrency, + created: updatedTransaction.modifiedCreated, + merchant: updatedTransaction.modifiedMerchant, + comment: updatedTransaction.comment.comment, + splits: JSON.stringify(splits), + }, + {optimisticData, successData, failureData}, + ); + Navigation.dismissModal(chatReportID); + Report.notifyNewAction(chatReportID, sessionAccountID); +} + +/** + * @param {String} transactionID + * @param {Object} transactionChanges + */ +function setDraftSplitTransaction(transactionID, transactionChanges = {}) { + let draftSplitTransaction = allDraftSplitTransactions[`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`]; + + if (!draftSplitTransaction) { + draftSplitTransaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + } + + const updatedTransaction = TransactionUtils.getUpdatedTransaction(draftSplitTransaction, transactionChanges, false, false); + + Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, updatedTransaction); +} + /** * @param {String} transactionID * @param {Number} transactionThreadReportID @@ -2660,7 +2892,9 @@ export { deleteMoneyRequest, splitBill, splitBillAndOpenReport, + setDraftSplitTransaction, startSplitBill, + completeSplitBill, requestMoney, sendMoneyElsewhere, approveMoneyRequest, diff --git a/src/pages/EditRequestAmountPage.js b/src/pages/EditRequestAmountPage.js index 9f72c9afbc23..d65fdafb3b59 100644 --- a/src/pages/EditRequestAmountPage.js +++ b/src/pages/EditRequestAmountPage.js @@ -1,13 +1,11 @@ import React, {useCallback, useRef} from 'react'; -import {InteractionManager} from 'react-native'; import {useFocusEffect} from '@react-navigation/native'; import PropTypes from 'prop-types'; +import CONST from '../CONST'; +import useLocalize from '../hooks/useLocalize'; import ScreenWrapper from '../components/ScreenWrapper'; import HeaderWithBackButton from '../components/HeaderWithBackButton'; -import Navigation from '../libs/Navigation/Navigation'; -import useLocalize from '../hooks/useLocalize'; import MoneyRequestAmountForm from './iou/steps/MoneyRequestAmountForm'; -import ROUTES from '../ROUTES'; const propTypes = { /** Transaction default amount value */ @@ -19,36 +17,25 @@ const propTypes = { /** Callback to fire when the Save button is pressed */ onSubmit: PropTypes.func.isRequired, - /** reportID for the transaction thread */ - reportID: PropTypes.string.isRequired, + /** Callback to fire when we press on the currency */ + onNavigateToCurrency: PropTypes.func.isRequired, }; -function EditRequestAmountPage({defaultAmount, defaultCurrency, onSubmit, reportID}) { +function EditRequestAmountPage({defaultAmount, defaultCurrency, onNavigateToCurrency, onSubmit}) { const {translate} = useLocalize(); - const textInput = useRef(null); - const focusTextInput = () => { - // Component may not be initialized due to navigation transitions - // Wait until interactions are complete before trying to focus - InteractionManager.runAfterInteractions(() => { - // Focus text input - if (!textInput.current) { - return; - } - - textInput.current.focus(); - }); - }; - - const navigateToCurrencySelectionPage = () => { - // Remove query from the route and encode it. - const activeRoute = encodeURIComponent(Navigation.getActiveRoute().replace(/\?.*/, '')); - Navigation.navigate(ROUTES.EDIT_CURRENCY_REQUEST.getRoute(reportID, defaultCurrency, activeRoute)); - }; + const textInput = useRef(null); + const focusTimeoutRef = useRef(null); useFocusEffect( useCallback(() => { - focusTextInput(); + focusTimeoutRef.current = setTimeout(() => textInput.current && textInput.current.focus(), CONST.ANIMATED_TRANSITION); + return () => { + if (!focusTimeoutRef.current) { + return; + } + clearTimeout(focusTimeoutRef.current); + }; }, []), ); @@ -64,7 +51,7 @@ function EditRequestAmountPage({defaultAmount, defaultCurrency, onSubmit, report currency={defaultCurrency} amount={defaultAmount} ref={(e) => (textInput.current = e)} - onCurrencyButtonPress={navigateToCurrencySelectionPage} + onCurrencyButtonPress={onNavigateToCurrency} onSubmitButtonPress={onSubmit} /> diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js index 8b19c7cb7b60..a85f490bbb42 100644 --- a/src/pages/EditRequestPage.js +++ b/src/pages/EditRequestPage.js @@ -5,6 +5,7 @@ import lodashValues from 'lodash/values'; import {withOnyx} from 'react-native-onyx'; import CONST from '../CONST'; import ONYXKEYS from '../ONYXKEYS'; +import ROUTES from '../ROUTES'; import compose from '../libs/compose'; import Navigation from '../libs/Navigation/Navigation'; import * as ReportActionsUtils from '../libs/ReportActionsUtils'; @@ -205,6 +206,10 @@ function EditRequestPage({betas, report, route, parentReport, policy, session, p currency: defaultCurrency, }); }} + onNavigateToCurrency={() => { + const activeRoute = encodeURIComponent(Navigation.getActiveRoute().replace(/\?.*/, '')); + Navigation.navigate(ROUTES.EDIT_CURRENCY_REQUEST.getRoute(report.reportID, defaultCurrency, activeRoute)); + }} /> ); } diff --git a/src/pages/EditSplitBillPage.js b/src/pages/EditSplitBillPage.js new file mode 100644 index 000000000000..217b1a100572 --- /dev/null +++ b/src/pages/EditSplitBillPage.js @@ -0,0 +1,161 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import lodashGet from 'lodash/get'; +import {withOnyx} from 'react-native-onyx'; +import CONST from '../CONST'; +import ROUTES from '../ROUTES'; +import ONYXKEYS from '../ONYXKEYS'; +import compose from '../libs/compose'; +import transactionPropTypes from '../components/transactionPropTypes'; +import * as ReportUtils from '../libs/ReportUtils'; +import * as IOU from '../libs/actions/IOU'; +import * as CurrencyUtils from '../libs/CurrencyUtils'; +import Navigation from '../libs/Navigation/Navigation'; +import FullPageNotFoundView from '../components/BlockingViews/FullPageNotFoundView'; +import EditRequestDescriptionPage from './EditRequestDescriptionPage'; +import EditRequestMerchantPage from './EditRequestMerchantPage'; +import EditRequestCreatedPage from './EditRequestCreatedPage'; +import EditRequestAmountPage from './EditRequestAmountPage'; + +const propTypes = { + /** Route from navigation */ + route: PropTypes.shape({ + /** Params from the route */ + params: PropTypes.shape({ + /** The transaction field we are editing */ + field: PropTypes.string, + + /** The chat reportID of the split */ + reportID: PropTypes.string, + + /** reportActionID of the split action */ + reportActionID: PropTypes.string, + }), + }).isRequired, + + /** The current transaction */ + transaction: transactionPropTypes.isRequired, + + /** The draft transaction that holds data to be persisted on the current transaction */ + draftTransaction: PropTypes.shape(transactionPropTypes), +}; + +const defaultProps = { + draftTransaction: {}, +}; + +function EditSplitBillPage({route, transaction, draftTransaction}) { + const fieldToEdit = lodashGet(route, ['params', 'field'], ''); + const reportID = lodashGet(route, ['params', 'reportID'], ''); + const reportActionID = lodashGet(route, ['params', 'reportActionID'], ''); + + const { + amount: transactionAmount, + currency: transactionCurrency, + comment: transactionDescription, + merchant: transactionMerchant, + created: transactionCreated, + } = draftTransaction ? ReportUtils.getTransactionDetails(draftTransaction) : ReportUtils.getTransactionDetails(transaction); + + const defaultCurrency = lodashGet(route, 'params.currency', '') || transactionCurrency; + + function navigateBackToSplitDetails() { + Navigation.navigate(ROUTES.SPLIT_BILL_DETAILS.getRoute(reportID, reportActionID)); + } + + function setDraftSplitTransaction(transactionChanges) { + IOU.setDraftSplitTransaction(transaction.transactionID, transactionChanges); + navigateBackToSplitDetails(); + } + + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DESCRIPTION) { + return ( + { + setDraftSplitTransaction({ + comment: transactionChanges.comment.trim(), + }); + }} + /> + ); + } + + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DATE) { + return ( + { + setDraftSplitTransaction({ + created: transactionChanges.created, + }); + }} + /> + ); + } + + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.AMOUNT) { + return ( + { + const amount = CurrencyUtils.convertToBackendAmount(Number.parseFloat(transactionChanges)); + + setDraftSplitTransaction({ + amount, + currency: defaultCurrency, + }); + }} + onNavigateToCurrency={() => { + const activeRoute = encodeURIComponent(Navigation.getActiveRoute().replace(/\?.*/, '')); + Navigation.navigate(ROUTES.EDIT_SPLIT_BILL_CURRENCY.getRoute(reportID, reportActionID, defaultCurrency, activeRoute)); + }} + /> + ); + } + + if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.MERCHANT) { + return ( + { + setDraftSplitTransaction({merchant: transactionChanges.merchant.trim()}); + }} + /> + ); + } + + return ; +} + +EditSplitBillPage.displayName = 'EditSplitBillPage'; +EditSplitBillPage.propTypes = propTypes; +EditSplitBillPage.defaultProps = defaultProps; +export default compose( + withOnyx({ + reportActions: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${route.params.reportID}`, + canEvict: false, + }, + }), + // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file + withOnyx({ + transaction: { + key: ({route, reportActions}) => { + const reportAction = reportActions[`${route.params.reportActionID.toString()}`]; + return `${ONYXKEYS.COLLECTION.TRANSACTION}${lodashGet(reportAction, 'originalMessage.IOUTransactionID', 0)}`; + }, + }, + draftTransaction: { + key: ({route, reportActions}) => { + const reportAction = reportActions[`${route.params.reportActionID.toString()}`]; + return `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${lodashGet(reportAction, 'originalMessage.IOUTransactionID', 0)}`; + }, + }, + }), +)(EditSplitBillPage); diff --git a/src/pages/iou/SplitBillDetailsPage.js b/src/pages/iou/SplitBillDetailsPage.js index 4b5037c5eca3..1b3052e9e72c 100644 --- a/src/pages/iou/SplitBillDetailsPage.js +++ b/src/pages/iou/SplitBillDetailsPage.js @@ -1,25 +1,27 @@ -import React from 'react'; +import React, {useCallback} from 'react'; import _ from 'underscore'; -import lodashGet from 'lodash/get'; import {View} from 'react-native'; import PropTypes from 'prop-types'; +import lodashGet from 'lodash/get'; import {withOnyx} from 'react-native-onyx'; import styles from '../../styles/styles'; import ONYXKEYS from '../../ONYXKEYS'; +import CONST from '../../CONST'; import * as OptionsListUtils from '../../libs/OptionsListUtils'; -import ScreenWrapper from '../../components/ScreenWrapper'; -import MoneyRequestConfirmationList from '../../components/MoneyRequestConfirmationList'; import personalDetailsPropType from '../personalDetailsPropType'; import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; import compose from '../../libs/compose'; import reportActionPropTypes from '../home/report/reportActionPropTypes'; import reportPropTypes from '../reportPropTypes'; +import transactionPropTypes from '../../components/transactionPropTypes'; import withReportAndReportActionOrNotFound from '../home/report/withReportAndReportActionOrNotFound'; -import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView'; -import CONST from '../../CONST'; -import HeaderWithBackButton from '../../components/HeaderWithBackButton'; import * as TransactionUtils from '../../libs/TransactionUtils'; import * as ReportUtils from '../../libs/ReportUtils'; +import * as IOU from '../../libs/actions/IOU'; +import ScreenWrapper from '../../components/ScreenWrapper'; +import MoneyRequestConfirmationList from '../../components/MoneyRequestConfirmationList'; +import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView'; +import HeaderWithBackButton from '../../components/HeaderWithBackButton'; import MoneyRequestHeaderStatusBar from '../../components/MoneyRequestHeaderStatusBar'; const propTypes = { @@ -34,6 +36,12 @@ const propTypes = { /** Array of report actions for this report */ reportActions: PropTypes.shape(reportActionPropTypes), + /** The current transaction */ + transaction: transactionPropTypes.isRequired, + + /** The draft transaction that holds data to be persisited on the current transaction */ + draftTransaction: PropTypes.shape(transactionPropTypes), + /** Route params */ route: PropTypes.shape({ params: PropTypes.shape({ @@ -45,17 +53,27 @@ const propTypes = { }), }).isRequired, + /** Session info for the currently logged in user. */ + session: PropTypes.shape({ + /** Currently logged in user accountID */ + accountID: PropTypes.number, + + /** Currently logged in user email */ + email: PropTypes.string, + }).isRequired, + ...withLocalizePropTypes, }; const defaultProps = { personalDetails: {}, reportActions: {}, + draftTransaction: undefined, }; function SplitBillDetailsPage(props) { + const {reportID} = props.report; const reportAction = props.reportActions[`${props.route.params.reportActionID.toString()}`]; - const transaction = TransactionUtils.getLinkedTransaction(reportAction); const participantAccountIDs = reportAction.originalMessage.participantAccountIDs; // In case this is workspace split bill, we manually add the workspace as the second participant of the split bill @@ -71,19 +89,29 @@ function SplitBillDetailsPage(props) { } const payeePersonalDetails = props.personalDetails[reportAction.actorAccountID]; const participantsExcludingPayee = _.filter(participants, (participant) => participant.accountID !== reportAction.actorAccountID); + + const isScanning = + TransactionUtils.hasReceipt(props.transaction) && TransactionUtils.isReceiptBeingScanned(props.transaction) && TransactionUtils.areRequiredFieldsEmpty(props.transaction); + const hasSmartScanFailed = TransactionUtils.hasReceipt(props.transaction) && props.transaction.receipt.state === CONST.IOU.RECEIPT_STATE.SCANFAILED; + const isEditingSplitBill = props.session.accountID === reportAction.actorAccountID && (TransactionUtils.areRequiredFieldsEmpty(props.transaction) || hasSmartScanFailed); + const { amount: splitAmount, currency: splitCurrency, + comment: splitComment, merchant: splitMerchant, created: splitCreated, - comment: splitComment, category: splitCategory, - } = ReportUtils.getTransactionDetails(transaction); - const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); + } = isEditingSplitBill && props.draftTransaction ? ReportUtils.getTransactionDetails(props.draftTransaction) : ReportUtils.getTransactionDetails(props.transaction); + + const onConfirm = useCallback( + () => IOU.completeSplitBill(reportID, reportAction, props.draftTransaction, props.session.accountID, props.session.email), + [reportID, reportAction, props.draftTransaction, props.session.accountID, props.session.email], + ); return ( - + )} @@ -124,8 +158,33 @@ export default compose( withLocalize, withReportAndReportActionOrNotFound, withOnyx({ + report: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`, + }, + reportActions: { + key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${route.params.reportID}`, + canEvict: false, + }, personalDetails: { key: ONYXKEYS.PERSONAL_DETAILS_LIST, }, + session: { + key: ONYXKEYS.SESSION, + }, + }), + // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file + withOnyx({ + transaction: { + key: ({route, reportActions}) => { + const reportAction = reportActions[`${route.params.reportActionID.toString()}`]; + return `${ONYXKEYS.COLLECTION.TRANSACTION}${lodashGet(reportAction, 'originalMessage.IOUTransactionID', 0)}`; + }, + }, + draftTransaction: { + key: ({route, reportActions}) => { + const reportAction = reportActions[`${route.params.reportActionID.toString()}`]; + return `${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${lodashGet(reportAction, 'originalMessage.IOUTransactionID', 0)}`; + }, + }, }), )(SplitBillDetailsPage); diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js index a35b363e9ef6..de0e0a16c214 100644 --- a/src/pages/iou/steps/MoneyRequestConfirmPage.js +++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js @@ -380,6 +380,7 @@ function MoneyRequestConfirmPage(props) { iouCreated={props.iou.created} isDistanceRequest={isDistanceRequest} listStyles={[StyleUtils.getMaximumHeight(windowHeight / 3)]} + shouldShowSmartScanFields={_.isEmpty(props.iou.receiptPath)} />