diff --git a/assets/images/invoice-generic.svg b/assets/images/invoice-generic.svg new file mode 100644 index 000000000000..d0e2662c4084 --- /dev/null +++ b/assets/images/invoice-generic.svg @@ -0,0 +1,15 @@ + + + + + + + diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 88a7adcbcb84..28c854d46203 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -314,6 +314,11 @@ const ROUTES = { route: ':action/:iouType/start/:transactionID/:reportID', getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string) => `${action as string}/${iouType as string}/start/${transactionID}/${reportID}` as const, }, + MONEY_REQUEST_STEP_SEND_FROM: { + route: 'create/:iouType/from/:transactionID/:reportID', + getRoute: (iouType: ValueOf, transactionID: string, reportID: string, backTo = '') => + getUrlWithBackToParam(`create/${iouType as string}/from/${transactionID}/${reportID}`, backTo), + }, MONEY_REQUEST_STEP_CONFIRMATION: { route: ':action/:iouType/confirmation/:transactionID/:reportID', getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string) => diff --git a/src/SCREENS.ts b/src/SCREENS.ts index daf2a9791930..bfe2935eeb7f 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -161,6 +161,7 @@ const SCREENS = { STEP_WAYPOINT: 'Money_Request_Step_Waypoint', STEP_TAX_AMOUNT: 'Money_Request_Step_Tax_Amount', STEP_TAX_RATE: 'Money_Request_Step_Tax_Rate', + STEP_SEND_FROM: 'Money_Request_Step_Send_From', CURRENCY: 'Money_Request_Currency', WAYPOINT: 'Money_Request_Waypoint', EDIT_WAYPOINT: 'Money_Request_Edit_Waypoint', diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 208543b2d9c2..822ae0a04a42 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -88,6 +88,7 @@ import ImageCropSquareMask from '@assets/images/image-crop-square-mask.svg'; import Info from '@assets/images/info.svg'; import QBOSquare from '@assets/images/integrationicons/qbo-icon-square.svg'; import XeroSquare from '@assets/images/integrationicons/xero-icon-square.svg'; +import InvoiceGeneric from '@assets/images/invoice-generic.svg'; import Invoice from '@assets/images/invoice.svg'; import Key from '@assets/images/key.svg'; import Keyboard from '@assets/images/keyboard.svg'; @@ -250,6 +251,7 @@ export { ImageCropSquareMask, Info, Invoice, + InvoiceGeneric, Key, Keyboard, Link, diff --git a/src/components/LHNOptionsList/OptionRowLHN.tsx b/src/components/LHNOptionsList/OptionRowLHN.tsx index 9946dea9d5a7..850173433cf0 100644 --- a/src/components/LHNOptionsList/OptionRowLHN.tsx +++ b/src/components/LHNOptionsList/OptionRowLHN.tsx @@ -202,6 +202,7 @@ function OptionRowLHN({reportID, isFocused = false, onSelectRow = () => {}, opti !!optionItem.isTaskReport || !!optionItem.isThread || !!optionItem.isMoneyRequestReport || + !!optionItem.isInvoiceReport || ReportUtils.isGroupChat(report) } /> diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 1afadd8ea856..2bfb78cf9340 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -87,6 +87,9 @@ type MenuItemBaseProps = { /** Any additional styles to apply on the badge element */ badgeStyle?: ViewStyle; + /** Any additional styles to apply to the label */ + labelStyle?: StyleProp; + /** Any adjustments to style when menu item is hovered or pressed */ hoverAndPressStyle?: StyleProp>; @@ -267,6 +270,7 @@ function MenuItem( outerWrapperStyle, containerStyle, titleStyle, + labelStyle, hoverAndPressStyle, descriptionTextStyle, badgeStyle, @@ -424,7 +428,7 @@ function MenuItem( return ( {!!label && !isLabelHoverable && ( - + {label} )} @@ -460,7 +464,7 @@ function MenuItem( <> {!!label && isLabelHoverable && ( - + {label} diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 522158b8edb7..7e0139b147fd 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -109,7 +109,7 @@ function MoneyReportHeader({ const shouldDisableApproveButton = shouldShowApproveButton && !ReportUtils.isAllowedToApproveExpenseReport(moneyRequestReport); - const shouldShowSettlementButton = shouldShowPayButton || shouldShowApproveButton; + const shouldShowSettlementButton = !ReportUtils.isInvoiceReport(moneyRequestReport) && (shouldShowPayButton || shouldShowApproveButton); const shouldShowSubmitButton = isDraft && reimbursableSpend !== 0; const shouldDisableSubmitButton = shouldShowSubmitButton && !ReportUtils.isAllowedToSubmitDraftExpenseReport(moneyRequestReport); diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx index b76ca99751af..59d7e9bb7fcd 100755 --- a/src/components/MoneyRequestConfirmationList.tsx +++ b/src/components/MoneyRequestConfirmationList.tsx @@ -5,7 +5,7 @@ import React, {useCallback, useEffect, useMemo, useReducer, useState} from 'reac import {View} from 'react-native'; import type {StyleProp, ViewStyle} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -25,6 +25,7 @@ import * as PolicyUtils from '@libs/PolicyUtils'; import {isTaxTrackingEnabled} from '@libs/PolicyUtils'; import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import * as TransactionUtils from '@libs/TransactionUtils'; import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; @@ -42,6 +43,7 @@ import type {DropdownOption} from './ButtonWithDropdownMenu/types'; import ConfirmedRoute from './ConfirmedRoute'; import ConfirmModal from './ConfirmModal'; import FormHelpMessage from './FormHelpMessage'; +import MenuItem from './MenuItem'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; import OptionsSelector from './OptionsSelector'; import PDFThumbnail from './PDFThumbnail'; @@ -73,6 +75,9 @@ type MoneyRequestConfirmationListOnyxProps = { /** Last selected distance rates */ lastSelectedDistanceRates: OnyxEntry>; + + /** The list of all policies */ + allPolicies: OnyxCollection; }; type MoneyRequestConfirmationListProps = MoneyRequestConfirmationListOnyxProps & { @@ -217,6 +222,7 @@ function MoneyRequestConfirmationList({ reportActionID, defaultMileageRate, lastSelectedDistanceRates, + allPolicies, action = CONST.IOU.ACTION.CREATE, }: MoneyRequestConfirmationListProps) { const theme = useTheme(); @@ -230,6 +236,7 @@ function MoneyRequestConfirmationList({ const isTypeSplit = iouType === CONST.IOU.TYPE.SPLIT; const isTypeSend = iouType === CONST.IOU.TYPE.PAY; const isTypeTrackExpense = iouType === CONST.IOU.TYPE.TRACK; + const isTypeInvoice = iouType === CONST.IOU.TYPE.INVOICE; const transactionID = transaction?.transactionID ?? ''; const customUnitRateID = TransactionUtils.getRateID(transaction) ?? ''; @@ -275,6 +282,13 @@ function MoneyRequestConfirmationList({ const policyTagLists = useMemo(() => PolicyUtils.getTagLists(policyTags), [policyTags]); + const senderWorkspace = useMemo(() => { + const senderWorkspaceParticipant = selectedParticipantsProp.find((participant) => participant.isSender); + return allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${senderWorkspaceParticipant?.policyID}`]; + }, [allPolicies, selectedParticipantsProp]); + + const canUpdateSenderWorkspace = useMemo(() => PolicyUtils.canSendInvoice(allPolicies) && !!transaction?.isFromGlobalCreate, [allPolicies, transaction?.isFromGlobalCreate]); + // A flag for showing the tags field const shouldShowTags = useMemo(() => isPolicyExpenseChat && OptionsListUtils.hasEnabledTags(policyTagLists), [isPolicyExpenseChat, policyTagLists]); @@ -376,7 +390,9 @@ function MoneyRequestConfirmationList({ const splitOrRequestOptions: Array> = useMemo(() => { let text; - if (isTypeTrackExpense) { + if (isTypeInvoice) { + text = translate('iou.sendInvoice', {amount: formattedAmount}); + } else if (isTypeTrackExpense) { text = translate('iou.trackExpense'); } else if (isTypeSplit && iouAmount === 0) { text = translate('iou.splitExpense'); @@ -395,7 +411,7 @@ function MoneyRequestConfirmationList({ value: iouType, }, ]; - }, [isTypeTrackExpense, isTypeSplit, iouAmount, receiptPath, isTypeRequest, isDistanceRequestWithPendingRoute, iouType, translate, formattedAmount]); + }, [isTypeTrackExpense, isTypeSplit, iouAmount, receiptPath, isTypeRequest, isDistanceRequestWithPendingRoute, iouType, translate, formattedAmount, isTypeInvoice]); const selectedParticipants = useMemo(() => selectedParticipantsProp.filter((participant) => participant.selected), [selectedParticipantsProp]); const payeePersonalDetails = useMemo(() => payeePersonalDetailsProp ?? currentUserPersonalDetails, [payeePersonalDetailsProp, currentUserPersonalDetails]); @@ -984,6 +1000,26 @@ function MoneyRequestConfirmationList({ )} + {isTypeInvoice && ( + { + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SEND_FROM.getRoute(iouType, transaction?.transactionID ?? '', reportID, Navigation.getActiveRouteWithoutParams())); + }} + style={styles.moneyRequestMenuItem} + labelStyle={styles.mt2} + titleStyle={styles.flex1} + disabled={didConfirm || !canUpdateSenderWorkspace} + /> + )} {!isDistanceRequest && // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing (receiptImage || receiptThumbnail @@ -1047,4 +1083,7 @@ export default withOnyx translate(`reportActionsView.iouTypes.${item}`)).join(', '); + const additionalText = moneyRequestOptions + .filter((item): item is Exclude => item !== CONST.IOU.TYPE.INVOICE) + .map((item) => translate(`reportActionsView.iouTypes.${item}`)) + .join(', '); const canEditPolicyDescription = ReportUtils.canEditPolicyDescription(policy); const reportName = ReportUtils.getReportName(report); diff --git a/src/languages/en.ts b/src/languages/en.ts index 8b7737f00812..826e54c7e28f 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -657,6 +657,7 @@ export default { payElsewhere: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} elsewhere` : `Pay elsewhere`), nextStep: 'Next Steps', finished: 'Finished', + sendInvoice: ({amount}: RequestAmountParams) => `Send ${amount} invoice`, submitAmount: ({amount}: RequestAmountParams) => `submit ${amount}`, trackAmount: ({amount}: RequestAmountParams) => `track ${amount}`, submittedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `submitted ${formattedAmount}${comment ? ` for ${comment}` : ''}`, @@ -703,6 +704,7 @@ export default { invalidSplit: 'Split amounts do not equal total amount', other: 'Unexpected error, please try again later', genericCreateFailureMessage: 'Unexpected error submitting this expense. Please try again later.', + genericCreateInvoiceFailureMessage: 'Unexpected error sending invoice, please try again later', receiptFailureMessage: "The receipt didn't upload. ", saveFileMessage: 'Download the file ', loseFileMessage: 'or dismiss this error and lose it', @@ -2226,6 +2228,7 @@ export default { unlockVBACopy: "You're all set to accept payments by ACH or credit card!", viewUnpaidInvoices: 'View unpaid invoices', sendInvoice: 'Send invoice', + sendFrom: 'Send from', }, travel: { unlockConciergeBookingTravel: 'Unlock Concierge travel booking', diff --git a/src/languages/es.ts b/src/languages/es.ts index 82b5505b18f4..f7250a1941cd 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -650,6 +650,7 @@ export default { payElsewhere: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} de otra forma` : `Pagar de otra forma`), nextStep: 'Pasos Siguientes', finished: 'Finalizado', + sendInvoice: ({amount}: RequestAmountParams) => `Enviar factura de ${amount}`, submitAmount: ({amount}: RequestAmountParams) => `solicitar ${amount}`, trackAmount: ({amount}: RequestAmountParams) => `seguimiento de ${amount}`, submittedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `solicitó ${formattedAmount}${comment ? ` para ${comment}` : ''}`, @@ -698,6 +699,7 @@ export default { invalidSplit: 'La suma de las partes no equivale al importe total', other: 'Error inesperado, por favor inténtalo más tarde', genericCreateFailureMessage: 'Error inesperado al enviar este gasto. Por favor, inténtalo más tarde.', + genericCreateInvoiceFailureMessage: 'Error inesperado al enviar la factura, inténtalo de nuevo más tarde', receiptFailureMessage: 'El recibo no se subió. ', saveFileMessage: 'Guarda el archivo ', loseFileMessage: 'o descarta este error y piérdelo', @@ -2254,6 +2256,7 @@ export default { unlockVBACopy: '¡Todo listo para recibir pagos por transferencia o con tarjeta!', viewUnpaidInvoices: 'Ver facturas emitidas pendientes', sendInvoice: 'Enviar factura', + sendFrom: 'Enviar desde', }, travel: { unlockConciergeBookingTravel: 'Desbloquea la reserva de viajes con Concierge', diff --git a/src/libs/API/parameters/SendInvoiceParams.ts b/src/libs/API/parameters/SendInvoiceParams.ts new file mode 100644 index 000000000000..5ede0dcaa920 --- /dev/null +++ b/src/libs/API/parameters/SendInvoiceParams.ts @@ -0,0 +1,19 @@ +type SendInvoiceParams = { + senderWorkspaceID: string; + accountID: number; + receiverEmail?: string; + receiverInvoiceRoomID?: string; + amount: number; + currency: string; + merchant: string; + date: string; + category?: string; + invoiceRoomReportID?: string; + createdChatReportActionID: string; + invoiceReportID: string; + reportPreviewReportActionID: string; + transactionID: string; + transactionThreadReportID: string; +}; + +export default SendInvoiceParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 03e6aca3b5ff..73a7a8a63f5f 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -217,3 +217,4 @@ export type {default as CategorizeTrackedExpenseParams} from './CategorizeTracke export type {default as LeavePolicyParams} from './LeavePolicyParams'; export type {default as OpenPolicyAccountingPageParams} from './OpenPolicyAccountingPageParams'; export type {default as SearchParams} from './Search'; +export type {default as SendInvoiceParams} from './SendInvoiceParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 841e97671e2f..a1e5146498b5 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -212,6 +212,7 @@ const WRITE_COMMANDS = { CATEGORIZE_TRACKED_EXPENSE: 'CategorizeTrackedExpense', SHARE_TRACKED_EXPENSE: 'ShareTrackedExpense', LEAVE_POLICY: 'LeavePolicy', + SEND_INVOICE: 'SendInvoice', } as const; type WriteCommand = ValueOf; @@ -424,6 +425,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.CATEGORIZE_TRACKED_EXPENSE]: Parameters.CategorizeTrackedExpenseParams; [WRITE_COMMANDS.SHARE_TRACKED_EXPENSE]: Parameters.ShareTrackedExpenseParams; [WRITE_COMMANDS.LEAVE_POLICY]: Parameters.LeavePolicyParams; + [WRITE_COMMANDS.SEND_INVOICE]: Parameters.SendInvoiceParams; }; const READ_COMMANDS = { diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index 48ebee0e68a1..9ff4f5fb8d11 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -106,10 +106,18 @@ function isIOUReportPendingCurrencyConversion(iouReport: Report): boolean { } /** - * Checks if the iou type is one of request, send, or split. + * Checks if the iou type is one of request, send, invoice or split. */ function isValidMoneyRequestType(iouType: string): boolean { - const moneyRequestType: string[] = [CONST.IOU.TYPE.REQUEST, CONST.IOU.TYPE.SUBMIT, CONST.IOU.TYPE.SPLIT, CONST.IOU.TYPE.SEND, CONST.IOU.TYPE.PAY, CONST.IOU.TYPE.TRACK]; + const moneyRequestType: string[] = [ + CONST.IOU.TYPE.REQUEST, + CONST.IOU.TYPE.SUBMIT, + CONST.IOU.TYPE.SPLIT, + CONST.IOU.TYPE.SEND, + CONST.IOU.TYPE.PAY, + CONST.IOU.TYPE.TRACK, + CONST.IOU.TYPE.INVOICE, + ]; return moneyRequestType.includes(iouType); } @@ -118,7 +126,7 @@ function isValidMoneyRequestType(iouType: string): boolean { */ // eslint-disable-next-line @typescript-eslint/naming-convention function temporary_isValidMoneyRequestType(iouType: string): boolean { - const moneyRequestType: string[] = [CONST.IOU.TYPE.SUBMIT, CONST.IOU.TYPE.SPLIT, CONST.IOU.TYPE.PAY, CONST.IOU.TYPE.TRACK]; + const moneyRequestType: string[] = [CONST.IOU.TYPE.SUBMIT, CONST.IOU.TYPE.SPLIT, CONST.IOU.TYPE.PAY, CONST.IOU.TYPE.TRACK, CONST.IOU.TYPE.INVOICE]; return moneyRequestType.includes(iouType); } diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index c78b01a2afa2..2c0b3275da0e 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -87,6 +87,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../../pages/iou/request/step/IOURequestStepScan').default as React.ComponentType, [SCREENS.MONEY_REQUEST.STEP_TAG]: () => require('../../../../pages/iou/request/step/IOURequestStepTag').default as React.ComponentType, [SCREENS.MONEY_REQUEST.STEP_WAYPOINT]: () => require('../../../../pages/iou/request/step/IOURequestStepWaypoint').default as React.ComponentType, + [SCREENS.MONEY_REQUEST.STEP_SEND_FROM]: () => require('../../../../pages/iou/request/step/IOURequestStepSendFrom').default as React.ComponentType, [SCREENS.MONEY_REQUEST.HOLD]: () => require('../../../../pages/iou/HoldReasonPage').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 abd8874bc0d7..9508cbc514ae 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -601,6 +601,7 @@ const config: LinkingOptions['config'] = { }, }, }, + [SCREENS.MONEY_REQUEST.STEP_SEND_FROM]: ROUTES.MONEY_REQUEST_STEP_SEND_FROM.route, [SCREENS.MONEY_REQUEST.STEP_AMOUNT]: ROUTES.MONEY_REQUEST_STEP_AMOUNT.route, [SCREENS.MONEY_REQUEST.STEP_CATEGORY]: ROUTES.MONEY_REQUEST_STEP_CATEGORY.route, [SCREENS.MONEY_REQUEST.STEP_CONFIRMATION]: ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index a758769a5e07..f3d693743be1 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -402,6 +402,12 @@ type RoomInviteNavigatorParamList = { }; type MoneyRequestNavigatorParamList = { + [SCREENS.MONEY_REQUEST.STEP_SEND_FROM]: { + iouType: IOUType; + transactionID: string; + reportID: string; + backTo: Routes; + }; [SCREENS.MONEY_REQUEST.STEP_PARTICIPANTS]: { action: IOUAction; iouType: Exclude; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 731dc5700c8e..0919a93f91bb 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -13,6 +13,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject'; import getPolicyIDFromState from './Navigation/getPolicyIDFromState'; import Navigation, {navigationRef} from './Navigation/Navigation'; import type {RootStackParamList, State} from './Navigation/types'; +import * as NetworkStore from './Network/NetworkStore'; import {getAccountIDsByLogins, getLoginsByAccountIDs, getPersonalDetailByEmail} from './PersonalDetailsUtils'; type MemberEmailsToAccountIDs = Record; @@ -29,7 +30,7 @@ Onyx.connect({ * Filter out the active policies, which will exclude policies with pending deletion * These are policies that we can use to create reports with in NewDot. */ -function getActivePolicies(policies: OnyxCollection): Policy[] | undefined { +function getActivePolicies(policies: OnyxCollection): Policy[] { return Object.values(policies ?? {}).filter( (policy): policy is Policy => policy !== null && policy && policy.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && !!policy.name && !!policy.id, ); @@ -378,6 +379,17 @@ function getPolicy(policyID: string | undefined): Policy | EmptyObject { return allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] ?? {}; } +/** Return active policies where current user is an admin */ +function getActiveAdminWorkspaces(policies: OnyxCollection): Policy[] { + const activePolicies = getActivePolicies(policies); + return activePolicies.filter((policy) => shouldShowPolicy(policy, NetworkStore.isOffline()) && isPolicyAdmin(policy)); +} + +/** Whether the user can send invoice */ +function canSendInvoice(policies: OnyxCollection): boolean { + return getActiveAdminWorkspaces(policies).length > 0; +} + export { getActivePolicies, hasAccountingConnections, @@ -421,6 +433,8 @@ export { getSubmitToAccountID, getAdminEmployees, getPolicy, + getActiveAdminWorkspaces, + canSendInvoice, }; export type {MemberEmailsToAccountIDs}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index a0da34416aad..6e8bc51c8848 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -44,6 +44,7 @@ import type { OriginalMessageCreated, OriginalMessageReimbursementDequeued, OriginalMessageRenamed, + OriginalMessageRoomChangeLog, PaymentMethodType, ReimbursementDeQueuedMessage, } from '@src/types/onyx/OriginalMessage'; @@ -116,6 +117,8 @@ type SpendBreakdown = { type ParticipantDetails = [number, string, UserUtils.AvatarSource, UserUtils.AvatarSource]; +type OptimisticInviteReportAction = ReportActionBase & OriginalMessageRoomChangeLog; + type OptimisticAddCommentReportAction = Pick< ReportAction, | 'reportActionID' @@ -144,6 +147,7 @@ type OptimisticAddCommentReportAction = Pick< | 'childCommenterCount' | 'childLastVisibleActionCreated' | 'childOldestFourAccountIDs' + | 'whisperedToAccountIDs' > & {isOptimisticAction: boolean}; type OptimisticReportAction = { @@ -422,6 +426,7 @@ type OptionData = { shouldShowSubscript?: boolean | null; isPolicyExpenseChat?: boolean | null; isMoneyRequestReport?: boolean | null; + isInvoiceReport?: boolean; isExpenseRequest?: boolean | null; isAllowedToComment?: boolean | null; isThread?: boolean | null; @@ -917,7 +922,7 @@ function isPaidGroupPolicyExpenseReport(report: OnyxEntry): boolean { * Checks if the supplied report is an invoice report in Open state and status. */ function isOpenInvoiceReport(report: OnyxEntry | EmptyObject): boolean { - return isInvoiceReport(report) && report?.stateNum === CONST.REPORT.STATE_NUM.OPEN && report?.statusNum === CONST.REPORT.STATUS_NUM.OPEN; + return isInvoiceReport(report) && report?.statusNum === CONST.REPORT.STATUS_NUM.OPEN; } /** @@ -2530,6 +2535,10 @@ function canEditMoneyRequest(reportAction: OnyxEntry): boolean { const isAdmin = policy.role === CONST.POLICY.ROLE.ADMIN; const isManager = currentUserAccountID === moneyRequestReport?.managerID; + if (isInvoiceReport(moneyRequestReport) && isManager) { + return false; + } + // Admin & managers can always edit coding fields such as tag, category, billable, etc. As long as the report has a state higher than OPEN. if ((isAdmin || isManager) && !isOpenExpenseReport(moneyRequestReport)) { return true; @@ -2586,7 +2595,7 @@ function canEditFieldOfMoneyRequest(reportAction: OnyxEntry, field if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.RECEIPT) { const isRequestor = currentUserAccountID === reportAction?.actorAccountID; - return !TransactionUtils.isReceiptBeingScanned(transaction) && !TransactionUtils.isDistanceRequest(transaction) && isRequestor; + return !isInvoiceReport(moneyRequestReport) && !TransactionUtils.isReceiptBeingScanned(transaction) && !TransactionUtils.isDistanceRequest(transaction) && isRequestor; } return true; @@ -3310,6 +3319,44 @@ function getPolicyDescriptionText(policy: OnyxEntry): string { return parser.htmlToText(policy.description); } +/** Builds an optimistic reportAction for the invite message */ +function buildOptimisticInviteReportAction(invitedUserDisplayName: string, invitedUserID: number): OptimisticInviteReportAction { + const text = `${Localize.translateLocal('workspace.invite.invited')} ${invitedUserDisplayName}`; + const commentText = getParsedComment(text); + const parser = new ExpensiMark(); + const currentUser = allPersonalDetails?.[currentUserAccountID ?? -1]; + + return { + reportActionID: NumberUtils.rand64(), + actionName: CONST.REPORT.ACTIONS.TYPE.ROOM_CHANGE_LOG.INVITE_TO_ROOM, + actorAccountID: currentUserAccountID, + person: [ + { + style: 'strong', + text: currentUser?.displayName ?? currentUserEmail, + type: 'TEXT', + }, + ], + automatic: false, + avatar: currentUser?.avatar ?? UserUtils.getDefaultAvatarURL(currentUserAccountID), + created: DateUtils.getDBTime(), + message: [ + { + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + html: commentText, + text: parser.htmlToText(commentText), + }, + ], + originalMessage: { + targetAccountIDs: [invitedUserID], + }, + isFirstItem: false, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + shouldShow: true, + isOptimisticAction: true, + }; +} + function buildOptimisticAddCommentReportAction(text?: string, file?: FileObject, actorAccountID?: number, createdOffset = 0, shouldEscapeText?: boolean): OptimisticReportAction { const parser = new ExpensiMark(); const commentText = getParsedComment(text ?? '', shouldEscapeText); @@ -3548,6 +3595,29 @@ function populateOptimisticReportFormula(formula: string, report: OptimisticExpe return result.trim().length ? result : formula; } +/** Builds an optimistic invoice report with a randomly generated reportID */ +function buildOptimisticInvoiceReport(chatReportID: string, policyID: string, receiverAccountID: number, receiverName: string, total: number, currency: string): OptimisticExpenseReport { + const formattedTotal = CurrencyUtils.convertToDisplayString(total, currency); + + return { + reportID: generateReportID(), + chatReportID, + policyID, + type: CONST.REPORT.TYPE.INVOICE, + ownerAccountID: currentUserAccountID, + managerID: receiverAccountID, + currency, + // We don’t translate reportName because the server response is always in English + reportName: `${receiverName} owes ${formattedTotal}`, + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.OPEN, + total, + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, + parentReportID: chatReportID, + lastVisibleActionCreated: DateUtils.getDBTime(), + }; +} + /** * Builds an optimistic Expense report with a randomly generated reportID * @@ -4186,7 +4256,7 @@ function buildOptimisticChatReport( }, {} as Participants); const currentTime = DateUtils.getDBTime(); const isNewlyCreatedWorkspaceChat = chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT && isOwnPolicyExpenseChat; - return { + const optimisticChatReport: OptimisticChatReport = { isOptimisticReport: true, type: CONST.REPORT.TYPE.CHAT, chatType, @@ -4218,6 +4288,16 @@ function buildOptimisticChatReport( writeCapability, avatarUrl, }; + + if (chatType === CONST.REPORT.CHAT_TYPE.INVOICE) { + // TODO: update to support workspace as an invoice receiver when workspace-to-workspace invoice room implemented + optimisticChatReport.invoiceReceiver = { + type: 'individual', + accountID: participantList[0], + }; + } + + return optimisticChatReport; } function buildOptimisticGroupChatReport(participantAccountIDs: number[], reportName: string, avatarUri: string, optimisticReportID?: string) { @@ -5010,6 +5090,26 @@ function getChatByParticipants(newParticipantList: number[], reports: OnyxCollec ); } +/** + * Attempts to find an invoice chat report in onyx with the provided policyID and receiverID. + */ +function getInvoiceChatByParticipants(policyID: string, receiverID: string | number, reports: OnyxCollection = allReports): OnyxEntry { + return ( + Object.values(reports ?? {}).find((report) => { + if (!report || !isInvoiceRoom(report)) { + return false; + } + + const isSameReceiver = + report.invoiceReceiver && + (('accountID' in report.invoiceReceiver && report.invoiceReceiver.accountID === receiverID) || + ('policyID' in report.invoiceReceiver && report.invoiceReceiver.policyID === receiverID)); + + return report.policyID === policyID && isSameReceiver; + }) ?? null + ); +} + /** * Attempts to find a report in onyx with the provided list of participants in given policy */ @@ -5807,6 +5907,7 @@ function isDeprecatedGroupDM(report: OnyxEntry): boolean { report && !isChatThread(report) && !isTaskReport(report) && + !isInvoiceReport(report) && !isMoneyRequestReport(report) && !isArchivedRoom(report) && !Object.values(CONST.REPORT.CHAT_TYPE).some((chatType) => chatType === getChatType(report)) && @@ -6570,6 +6671,9 @@ export { updateOptimisticParentReportAction, updateReportPreview, temporary_getMoneyRequestOptions, + buildOptimisticInvoiceReport, + buildOptimisticInviteReportAction, + getInvoiceChatByParticipants, }; export type { @@ -6584,4 +6688,5 @@ export type { OptimisticTaskReportAction, OptionData, TransactionDetails, + OptimisticInviteReportAction, }; diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 4f1a35ee1d87..1fa7844fe6b1 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -250,6 +250,7 @@ function getOptionData({ result.isThread = ReportUtils.isChatThread(report); result.isChatRoom = ReportUtils.isChatRoom(report); result.isTaskReport = ReportUtils.isTaskReport(report); + result.isInvoiceReport = ReportUtils.isInvoiceReport(report); result.parentReportAction = parentReportAction; result.isArchivedRoom = ReportUtils.isArchivedRoom(report); result.isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 07ff9420b60e..820481844589 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -16,6 +16,7 @@ import type { PayMoneyRequestParams, ReplaceReceiptParams, RequestMoneyParams, + SendInvoiceParams, SendMoneyParams, SplitBillParams, StartSplitBillParams, @@ -39,7 +40,7 @@ import Permissions from '@libs/Permissions'; import * as PhoneNumber from '@libs/PhoneNumber'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; -import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction, TransactionDetails} from '@libs/ReportUtils'; +import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticInviteReportAction, OptimisticIOUReportAction, TransactionDetails} from '@libs/ReportUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import * as UserUtils from '@libs/UserUtils'; @@ -93,6 +94,18 @@ type TrackExpenseInformation = { onyxData: OnyxData; }; +type SendInvoiceInformation = { + senderWorkspaceID: string; + receiver: Partial; + invoiceRoomReportID: string; + createdChatReportActionID: string; + invoiceReportID: string; + reportPreviewReportActionID: string; + transactionID: string; + transactionThreadReportID: string; + onyxData: OnyxData; +}; + type SplitData = { chatReportID: string; transactionID: string; @@ -780,6 +793,301 @@ function buildOnyxDataForMoneyRequest( return [optimisticData, successData, failureData]; } +/** Builds the Onyx data for an invoice */ +function buildOnyxDataForInvoice( + chatReport: OnyxEntry, + iouReport: OnyxTypes.Report, + transaction: OnyxTypes.Transaction, + chatCreatedAction: OptimisticCreatedReportAction, + iouCreatedAction: OptimisticCreatedReportAction, + iouAction: OptimisticIOUReportAction, + optimisticPersonalDetailListAction: OnyxTypes.PersonalDetailsList, + reportPreviewAction: ReportAction, + optimisticPolicyRecentlyUsedCategories: string[], + optimisticPolicyRecentlyUsedTags: OnyxTypes.RecentlyUsedTags, + isNewChatReport: boolean, + transactionThreadReport: OptimisticChatReport, + transactionThreadCreatedReportAction: OptimisticCreatedReportAction | EmptyObject, + inviteReportAction?: OptimisticInviteReportAction, + policy?: OnyxEntry, + policyTagList?: OnyxEntry, + policyCategories?: OnyxEntry, +): [OnyxUpdate[], OnyxUpdate[], OnyxUpdate[]] { + const clearedPendingFields = Object.fromEntries(Object.keys(transaction.pendingFields ?? {}).map((key) => [key, null])); + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + ...iouReport, + lastMessageText: iouAction.message?.[0]?.text, + lastMessageHtml: iouAction.message?.[0]?.html, + pendingFields: { + createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: transaction, + }, + isNewChatReport && inviteReportAction + ? { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [inviteReportAction.reportActionID]: inviteReportAction as ReportAction, + [chatCreatedAction.reportActionID]: chatCreatedAction, + [reportPreviewAction.reportActionID]: reportPreviewAction, + }, + } + : { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [reportPreviewAction.reportActionID]: reportPreviewAction, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: { + [iouCreatedAction.reportActionID]: iouCreatedAction as OnyxTypes.ReportAction, + [iouAction.reportActionID]: iouAction as OnyxTypes.ReportAction, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`, + value: transactionThreadReport, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport.reportID}`, + value: { + [transactionThreadCreatedReportAction.reportActionID]: transactionThreadCreatedReportAction, + }, + }, + // Remove the temporary transaction used during the creation flow + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`, + value: null, + }, + ]; + + if (chatReport) { + optimisticData.push({ + // Use SET for new reports because it doesn't exist yet, is faster and we need the data to be available when we navigate to the chat page + onyxMethod: isNewChatReport ? Onyx.METHOD.SET : Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + value: { + ...chatReport, + lastReadTime: DateUtils.getDBTime(), + lastMessageTranslationKey: '', + iouReportID: iouReport.reportID, + ...(isNewChatReport ? {pendingFields: {createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}} : {}), + }, + }); + } + + if (optimisticPolicyRecentlyUsedCategories.length) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES}${iouReport.policyID}`, + value: optimisticPolicyRecentlyUsedCategories, + }); + } + + if (!isEmptyObject(optimisticPolicyRecentlyUsedTags)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS}${iouReport.policyID}`, + value: optimisticPolicyRecentlyUsedTags, + }); + } + + if (!isEmptyObject(optimisticPersonalDetailListAction)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + value: optimisticPersonalDetailListAction, + }); + } + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + pendingFields: null, + errorFields: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`, + value: { + pendingFields: null, + errorFields: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + pendingAction: null, + pendingFields: clearedPendingFields, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + ...(isNewChatReport + ? { + [chatCreatedAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + } + : {}), + [reportPreviewAction.reportActionID]: { + pendingAction: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: { + [iouCreatedAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + [iouAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport.reportID}`, + value: { + [transactionThreadCreatedReportAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + }, + }, + ]; + + if (isNewChatReport) { + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: { + pendingFields: null, + errorFields: null, + isOptimisticReport: false, + }, + }); + } + + const errorKey = DateUtils.getMicroseconds(); + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: { + iouReportID: chatReport?.iouReportID, + lastReadTime: chatReport?.lastReadTime, + pendingFields: null, + hasOutstandingChildRequest: chatReport?.hasOutstandingChildRequest, + ...(isNewChatReport + ? { + errorFields: { + createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), + }, + } + : {}), + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + pendingFields: null, + errorFields: { + createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReport.reportID}`, + value: { + errorFields: { + createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: { + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateInvoiceFailureMessage'), + pendingAction: null, + pendingFields: clearedPendingFields, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`, + value: { + [iouCreatedAction.reportActionID]: { + // Disabling this line since transaction.filename can be an empty string + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + errors: getReceiptError(transaction.receipt, transaction.filename || transaction.receipt?.filename, false, errorKey), + }, + [iouAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateInvoiceFailureMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport.reportID}`, + value: { + [transactionThreadCreatedReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateInvoiceFailureMessage', false, errorKey), + }, + }, + }, + ]; + + // We don't need to compute violations unless we're on a paid policy + if (!policy || !PolicyUtils.isPaidGroupPolicy(policy)) { + return [optimisticData, successData, failureData]; + } + + const violationsOnyxData = ViolationsUtils.getViolationsOnyxData(transaction, [], !!policy.requiresTag, policyTagList ?? {}, !!policy.requiresCategory, policyCategories ?? {}); + + if (violationsOnyxData) { + optimisticData.push(violationsOnyxData); + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transaction.transactionID}`, + value: [], + }); + } + + return [optimisticData, successData, failureData]; +} + /** Builds the Onyx data for track expense */ function buildOnyxDataForTrackExpense( chatReport: OnyxEntry, @@ -1281,6 +1589,143 @@ function getDeleteTrackExpenseInformation( return {parameters, optimisticData, successData, failureData, shouldDeleteTransactionThread, chatReport}; } +/** Gathers all the data needed to create an invoice. */ +function getSendInvoiceInformation( + transaction: OnyxEntry, + currentUserAccountID: number, + invoiceChatReport?: OnyxEntry, + receipt?: Receipt, + policy?: OnyxEntry, + policyTagList?: OnyxEntry, + policyCategories?: OnyxEntry, +): SendInvoiceInformation { + const {amount = 0, currency = '', created = '', merchant = '', category = '', tag = '', billable, comment, participants} = transaction ?? {}; + const trimmedComment = (comment?.comment ?? '').trim(); + const senderWorkspaceID = participants?.find((participant) => participant?.isSender)?.policyID ?? ''; + const receiverParticipant = participants?.find((participant) => participant?.accountID); + const receiverAccountID = receiverParticipant?.accountID ?? -1; + let receiver = ReportUtils.getPersonalDetailsForAccountID(receiverAccountID); + let optimisticPersonalDetailListAction = {}; + + // STEP 1: Get existing chat report OR build a new optimistic one + let isNewChatReport = false; + let chatReport = !isEmptyObject(invoiceChatReport) && invoiceChatReport?.reportID ? invoiceChatReport : null; + + if (!chatReport) { + chatReport = ReportUtils.getInvoiceChatByParticipants(senderWorkspaceID, receiverAccountID); + } + + if (!chatReport) { + isNewChatReport = true; + chatReport = ReportUtils.buildOptimisticChatReport([receiverAccountID, currentUserAccountID], CONST.REPORT.DEFAULT_REPORT_NAME, CONST.REPORT.CHAT_TYPE.INVOICE, senderWorkspaceID); + } + + // STEP 2: Create a new optimistic invoice report. + const optimisticInvoiceReport = ReportUtils.buildOptimisticInvoiceReport(chatReport.reportID, senderWorkspaceID, receiverAccountID, receiver.displayName ?? '', amount, currency); + + // STEP 3: Build optimistic receipt and transaction + const receiptObject: Receipt = {}; + let filename; + if (receipt?.source) { + receiptObject.source = receipt.source; + receiptObject.state = receipt.state ?? CONST.IOU.RECEIPT_STATE.SCANREADY; + filename = receipt.name; + } + const optimisticTransaction = TransactionUtils.buildOptimisticTransaction( + amount, + currency, + optimisticInvoiceReport.reportID, + trimmedComment, + created, + '', + '', + merchant, + receiptObject, + filename, + undefined, + category, + tag, + billable, + ); + + const optimisticPolicyRecentlyUsedCategories = Policy.buildOptimisticPolicyRecentlyUsedCategories(optimisticInvoiceReport.policyID, category); + const optimisticPolicyRecentlyUsedTags = Policy.buildOptimisticPolicyRecentlyUsedTags(optimisticInvoiceReport.policyID, tag); + + // STEP 4: Add optimistic personal details for participant + const shouldCreateOptimisticPersonalDetails = isNewChatReport && !allPersonalDetails[receiverAccountID]; + if (shouldCreateOptimisticPersonalDetails) { + receiver = { + accountID: receiverAccountID, + avatar: UserUtils.getDefaultAvatarURL(receiverAccountID), + displayName: LocalePhoneNumber.formatPhoneNumber(receiverParticipant?.login ?? ''), + login: receiverParticipant?.login, + isOptimisticPersonalDetail: true, + }; + + optimisticPersonalDetailListAction = {[receiverAccountID]: receiver}; + } + + // STEP 5: Build optimistic reportActions. + let inviteReportAction: OptimisticInviteReportAction | undefined; + const [optimisticCreatedActionForChat, optimisticCreatedActionForIOUReport, iouAction, optimisticTransactionThread, optimisticCreatedActionForTransactionThread] = + ReportUtils.buildOptimisticMoneyRequestEntities( + optimisticInvoiceReport, + CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount, + currency, + trimmedComment, + receiver.login ?? '', + [receiver], + optimisticTransaction.transactionID, + undefined, + false, + false, + receiptObject, + false, + ); + if (isNewChatReport) { + inviteReportAction = ReportUtils.buildOptimisticInviteReportAction(receiver?.displayName ?? '', receiver.accountID ?? -1); + } + const reportPreviewAction = ReportUtils.buildOptimisticReportPreview(chatReport, optimisticInvoiceReport, trimmedComment, optimisticTransaction); + + // STEP 6: Build Onyx Data + const [optimisticData, successData, failureData] = buildOnyxDataForInvoice( + chatReport, + optimisticInvoiceReport, + optimisticTransaction, + optimisticCreatedActionForChat, + optimisticCreatedActionForIOUReport, + iouAction, + optimisticPersonalDetailListAction, + reportPreviewAction, + optimisticPolicyRecentlyUsedCategories, + optimisticPolicyRecentlyUsedTags, + isNewChatReport, + optimisticTransactionThread, + optimisticCreatedActionForTransactionThread, + inviteReportAction, + policy, + policyTagList, + policyCategories, + ); + + return { + senderWorkspaceID, + receiver, + invoiceRoomReportID: chatReport.reportID, + createdChatReportActionID: optimisticCreatedActionForChat.reportActionID, + invoiceReportID: optimisticInvoiceReport.reportID, + reportPreviewReportActionID: reportPreviewAction.reportActionID, + transactionID: optimisticTransaction.transactionID, + transactionThreadReportID: optimisticTransactionThread.reportID, + onyxData: { + optimisticData, + successData, + failureData, + }, + }; +} + /** * Gathers all the data needed to submit an expense. It attempts to find existing reports, iouReports, and receipts. If it doesn't find them, then * it creates optimistic versions of them and uses those instead @@ -2881,6 +3326,52 @@ function requestMoney( } } +function sendInvoice( + currentUserAccountID: number, + transaction: OnyxEntry, + invoiceChatReport?: OnyxEntry, + receiptFile?: Receipt, + policy?: OnyxEntry, + policyTagList?: OnyxEntry, + policyCategories?: OnyxEntry, +) { + const {senderWorkspaceID, receiver, invoiceRoomReportID, createdChatReportActionID, invoiceReportID, reportPreviewReportActionID, transactionID, transactionThreadReportID, onyxData} = + getSendInvoiceInformation(transaction, currentUserAccountID, invoiceChatReport, receiptFile, policy, policyTagList, policyCategories); + + let parameters: SendInvoiceParams = { + senderWorkspaceID, + accountID: currentUserAccountID, + amount: transaction?.amount ?? 0, + currency: transaction?.currency ?? '', + merchant: transaction?.merchant ?? '', + category: transaction?.category, + date: transaction?.created ?? '', + invoiceRoomReportID, + createdChatReportActionID, + invoiceReportID, + reportPreviewReportActionID, + transactionID, + transactionThreadReportID, + }; + + if (invoiceChatReport) { + parameters = { + ...parameters, + receiverInvoiceRoomID: invoiceChatReport.reportID, + }; + } else { + parameters = { + ...parameters, + receiverEmail: receiver.login, + }; + } + + API.write(WRITE_COMMANDS.SEND_INVOICE, parameters, onyxData); + + Navigation.dismissModal(invoiceRoomReportID); + Report.notifyNewAction(invoiceRoomReportID, receiver.accountID); +} + /** * Track an expense */ @@ -5795,10 +6286,24 @@ function setMoneyRequestParticipantsFromReport(transactionID: string, report: On const chatReport = ReportUtils.isMoneyRequestReport(report) ? ReportUtils.getReport(report?.chatReportID) : report; const currentUserAccountID = currentUserPersonalDetails.accountID; const shouldAddAsReport = !isEmptyObject(chatReport) && ReportUtils.isSelfDM(chatReport); - const participants: Participant[] = - ReportUtils.isPolicyExpenseChat(chatReport) || shouldAddAsReport - ? [{accountID: 0, reportID: chatReport?.reportID, isPolicyExpenseChat: ReportUtils.isPolicyExpenseChat(chatReport), selected: true}] - : (chatReport?.participantAccountIDs ?? []).filter((accountID) => currentUserAccountID !== accountID).map((accountID) => ({accountID, selected: true})); + let participants: Participant[] = []; + + if (ReportUtils.isPolicyExpenseChat(chatReport) || shouldAddAsReport) { + participants = [{accountID: 0, reportID: chatReport?.reportID, isPolicyExpenseChat: ReportUtils.isPolicyExpenseChat(chatReport), selected: true}]; + } else { + participants = (chatReport?.participantAccountIDs ?? []).filter((accountID) => currentUserAccountID !== accountID).map((accountID) => ({accountID, selected: true})); + + if (ReportUtils.isInvoiceRoom(chatReport)) { + participants = [ + ...participants, + { + policyID: chatReport?.policyID, + isSender: true, + selected: false, + }, + ]; + } + } Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {participants, participantsAutoAssigned: true}); @@ -5970,6 +6475,13 @@ function savePreferredPaymentMethod(policyID: string, paymentMethod: PaymentMeth Onyx.merge(`${ONYXKEYS.NVP_LAST_PAYMENT_METHOD}`, {[policyID]: paymentMethod}); } +/** Get report policy id of IOU request */ +function getIOURequestPolicyID(transaction: OnyxEntry, report: OnyxEntry): string { + // Workspace sender will exist for invoices + const workspaceSender = transaction?.participants?.find((participant) => participant.isSender); + return workspaceSender?.policyID ?? report?.policyID ?? '0'; +} + export { approveMoneyRequest, canApproveIOU, @@ -6027,5 +6539,7 @@ export { updateMoneyRequestTag, updateMoneyRequestTaxAmount, updateMoneyRequestTaxRate, + sendInvoice, + getIOURequestPolicyID, }; export type {GPSPoint as GpsPoint, IOURequestType}; diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index 6d457f150fe6..fd2b463659da 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -277,6 +277,16 @@ function getPolicy(policyID: string | undefined): Policy | EmptyObject { return allPolicies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`] ?? {}; } +/** + * Returns a primary policy for the user + */ +function getPrimaryPolicy(activePolicyID?: string): Policy | undefined { + const activeAdminWorkspaces = PolicyUtils.getActiveAdminWorkspaces(allPolicies); + const primaryPolicy: Policy | null | undefined = allPolicies?.[activePolicyID ?? '']; + + return primaryPolicy ?? activeAdminWorkspaces[0]; +} + /** * Check if the user has any active free policies (aka workspaces) */ @@ -5102,6 +5112,7 @@ export { updatePolicyDistanceRateValue, setPolicyDistanceRatesEnabled, deletePolicyDistanceRates, + getPrimaryPolicy, }; export type {NewCustomUnit}; diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx index d74ab3fbfb58..260e08ffcdf2 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.tsx @@ -19,6 +19,7 @@ import getIconForAction from '@libs/getIconForAction'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as App from '@userActions/App'; import * as IOU from '@userActions/IOU'; @@ -43,7 +44,7 @@ const useIsFocused = () => { return isFocused || (topmostCentralPane?.name === SCREENS.SEARCH.CENTRAL_PANE && isSmallScreenWidth); }; -type PolicySelector = Pick; +type PolicySelector = Pick; type FloatingActionButtonAndPopoverOnyxProps = { /** The list of policies the user has access to. */ @@ -87,6 +88,7 @@ const policySelector = (policy: OnyxEntry): PolicySelector => (policy && { type: policy.type, role: policy.role, + id: policy.id, isPolicyExpenseChatEnabled: policy.isPolicyExpenseChatEnabled, pendingAction: policy.pendingAction, avatar: policy.avatar, @@ -176,6 +178,8 @@ function FloatingActionButtonAndPopover( const prevIsFocused = usePrevious(isFocused); const {isOffline} = useNetwork(); + const canSendInvoice = useMemo(() => PolicyUtils.canSendInvoice(allPolicies as OnyxCollection), [allPolicies]); + const quickActionAvatars = useMemo(() => { if (quickActionReport) { const avatars = ReportUtils.getIcons(quickActionReport, personalDetails); @@ -373,6 +377,23 @@ function FloatingActionButtonAndPopover( ), ), }, + ...(canSendInvoice + ? [ + { + icon: Expensicons.InvoiceGeneric, + text: translate('workspace.invoices.sendInvoice'), + onSelected: () => + interceptAnonymousUser(() => + IOU.startMoneyRequest( + CONST.IOU.TYPE.INVOICE, + // When starting to create an invoice from the global FAB, there is not an existing report yet. A random optimistic reportID is generated and used + // for all of the routes in the creation flow. + ReportUtils.generateReportID(), + ), + ), + }, + ] + : []), { icon: Expensicons.Task, text: translate('newTaskPage.assignTask'), diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx index db58e4220cba..1bbf0d02a941 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -1,7 +1,7 @@ import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import DragAndDropProvider from '@components/DragAndDrop/Provider'; @@ -17,6 +17,7 @@ import * as IOUUtils from '@libs/IOUUtils'; import * as KeyDownPressListener from '@libs/KeyboardShortcut/KeyDownPressListener'; import Navigation from '@libs/Navigation/Navigation'; import OnyxTabNavigator, {TopTab} from '@libs/Navigation/OnyxTabNavigator'; +import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; import * as IOU from '@userActions/IOU'; @@ -43,6 +44,9 @@ type IOURequestStartPageOnyxProps = { /** The transaction being modified */ transaction: OnyxEntry; + + /** The list of all policies */ + allPolicies: OnyxCollection; }; type IOURequestStartPageProps = IOURequestStartPageOnyxProps & WithWritableReportOrNotFoundProps; @@ -56,6 +60,7 @@ function IOURequestStartPage({ }, selectedTab, transaction, + allPolicies, }: IOURequestStartPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -101,7 +106,7 @@ function IOURequestStartPage({ const shouldDisplayDistanceRequest = (!!canUseP2PDistanceRequests || isExpenseChat || isExpenseReport || isFromGlobalCreate) && iouType !== CONST.IOU.TYPE.SPLIT; // Allow the user to submit the expense if we are submitting the expense in global menu or the report can create the exoense - const isAllowedToCreateRequest = isEmptyObject(report?.reportID) || ReportUtils.canCreateRequest(report, policy, iouType); + const isAllowedToCreateRequest = isEmptyObject(report?.reportID) || ReportUtils.canCreateRequest(report, policy, iouType) || PolicyUtils.canSendInvoice(allPolicies); const navigateBack = () => { Navigation.closeRHPFlow(); @@ -139,7 +144,7 @@ function IOURequestStartPage({ title={tabTitles[iouType]} onBackButtonPress={navigateBack} /> - {iouType !== CONST.IOU.TYPE.SEND && iouType !== CONST.IOU.TYPE.PAY ? ( + {iouType !== CONST.IOU.TYPE.SEND && iouType !== CONST.IOU.TYPE.PAY && iouType !== CONST.IOU.TYPE.INVOICE ? ( ( transaction: { key: ({route}) => `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${route?.params.transactionID ?? 0}`, }, + allPolicies: { + key: ONYXKEYS.COLLECTION.POLICY, + }, })(IOURequestStartPage); diff --git a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js index 3a29f35fac8d..7ae6d25e1b4f 100644 --- a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js +++ b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js @@ -24,6 +24,7 @@ import useScreenWrapperTranstionStatus from '@hooks/useScreenWrapperTransitionSt import useThemeStyles from '@hooks/useThemeStyles'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import * as Policy from '@userActions/Policy'; import * as Report from '@userActions/Report'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -72,6 +73,7 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({participants, onF const {canUseP2PDistanceRequests} = usePermissions(); const {didScreenTransitionEnd} = useScreenWrapperTranstionStatus(); const [betas] = useOnyx(ONYXKEYS.BETAS); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const {options, areOptionsInitialized} = useOptionsList({ shouldInitialize: didScreenTransitionEnd, }); @@ -83,6 +85,8 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({participants, onF const isIOUSplit = iouType === CONST.IOU.TYPE.SPLIT; const isCategorizeOrShareAction = [CONST.IOU.ACTION.CATEGORIZE, CONST.IOU.ACTION.SHARE].includes(action); + const shouldShowReferralBanner = !isDismissed && iouType !== CONST.IOU.TYPE.INVOICE; + useEffect(() => { Report.searchInServer(debouncedSearchTerm.trim()); }, [debouncedSearchTerm]); @@ -193,13 +197,26 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({participants, onF */ const addSingleParticipant = useCallback( (option) => { - onParticipantsAdded([ + const newParticipants = [ { ...lodashPick(option, 'accountID', 'login', 'isPolicyExpenseChat', 'reportID', 'searchText', 'policyID'), selected: true, iouType, }, - ]); + ]; + + if (iouType === CONST.IOU.TYPE.INVOICE) { + const primaryPolicy = Policy.getPrimaryPolicy(activePolicyID); + + newParticipants.push({ + policyID: primaryPolicy.id, + isSender: true, + selected: false, + iouType, + }); + } + + onParticipantsAdded(newParticipants); onFinish(); }, // eslint-disable-next-line react-hooks/exhaustive-deps -- we don't want to trigger this callback when iouType changes @@ -270,8 +287,7 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({participants, onF // canUseP2PDistanceRequests is true if the iouType is track expense, but we don't want to allow splitting distance with track expense yet const isAllowedToSplit = (canUseP2PDistanceRequests || iouRequestType !== CONST.IOU.REQUEST_TYPE.DISTANCE) && - iouType !== CONST.IOU.TYPE.PAY && - iouType !== CONST.IOU.TYPE.TRACK && + ![CONST.IOU.TYPE.PAY, CONST.IOU.TYPE.TRACK, CONST.IOU.TYPE.INVOICE].includes(iouType) && ![CONST.IOU.ACTION.SHARE, CONST.IOU.ACTION.SUBMIT, CONST.IOU.ACTION.CATEGORIZE].includes(action); const handleConfirmSelection = useCallback( @@ -298,7 +314,7 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({participants, onF return ( <> - {!isDismissed && ( + {shouldShowReferralBanner && ( ); - }, [handleConfirmSelection, participants.length, isDismissed, referralContentType, shouldShowSplitBillErrorMessage, styles, translate]); + }, [handleConfirmSelection, participants.length, isDismissed, referralContentType, shouldShowSplitBillErrorMessage, styles, translate, shouldShowReferralBanner]); return ( transaction?.participants?.map((participant) => { const participantAccountID = participant.accountID ?? 0; + + if (participant.isSender && iouType === CONST.IOU.TYPE.INVOICE) { + return participant; + } + return participantAccountID ? OptionsListUtils.getParticipantsOption(participant, personalDetails) : OptionsListUtils.getReportOption(participant); }) ?? [], - [transaction?.participants, personalDetails], + [transaction?.participants, personalDetails, iouType], ); const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(report)), [report]); const formHasBeenSubmitted = useRef(false); @@ -353,6 +361,11 @@ function IOURequestStepConfirmation({ return; } + if (iouType === CONST.IOU.TYPE.INVOICE) { + IOU.sendInvoice(currentUserPersonalDetails.accountID, transaction, report, receiptFile, policy, policyTags, policyCategories); + return; + } + if (iouType === CONST.IOU.TYPE.TRACK || isCategorizingTrackExpense || isSharingTrackExpense) { if (receiptFile && transaction) { // If the transaction amount is zero, then the money is being requested through the "Scan" flow and the GPS coordinates need to be included. @@ -429,18 +442,21 @@ function IOURequestStepConfirmation({ }, [ transaction, + report, iouType, receiptFile, requestType, requestMoney, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, - report?.reportID, trackExpense, createDistanceRequest, isSharingTrackExpense, isCategorizingTrackExpense, action, + policy, + policyTags, + policyCategories, ], ); @@ -548,13 +564,13 @@ IOURequestStepConfirmation.displayName = 'IOURequestStepConfirmation'; const IOURequestStepConfirmationWithOnyx = withOnyx({ policy: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`, + key: ({report, transaction}) => `${ONYXKEYS.COLLECTION.POLICY}${IOU.getIOURequestPolicyID(transaction, report)}`, }, policyCategories: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${report ? report.policyID : '0'}`, + key: ({report, transaction}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${IOU.getIOURequestPolicyID(transaction, report)}`, }, policyTags: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${report ? report.policyID : '0'}`, + key: ({report, transaction}) => `${ONYXKEYS.COLLECTION.POLICY_TAGS}${IOU.getIOURequestPolicyID(transaction, report)}`, }, })(IOURequestStepConfirmation); /* eslint-disable rulesdir/no-negated-variables */ diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx index 374d4e9777cf..be95cb03e95b 100644 --- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx +++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx @@ -55,6 +55,9 @@ function IOURequestStepParticipants({ if (iouType === CONST.IOU.TYPE.PAY) { return translate('iou.paySomeone', {}); } + if (iouType === CONST.IOU.TYPE.INVOICE) { + return translate('workspace.invoices.sendInvoice'); + } return translate('iou.submitExpense'); }, [iouType, translate, isSplitRequest, action]); diff --git a/src/pages/iou/request/step/IOURequestStepSendFrom.tsx b/src/pages/iou/request/step/IOURequestStepSendFrom.tsx new file mode 100644 index 000000000000..6de3780aa6e8 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepSendFrom.tsx @@ -0,0 +1,105 @@ +import React, {useMemo} from 'react'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxCollection} from 'react-native-onyx'; +import * as Expensicons from '@components/Icon/Expensicons'; +import SelectionList from '@components/SelectionList'; +import type {ListItem} from '@components/SelectionList/types'; +import UserListItem from '@components/SelectionList/UserListItem'; +import useLocalize from '@hooks/useLocalize'; +import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import * as IOU from '@userActions/IOU'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type {Policy} from '@src/types/onyx'; +import StepScreenWrapper from './StepScreenWrapper'; +import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; +import type {WithFullTransactionOrNotFoundProps} from './withFullTransactionOrNotFound'; +import withWritableReportOrNotFound from './withWritableReportOrNotFound'; +import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; + +type WorkspaceListItem = ListItem & { + value: string; +}; + +type IOURequestStepSendFromOnyxProps = { + /** The list of all policies */ + allPolicies: OnyxCollection; +}; + +type IOURequestStepSendFromProps = IOURequestStepSendFromOnyxProps & + WithWritableReportOrNotFoundProps & + WithFullTransactionOrNotFoundProps; + +function IOURequestStepSendFrom({route, transaction, allPolicies}: IOURequestStepSendFromProps) { + const {translate} = useLocalize(); + const {transactionID, backTo} = route.params; + + const selectedWorkspace = useMemo(() => transaction?.participants?.find((participant) => participant.isSender), [transaction]); + + const workspaceOptions: WorkspaceListItem[] = useMemo(() => { + const activeAdminWorkspaces = PolicyUtils.getActiveAdminWorkspaces(allPolicies); + return activeAdminWorkspaces.map((policy) => ({ + text: policy.name, + value: policy.id, + keyForList: policy.id, + icons: [ + { + source: policy?.avatar ? policy.avatar : ReportUtils.getDefaultWorkspaceAvatar(policy.name), + fallbackIcon: Expensicons.FallbackWorkspaceAvatar, + name: policy.name, + type: CONST.ICON_TYPE_WORKSPACE, + }, + ], + isSelected: selectedWorkspace?.policyID === policy.id, + })); + }, [allPolicies, selectedWorkspace]); + + const navigateBack = () => { + Navigation.goBack(backTo); + }; + + const selectWorkspace = (item: WorkspaceListItem) => { + const newParticipants = (transaction?.participants ?? []).filter((participant) => participant.accountID); + + newParticipants.push({ + policyID: item.value, + isSender: true, + selected: false, + }); + + IOU.setMoneyRequestParticipants(transactionID, newParticipants); + navigateBack(); + }; + + return ( + + + + ); +} + +IOURequestStepSendFrom.displayName = 'IOURequestStepSendFrom'; + +export default withWritableReportOrNotFound( + withFullTransactionOrNotFound( + withOnyx({ + allPolicies: { + key: ONYXKEYS.COLLECTION.POLICY, + }, + })(IOURequestStepSendFrom), + ), +); diff --git a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx index e29ee52f32a7..68712b730115 100644 --- a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx +++ b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx @@ -33,7 +33,8 @@ type MoneyRequestRouteName = | typeof SCREENS.MONEY_REQUEST.STEP_CATEGORY | typeof SCREENS.MONEY_REQUEST.STEP_TAX_RATE | typeof SCREENS.MONEY_REQUEST.STEP_SCAN - | typeof SCREENS.MONEY_REQUEST.STEP_CURRENCY; + | typeof SCREENS.MONEY_REQUEST.STEP_CURRENCY + | typeof SCREENS.MONEY_REQUEST.STEP_SEND_FROM; type Route = RouteProp; diff --git a/src/pages/iou/request/step/withWritableReportOrNotFound.tsx b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx index 81a6ded4e7a3..4a020ee8d411 100644 --- a/src/pages/iou/request/step/withWritableReportOrNotFound.tsx +++ b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx @@ -32,7 +32,8 @@ type MoneyRequestRouteName = | typeof SCREENS.MONEY_REQUEST.STEP_PARTICIPANTS | typeof SCREENS.MONEY_REQUEST.STEP_MERCHANT | typeof SCREENS.MONEY_REQUEST.STEP_TAX_AMOUNT - | typeof SCREENS.MONEY_REQUEST.STEP_SCAN; + | typeof SCREENS.MONEY_REQUEST.STEP_SCAN + | typeof SCREENS.MONEY_REQUEST.STEP_SEND_FROM; type Route = RouteProp; diff --git a/src/types/onyx/IOU.ts b/src/types/onyx/IOU.ts index 7e1827f73954..726b94c5f6d3 100644 --- a/src/types/onyx/IOU.ts +++ b/src/types/onyx/IOU.ts @@ -22,6 +22,7 @@ type Participant = { text?: string; isSelected?: boolean; isSelfDM?: boolean; + isSender?: boolean; }; type Split = { diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index f22edd423c0d..853ca8485c4a 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -354,6 +354,7 @@ export type { OriginalMessageJoinPolicyChangeLog, OriginalMessageActionableMentionWhisper, OriginalMessageChronosOOOList, + OriginalMessageRoomChangeLog, OriginalMessageSource, OriginalMessageReimbursementDequeued, DecisionName,