diff --git a/src/ROUTES.ts b/src/ROUTES.ts index bf32219485e6..98960e9246e1 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -561,13 +561,12 @@ const ROUTES = { getRoute: (reportID: string, accountID: number, backTo?: string) => getUrlWithBackToParam(`r/${reportID}/members/${accountID}` as const, backTo), }, ROOM_INVITE: { - route: 'r/:reportID/invite/:role?', - getRoute: (reportID: string | undefined, role?: string, backTo?: string) => { + route: 'r/:reportID/invite', + getRoute: (reportID: string | undefined, backTo?: string) => { if (!reportID) { Log.warn('Invalid reportID is used to build the ROOM_INVITE route'); } - const route = role ? (`r/${reportID}/invite/${role}` as const) : (`r/${reportID}/invite` as const); - return getUrlWithBackToParam(route, backTo); + return getUrlWithBackToParam(`r/${reportID}/invite` as const, backTo); }, }, MONEY_REQUEST_HOLD_REASON: { @@ -642,6 +641,15 @@ const ROUTES = { return getUrlWithBackToParam(`${action as string}/${iouType as string}/attendees/${transactionID}/${reportID}`, backTo); }, }, + MONEY_REQUEST_ACCOUNTANT: { + route: ':action/:iouType/accountant/:transactionID/:reportID', + getRoute: (action: IOUAction, iouType: IOUType, transactionID: string | undefined, reportID: string | undefined, backTo = '') => { + if (!transactionID || !reportID) { + Log.warn('Invalid transactionID or reportID is used to build the MONEY_REQUEST_ACCOUNTANT route'); + } + return getUrlWithBackToParam(`${action as string}/${iouType as string}/accountant/${transactionID}/${reportID}`, backTo); + }, + }, MONEY_REQUEST_UPGRADE: { route: ':action/:iouType/upgrade/:transactionID/:reportID', getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => diff --git a/src/SCREENS.ts b/src/SCREENS.ts index cb60b91e4e2c..b18736969d31 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -257,6 +257,7 @@ const SCREENS = { RECEIPT: 'Money_Request_Receipt', STATE_SELECTOR: 'Money_Request_State_Selector', STEP_ATTENDEES: 'Money_Request_Attendee', + STEP_ACCOUNTANT: 'Money_Request_Accountant', STEP_DESTINATION: 'Money_Request_Destination', STEP_TIME: 'Money_Request_Time', STEP_SUBRATE: 'Money_Request_SubRate', diff --git a/src/languages/en.ts b/src/languages/en.ts index a05029c2b691..568a4d50759f 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1168,6 +1168,7 @@ const translations = { bookingArchived: 'This booking is archived', bookingArchivedDescription: 'This booking is archived because the trip date has passed. Add an expense for the final amount if needed.', attendees: 'Attendees', + whoIsYourAccountant: 'Who is your accountant?', paymentComplete: 'Payment complete', time: 'Time', startDate: 'Start date', diff --git a/src/languages/es.ts b/src/languages/es.ts index 37e3a4837e6e..f16f01878ec6 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1167,6 +1167,7 @@ const translations = { bookingArchived: 'Esta reserva está archivada', bookingArchivedDescription: 'Esta reserva está archivada porque la fecha del viaje ha pasado. Agregue un gasto por el monto final si es necesario.', attendees: 'Asistentes', + whoIsYourAccountant: '¿Quién es tu contador?', paymentComplete: 'Pago completo', time: 'Tiempo', startDate: 'Fecha de inicio', diff --git a/src/libs/API/parameters/ShareTrackedExpenseParams.ts b/src/libs/API/parameters/ShareTrackedExpenseParams.ts index 38f684de4a8c..dcdf87cf74cf 100644 --- a/src/libs/API/parameters/ShareTrackedExpenseParams.ts +++ b/src/libs/API/parameters/ShareTrackedExpenseParams.ts @@ -29,6 +29,7 @@ type ShareTrackedExpenseParams = { engagementChoice?: string; guidedSetupData?: string; description?: string; + accountantEmail: string; policyName?: string; }; diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 69f863e33cb1..4d0b625cde99 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -1052,6 +1052,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) managedCard: CONST.RED_BRICK_ROAD_PENDING_ACTION, posted: CONST.RED_BRICK_ROAD_PENDING_ACTION, inserted: CONST.RED_BRICK_ROAD_PENDING_ACTION, + accountant: CONST.RED_BRICK_ROAD_PENDING_ACTION, }, 'string', ); @@ -1089,6 +1090,11 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) splits: 'array', dismissedViolations: 'object', }); + case 'accountant': + return validateObject>(value, { + accountID: 'number', + login: 'string', + }); case 'modifiedAttendees': return validateArray>(value, { email: 'string', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 5a3d19494515..673616b26873 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -105,6 +105,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../../pages/EnablePayments/EnablePaymentsPage').default, [SCREENS.MONEY_REQUEST.STATE_SELECTOR]: () => require('../../../../pages/settings/Profile/PersonalDetails/StateSelectionPage').default, [SCREENS.MONEY_REQUEST.STEP_ATTENDEES]: () => require('../../../../pages/iou/request/step/IOURequestStepAttendees').default, + [SCREENS.MONEY_REQUEST.STEP_ACCOUNTANT]: () => require('../../../../pages/iou/request/step/IOURequestStepAccountant').default, [SCREENS.MONEY_REQUEST.STEP_UPGRADE]: () => require('../../../../pages/iou/request/step/IOURequestStepUpgrade').default, [SCREENS.MONEY_REQUEST.STEP_DESTINATION]: () => require('../../../../pages/iou/request/step/IOURequestStepDestination').default, [SCREENS.MONEY_REQUEST.STEP_TIME]: () => require('../../../../pages/iou/request/step/IOURequestStepTime').default, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index b1a6ef4cef2a..6dc9de127ba7 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1327,6 +1327,7 @@ const config: LinkingOptions['config'] = { }, [SCREENS.MONEY_REQUEST.STEP_SPLIT_PAYER]: ROUTES.MONEY_REQUEST_STEP_SPLIT_PAYER.route, [SCREENS.MONEY_REQUEST.STEP_ATTENDEES]: ROUTES.MONEY_REQUEST_ATTENDEE.route, + [SCREENS.MONEY_REQUEST.STEP_ACCOUNTANT]: ROUTES.MONEY_REQUEST_ACCOUNTANT.route, [SCREENS.MONEY_REQUEST.STEP_UPGRADE]: ROUTES.MONEY_REQUEST_UPGRADE.route, [SCREENS.MONEY_REQUEST.STEP_DESTINATION]: ROUTES.MONEY_REQUEST_STEP_DESTINATION.route, [SCREENS.MONEY_REQUEST.STEP_TIME]: ROUTES.MONEY_REQUEST_STEP_TIME.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 9d97fe7a43c3..d294b90a85e6 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1327,6 +1327,13 @@ type MoneyRequestNavigatorParamList = { reportID: string; backTo: Routes; }; + [SCREENS.MONEY_REQUEST.STEP_ACCOUNTANT]: { + action: IOUAction; + iouType: Exclude; + transactionID: string; + reportID: string; + backTo: Routes; + }; [SCREENS.MONEY_REQUEST.STEP_UPGRADE]: { action: IOUAction; iouType: Exclude; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 34c87d0318e6..9748be7d15a5 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -205,6 +205,7 @@ type GetValidReportsConfig = { loginsToExclude?: Record; shouldSeparateWorkspaceChat?: boolean; shouldSeparateSelfDMChat?: boolean; + excludeNonAdminWorkspaces?: boolean; } & GetValidOptionsSharedConfig; type GetValidReportsReturnTypeCombined = { @@ -1402,6 +1403,7 @@ function getValidReports(reports: OptionList['reports'], config: GetValidReports loginsToExclude = {}, shouldSeparateSelfDMChat, shouldSeparateWorkspaceChat, + excludeNonAdminWorkspaces, } = config; const topmostReportId = Navigation.getTopmostReportId(); @@ -1441,6 +1443,10 @@ function getValidReports(reports: OptionList['reports'], config: GetValidReports const isChatRoom = option.isChatRoom; const accountIDs = getParticipantsAccountIDsForDisplay(report); + if (excludeNonAdminWorkspaces && !isPolicyAdmin(option.policyID, policies)) { + continue; + } + if (isPolicyExpenseChat && report.isOwnPolicyExpenseChat && !includeOwnedWorkspaceChats) { continue; } diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 577b5e85d671..5e11d9e9696c 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -718,6 +718,13 @@ function getActiveAdminWorkspaces(policies: OnyxCollection | null, curre return activePolicies.filter((policy) => shouldShowPolicy(policy, isOfflineNetworkStore(), currentUserLogin) && isPolicyAdmin(policy, currentUserLogin)); } +/** + * Checks whether the current user has a policy with admin access + */ +function hasActiveAdminWorkspaces(currentUserLogin: string | undefined) { + return getActiveAdminWorkspaces(allPolicies, currentUserLogin).length > 0; +} + /** * * Checks whether the current user has a policy with Xero accounting software integration @@ -1474,6 +1481,7 @@ export { isTaxTrackingEnabled, shouldShowPolicy, getActiveAdminWorkspaces, + hasActiveAdminWorkspaces, getOwnedPaidPolicies, canSendInvoiceFromWorkspace, canSubmitPerDiemExpenseFromWorkspace, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 4f3a0cf32927..a1a72ebb388d 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -9511,6 +9511,11 @@ function createDraftTransactionAndNavigateToParticipantSelector( return; } + if (actionName === CONST.IOU.ACTION.SHARE) { + Navigation.navigate(ROUTES.MONEY_REQUEST_ACCOUNTANT.getRoute(actionName, CONST.IOU.TYPE.SUBMIT, transactionID, reportID, undefined)); + return; + } + if (actionName === CONST.IOU.ACTION.SUBMIT || (allPolicies && filteredPolicies.length > 0)) { Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.SUBMIT, transactionID, reportID, undefined, actionName)); return; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 9f832b7f171d..22f1ed89026b 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -61,6 +61,7 @@ import Performance from '@libs/Performance'; import {getAccountIDsByLogins} from '@libs/PersonalDetailsUtils'; import {addSMSDomainIfPhoneNumber} from '@libs/PhoneNumber'; import { + getMemberAccountIDsForWorkspace, getPerDiemCustomUnit, getPersonalPolicy, getPolicy, @@ -113,6 +114,7 @@ import { canBeAutoReimbursed, canUserPerformWriteAction as canUserPerformWriteActionReportUtils, getAllHeldTransactions as getAllHeldTransactionsReportUtils, + getAllPolicyReports, getApprovalChain, getChatByParticipants, getDisplayedReportID, @@ -194,7 +196,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; -import type {Attendee, Participant, Split} from '@src/types/onyx/IOU'; +import type {Accountant, Attendee, Participant, Split} from '@src/types/onyx/IOU'; import type {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon'; import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage'; import type {QuickActionName} from '@src/types/onyx/QuickAction'; @@ -206,10 +208,11 @@ import type {Comment, Receipt, ReceiptSource, Routes, SplitShares, TransactionCh import {isEmptyObject} from '@src/types/utils/EmptyObject'; import {clearByKey as clearPdfByOnyxKey} from './CachedPDFPaths'; import {buildOptimisticPolicyRecentlyUsedCategories} from './Policy/Category'; +import {buildAddMembersToWorkspaceOnyxData, buildUpdateWorkspaceMembersRoleOnyxData} from './Policy/Member'; import {buildOptimisticPolicyRecentlyUsedDestinations} from './Policy/PerDiem'; import {buildOptimisticRecentlyUsedCurrencies, buildPolicyData, generatePolicyID} from './Policy/Policy'; import {buildOptimisticPolicyRecentlyUsedTags} from './Policy/Tag'; -import {completeOnboarding, getCurrentUserAccountID, notifyNewAction} from './Report'; +import {buildInviteToRoomOnyxData, completeOnboarding, getCurrentUserAccountID, notifyNewAction} from './Report'; import {clearAllRelatedReportActionErrors} from './ReportActions'; import {getRecentWaypoints, sanitizeRecentWaypoints} from './Transaction'; import {removeDraftTransaction} from './TransactionEdit'; @@ -284,6 +287,7 @@ type TrackedExpenseReportInformation = { linkedTrackedExpenseReportID: string; transactionThreadReportID: string | undefined; reportPreviewReportActionID: string | undefined; + chatReportID: string | undefined; }; type TrackedExpenseParams = { onyxData?: OnyxData; @@ -291,6 +295,7 @@ type TrackedExpenseParams = { transactionParams: TrackedExpenseTransactionParams; policyParams: TrackedExpensePolicyParams; createdWorkspaceParams?: CreateWorkspaceParams; + accountantParams?: TrackExpenseAccountantParams; }; type SendInvoiceInformation = { @@ -497,6 +502,10 @@ type TrackExpenseTransactionParams = { customUnitRateID?: string; }; +type TrackExpenseAccountantParams = { + accountant?: Accountant; +}; + type CreateTrackExpenseParams = { report: OnyxTypes.Report; isDraftPolicy: boolean; @@ -504,6 +513,7 @@ type CreateTrackExpenseParams = { participantParams: RequestMoneyParticipantParams; policyParams?: BasePolicyParams; transactionParams: TrackExpenseTransactionParams; + accountantParams?: TrackExpenseAccountantParams; isRetry?: boolean; }; @@ -656,6 +666,39 @@ Onyx.connect({ }, }); +const allPolicies: OnyxCollection = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + callback: (val, key) => { + if (!key) { + return; + } + if (val === null || val === undefined) { + // If we are deleting a policy, we have to check every report linked to that policy + // and unset the draft indicator (pencil icon) alongside removing any draft comments. Clearing these values will keep the newly archived chats from being displayed in the LHN. + // More info: https://github.com/Expensify/App/issues/14260 + const policyID = key.replace(ONYXKEYS.COLLECTION.POLICY, ''); + const policyReports = getAllPolicyReports(policyID); + const cleanUpMergeQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT}${string}`, NullishDeep> = {}; + const cleanUpSetQueries: Record<`${typeof ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${string}` | `${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${string}`, null> = {}; + policyReports.forEach((policyReport) => { + if (!policyReport) { + return; + } + const {reportID} = policyReport; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`] = null; + cleanUpSetQueries[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}`] = null; + }); + Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, cleanUpMergeQueries); + Onyx.multiSet(cleanUpSetQueries); + delete allPolicies[key]; + return; + } + + allPolicies[key] = val; + }, +}); + let allReports: OnyxCollection; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, @@ -917,6 +960,10 @@ function setMoneyRequestAttendees(transactionID: string, attendees: Attendee[], Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {comment: {attendees}}); } +function setMoneyRequestAccountant(transactionID: string, accountant: Accountant, isDraft: boolean) { + Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {accountant}); +} + function setMoneyRequestPendingFields(transactionID: string, pendingFields: OnyxTypes.Transaction['pendingFields']) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {pendingFields}); } @@ -4818,8 +4865,19 @@ function categorizeTrackedExpense(trackedExpenseParams: TrackedExpenseParams) { } function shareTrackedExpense(trackedExpenseParams: TrackedExpenseParams) { - const {onyxData, reportInformation, transactionParams, policyParams, createdWorkspaceParams} = trackedExpenseParams; + const {onyxData, reportInformation, transactionParams, policyParams, createdWorkspaceParams, accountantParams} = trackedExpenseParams; + + const policyID = policyParams?.policyID; + const chatReportID = reportInformation?.chatReportID; + const accountantEmail = addSMSDomainIfPhoneNumber(accountantParams?.accountant?.login); + const accountantAccountID = accountantParams?.accountant?.accountID; + + if (!policyID || !chatReportID || !accountantEmail || !accountantAccountID) { + return; + } + const {optimisticData: shareTrackedExpenseOptimisticData = [], successData: shareTrackedExpenseSuccessData = [], failureData: shareTrackedExpenseFailureData = []} = onyxData ?? {}; + const {transactionID} = transactionParams; const { actionableWhisperReportActionID, @@ -4846,9 +4904,43 @@ function shareTrackedExpense(trackedExpenseParams: TrackedExpenseParams) { successData?.push(...shareTrackedExpenseSuccessData); failureData?.push(...shareTrackedExpenseFailureData); + const policyEmployeeList = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyParams?.policyID}`]?.employeeList; + if (!policyEmployeeList?.[accountantEmail]) { + const policyMemberAccountIDs = Object.values(getMemberAccountIDsForWorkspace(policyEmployeeList, false, false)); + const { + optimisticData: addAccountantToWorkspaceOptimisticData, + successData: addAccountantToWorkspaceSuccessData, + failureData: addAccountantToWorkspaceFailureData, + } = buildAddMembersToWorkspaceOnyxData({[accountantEmail]: accountantAccountID}, policyID, policyMemberAccountIDs, CONST.POLICY.ROLE.ADMIN); + optimisticData?.push(...addAccountantToWorkspaceOptimisticData); + successData?.push(...addAccountantToWorkspaceSuccessData); + failureData?.push(...addAccountantToWorkspaceFailureData); + } else if (policyEmployeeList?.[accountantEmail].role !== CONST.POLICY.ROLE.ADMIN) { + const { + optimisticData: addAccountantToWorkspaceOptimisticData, + successData: addAccountantToWorkspaceSuccessData, + failureData: addAccountantToWorkspaceFailureData, + } = buildUpdateWorkspaceMembersRoleOnyxData(policyID, [accountantAccountID], CONST.POLICY.ROLE.ADMIN); + optimisticData?.push(...addAccountantToWorkspaceOptimisticData); + successData?.push(...addAccountantToWorkspaceSuccessData); + failureData?.push(...addAccountantToWorkspaceFailureData); + } + + const chatReportParticipants = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`]?.participants; + if (!chatReportParticipants?.[accountantAccountID]) { + const { + optimisticData: inviteAccountantToRoomOptimisticData, + successData: inviteAccountantToRoomSuccessData, + failureData: inviteAccountantToRoomFailureData, + } = buildInviteToRoomOnyxData(chatReportID, {[accountantEmail]: accountantAccountID}); + optimisticData?.push(...inviteAccountantToRoomOptimisticData); + successData?.push(...inviteAccountantToRoomSuccessData); + failureData?.push(...inviteAccountantToRoomFailureData); + } + const parameters: ShareTrackedExpenseParams = { ...transactionParams, - policyID: policyParams?.policyID, + policyID, moneyRequestPreviewReportActionID, moneyRequestReportID, moneyRequestCreatedReportActionID, @@ -4863,6 +4955,7 @@ function shareTrackedExpense(trackedExpenseParams: TrackedExpenseParams) { guidedSetupData: createdWorkspaceParams?.guidedSetupData, policyName: createdWorkspaceParams?.policyName, description: transactionParams.comment, + accountantEmail, }; API.write(WRITE_COMMANDS.SHARE_TRACKED_EXPENSE, parameters, {optimisticData, successData, failureData}); @@ -5202,7 +5295,7 @@ function sendInvoice( * Track an expense */ function trackExpense(params: CreateTrackExpenseParams) { - const {report, action, isDraftPolicy, participantParams, policyParams: policyData = {}, transactionParams: transactionData} = params; + const {report, action, isDraftPolicy, participantParams, policyParams: policyData = {}, transactionParams: transactionData, accountantParams} = params; const {participant, payeeAccountID, payeeEmail} = participantParams; const {policy, policyCategories, policyTagList} = policyData; const parsedComment = getParsedComment(transactionData.comment ?? ''); @@ -5358,6 +5451,7 @@ function trackExpense(params: CreateTrackExpenseParams) { linkedTrackedExpenseReportID, transactionThreadReportID, reportPreviewReportActionID: reportPreviewAction?.reportActionID, + chatReportID: chatReport?.reportID, }; const trackedExpenseParams: TrackedExpenseParams = { onyxData, @@ -5402,6 +5496,7 @@ function trackExpense(params: CreateTrackExpenseParams) { linkedTrackedExpenseReportID, transactionThreadReportID, reportPreviewReportActionID: reportPreviewAction?.reportActionID, + chatReportID: chatReport?.reportID, }; const trackedExpenseParams: TrackedExpenseParams = { onyxData, @@ -5409,6 +5504,7 @@ function trackExpense(params: CreateTrackExpenseParams) { transactionParams, policyParams, createdWorkspaceParams, + accountantParams, }; shareTrackedExpense(trackedExpenseParams); break; @@ -5449,19 +5545,11 @@ function trackExpense(params: CreateTrackExpenseParams) { } } InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID)); + if (!params.isRetry) { dismissModalAndOpenReportInInboxTab(activeReportID); } - if (action === CONST.IOU.ACTION.SHARE) { - if (isSearchTopmostFullScreenRoute() && activeReportID) { - Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(activeReportID), {forceReplace: true}); - }); - } - Navigation.setNavigationActionToMicrotaskQueue(() => Navigation.navigate(ROUTES.ROOM_INVITE.getRoute(activeReportID, CONST.IOU.SHARE.ROLE.ACCOUNTANT))); - } - notifyNewAction(activeReportID, payeeAccountID); } @@ -10654,6 +10742,7 @@ export { setIndividualShare, setMoneyRequestAmount, setMoneyRequestAttendees, + setMoneyRequestAccountant, setMoneyRequestBillable, setMoneyRequestCategory, setMoneyRequestCreated, diff --git a/src/libs/actions/Policy/Member.ts b/src/libs/actions/Policy/Member.ts index 91348f7dac5b..6dab846aa382 100644 --- a/src/libs/actions/Policy/Member.ts +++ b/src/libs/actions/Policy/Member.ts @@ -628,7 +628,7 @@ function removeMembers(accountIDs: number[], policyID: string) { API.write(WRITE_COMMANDS.DELETE_MEMBERS_FROM_WORKSPACE, params, {optimisticData, successData, failureData}); } -function updateWorkspaceMembersRole(policyID: string, accountIDs: number[], newRole: ValueOf) { +function buildUpdateWorkspaceMembersRoleOnyxData(policyID: string, accountIDs: number[], newRole: ValueOf) { const previousEmployeeList = {...allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.employeeList}; const memberRoles: WorkspaceMembersRoleData[] = accountIDs.reduce((result: WorkspaceMembersRoleData[], accountID: number) => { if (!allPersonalDetails?.[accountID]?.login) { @@ -737,6 +737,12 @@ function updateWorkspaceMembersRole(policyID: string, accountIDs: number[], newR } } + return {optimisticData, successData, failureData, memberRoles}; +} + +function updateWorkspaceMembersRole(policyID: string, accountIDs: number[], newRole: ValueOf) { + const {optimisticData, successData, failureData, memberRoles} = buildUpdateWorkspaceMembersRoleOnyxData(policyID, accountIDs, newRole); + const params: UpdateWorkspaceMembersRoleParams = { policyID, employees: JSON.stringify(memberRoles.map((item) => ({email: item.email, role: item.role}))), @@ -831,15 +837,12 @@ function clearWorkspaceOwnerChangeFlow(policyID: string) { }); } -/** - * Adds members to the specified workspace/policyID - * Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details - */ -function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, welcomeNote: string, policyID: string, policyMemberAccountIDs: number[], role: string) { - const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; +function buildAddMembersToWorkspaceOnyxData(invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, policyID: string, policyMemberAccountIDs: number[], role: string) { const logins = Object.keys(invitedEmailsToAccountIDs).map((memberLogin) => PhoneNumber.addSMSDomainIfPhoneNumber(memberLogin)); const accountIDs = Object.values(invitedEmailsToAccountIDs); + const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; + const {newAccountIDs, newLogins} = PersonalDetailsUtils.getNewAccountIDsAndLogins(logins, accountIDs); const newPersonalDetailsOnyxData = PersonalDetailsUtils.getPersonalDetailsOnyxDataForOptimisticUsers(newLogins, newAccountIDs); @@ -921,6 +924,21 @@ function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccount ]; failureData.push(...membersChats.onyxFailureData, ...announceRoomChat.onyxFailureData, ...announceRoomMembers.failureData, ...adminRoomMembers.failureData); + return {optimisticData, successData, failureData, optimisticAnnounceChat, membersChats, logins}; +} + +/** + * Adds members to the specified workspace/policyID + * Please see https://github.com/Expensify/App/blob/main/README.md#Security for more details + */ +function addMembersToWorkspace(invitedEmailsToAccountIDs: InvitedEmailsToAccountIDs, welcomeNote: string, policyID: string, policyMemberAccountIDs: number[], role: string) { + const {optimisticData, successData, failureData, optimisticAnnounceChat, membersChats, logins} = buildAddMembersToWorkspaceOnyxData( + invitedEmailsToAccountIDs, + policyID, + policyMemberAccountIDs, + role, + ); + const params: AddMembersToWorkspaceParams = { employees: JSON.stringify(logins.map((login) => ({email: login, role}))), ...(optimisticAnnounceChat.announceChatReportID ? {announceChatReportID: optimisticAnnounceChat.announceChatReportID} : {}), @@ -1250,9 +1268,11 @@ function clearInviteDraft(policyID: string) { export { removeMembers, + buildUpdateWorkspaceMembersRoleOnyxData, updateWorkspaceMembersRole, requestWorkspaceOwnerChange, clearWorkspaceOwnerChangeFlow, + buildAddMembersToWorkspaceOnyxData, addMembersToWorkspace, clearDeleteMemberError, clearAddMemberError, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 10e8d0d8f983..57d1eb0d3f34 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -3554,13 +3554,10 @@ function leaveRoom(reportID: string, isWorkspaceMemberLeavingWorkspaceRoom = fal navigateToMostRecentReport(report); } -/** Invites people to a room */ -function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: InvitedEmailsToAccountIDs) { +function buildInviteToRoomOnyxData(reportID: string, inviteeEmailsToAccountIDs: InvitedEmailsToAccountIDs) { const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const reportMetadata = getReportMetadata(reportID); - if (!report) { - return; - } + const isGroupChat = isGroupChatReportUtils(report); const defaultNotificationPreference = getDefaultNotificationPreferenceForReport(report); @@ -3580,7 +3577,7 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: InvitedEmails reportParticipants[accountID] = participant; return reportParticipants; }, - {...report.participants}, + {...report?.participants}, ); const newPersonalDetailsOnyxData = PersonalDetailsUtils.getPersonalDetailsOnyxDataForOptimisticUsers(newLogins, newAccountIDs); @@ -3652,7 +3649,14 @@ function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: InvitedEmails }, ]; - if (isGroupChatReportUtils(report)) { + return {optimisticData, successData, failureData, isGroupChat, inviteeEmails, newAccountIDs}; +} + +/** Invites people to a room */ +function inviteToRoom(reportID: string, inviteeEmailsToAccountIDs: InvitedEmailsToAccountIDs) { + const {optimisticData, successData, failureData, isGroupChat, inviteeEmails, newAccountIDs} = buildInviteToRoomOnyxData(reportID, inviteeEmailsToAccountIDs); + + if (isGroupChat) { const parameters: InviteToGroupChatParams = { reportID, inviteeEmails, @@ -5249,6 +5253,7 @@ export { handleUserDeletedLinksInHtml, hasErrorInPrivateNotes, inviteToGroupChat, + buildInviteToRoomOnyxData, inviteToRoom, joinRoom, leaveGroupChat, diff --git a/src/pages/RoomInvitePage.tsx b/src/pages/RoomInvitePage.tsx index eae5be29c9d7..62e5452657d5 100644 --- a/src/pages/RoomInvitePage.tsx +++ b/src/pages/RoomInvitePage.tsx @@ -50,15 +50,15 @@ function RoomInvitePage({ report, policies, route: { - params: {role, backTo}, + params: {backTo}, }, }: RoomInvitePageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const [userSearchPhrase] = useOnyx(ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE); + const [userSearchPhrase] = useOnyx(ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE, {canBeMissing: true}); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(userSearchPhrase ?? ''); const [selectedOptions, setSelectedOptions] = useState([]); - const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false}); + const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false, canBeMissing: true}); const {options, areOptionsInitialized} = useOptionsList(); @@ -185,11 +185,8 @@ function RoomInvitePage({ const reportID = report?.reportID; const isPolicyEmployee = useMemo(() => (report?.policyID ? isPolicyEmployeeUtil(report.policyID, policies as Record) : false), [report?.policyID, policies]); const backRoute = useMemo(() => { - if (role === CONST.IOU.SHARE.ROLE.ACCOUNTANT) { - return ROUTES.REPORT_WITH_ID.getRoute(reportID); - } return reportID && (isPolicyEmployee ? ROUTES.ROOM_MEMBERS.getRoute(reportID, backTo) : ROUTES.REPORT_WITH_ID_DETAILS.getRoute(reportID, backTo)); - }, [isPolicyEmployee, reportID, role, backTo]); + }, [isPolicyEmployee, reportID, backTo]); const reportName = useMemo(() => getReportName(report), [report]); const inviteUsers = useCallback(() => { HttpUtils.cancelPendingRequests(READ_COMMANDS.SEARCH_FOR_REPORTS); @@ -214,12 +211,8 @@ function RoomInvitePage({ }, [selectedOptions, backRoute, reportID, validate]); const goBack = useCallback(() => { - if (role === CONST.IOU.SHARE.ROLE.ACCOUNTANT) { - Navigation.dismissModalWithReport({reportID}); - return; - } Navigation.goBack(backRoute); - }, [role, reportID, backRoute]); + }, [backRoute]); const headerMessage = useMemo(() => { const searchValue = debouncedSearchTerm.trim().toLowerCase(); diff --git a/src/pages/RoomMembersPage.tsx b/src/pages/RoomMembersPage.tsx index 907f374e84f0..2344434f5833 100644 --- a/src/pages/RoomMembersPage.tsx +++ b/src/pages/RoomMembersPage.tsx @@ -48,13 +48,13 @@ type RoomMembersPageProps = WithReportOrNotFoundProps & WithCurrentUserPersonalD function RoomMembersPage({report, policies}: RoomMembersPageProps) { const route = useRoute>(); const styles = useThemeStyles(); - const [session] = useOnyx(ONYXKEYS.SESSION); - const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID}`); + const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: false}); + const [reportMetadata] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_METADATA}${report?.reportID}`, {canBeMissing: false}); const currentUserAccountID = Number(session?.accountID); const {formatPhoneNumber, translate} = useLocalize(); const [selectedMembers, setSelectedMembers] = useState([]); const [removeMembersConfirmModalVisible, setRemoveMembersConfirmModalVisible] = useState(false); - const [userSearchPhrase] = useOnyx(ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE); + const [userSearchPhrase] = useOnyx(ONYXKEYS.ROOM_MEMBERS_USER_SEARCH_PHRASE, {canBeMissing: true}); const [searchValue, setSearchValue] = useState(''); const [didLoadRoomMembers, setDidLoadRoomMembers] = useState(false); const personalDetails = usePersonalDetails(); @@ -68,7 +68,7 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) { // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to use the selection mode only on small screens // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); - const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE); + const [selectionMode] = useOnyx(ONYXKEYS.MOBILE_SELECTION_MODE, {canBeMissing: true}); const canSelectMultiple = isSmallScreenWidth ? selectionMode?.isEnabled : true; useEffect(() => { @@ -103,7 +103,7 @@ function RoomMembersPage({report, policies}: RoomMembersPageProps) { return; } setSearchValue(''); - Navigation.navigate(ROUTES.ROOM_INVITE.getRoute(report.reportID, undefined, backTo)); + Navigation.navigate(ROUTES.ROOM_INVITE.getRoute(report.reportID, backTo)); }, [report, setSearchValue, backTo]); /** diff --git a/src/pages/iou/request/MoneyRequestAccountantSelector.tsx b/src/pages/iou/request/MoneyRequestAccountantSelector.tsx new file mode 100644 index 000000000000..64003563574e --- /dev/null +++ b/src/pages/iou/request/MoneyRequestAccountantSelector.tsx @@ -0,0 +1,210 @@ +import lodashPick from 'lodash/pick'; +import React, {memo, useCallback, useEffect, useMemo} from 'react'; +import type {GestureResponderEvent} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import EmptySelectionListContent from '@components/EmptySelectionListContent'; +import {usePersonalDetails} from '@components/OnyxProvider'; +import {useOptionsList} from '@components/OptionListContextProvider'; +import SelectionList from '@components/SelectionList'; +import InviteMemberListItem from '@components/SelectionList/InviteMemberListItem'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useScreenWrapperTranstionStatus from '@hooks/useScreenWrapperTransitionStatus'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import type {Section} from '@libs/OptionsListUtils'; +import { + filterAndOrderOptions, + formatSectionsFromSearchTerm, + getEmptyOptions, + getHeaderMessage, + getParticipantsOption, + getPolicyExpenseReportOption, + getValidOptions, + isCurrentUser, + orderOptions, +} from '@libs/OptionsListUtils'; +import {searchInServer} from '@userActions/Report'; +import type {IOUAction, IOUType} from '@src/CONST'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Accountant} from '@src/types/onyx/IOU'; + +type MoneyRequestAccountantSelectorProps = { + /** Callback to request parent modal to go to next step */ + onFinish: (value?: string) => void; + + /** Callback to set accountant in MoneyRequestModal */ + onAccountantSelected: (value: Accountant) => void; + + /** The type of IOU report, i.e. split, request, send, track */ + iouType: IOUType; + + /** The action of the IOU, i.e. create, split, move */ + action: IOUAction; +}; + +function MoneyRequestAccountantSelector({onFinish, onAccountantSelected, iouType, action}: MoneyRequestAccountantSelectorProps) { + const {translate} = useLocalize(); + const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); + const {isOffline} = useNetwork(); + const personalDetails = usePersonalDetails(); + const {didScreenTransitionEnd} = useScreenWrapperTranstionStatus(); + const [betas] = useOnyx(ONYXKEYS.BETAS, {canBeMissing: false}); + const [isSearchingForReports] = useOnyx(ONYXKEYS.IS_SEARCHING_FOR_REPORTS, {initWithStoredValues: false, canBeMissing: true}); + const {options, areOptionsInitialized} = useOptionsList({ + shouldInitialize: didScreenTransitionEnd, + }); + const offlineMessage: string = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''; + + useEffect(() => { + searchInServer(debouncedSearchTerm.trim()); + }, [debouncedSearchTerm]); + + const defaultOptions = useMemo(() => { + if (!areOptionsInitialized || !didScreenTransitionEnd) { + getEmptyOptions(); + } + + const optionList = getValidOptions( + { + reports: options.reports, + personalDetails: options.personalDetails, + }, + { + betas, + excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, + action, + }, + ); + + const orderedOptions = orderOptions(optionList); + + return { + ...optionList, + ...orderedOptions, + }; + }, [action, areOptionsInitialized, betas, didScreenTransitionEnd, options.personalDetails, options.reports]); + + const chatOptions = useMemo(() => { + if (!areOptionsInitialized) { + return { + userToInvite: null, + recentReports: [], + personalDetails: [], + currentUserOption: null, + headerMessage: '', + }; + } + const newOptions = filterAndOrderOptions(defaultOptions, debouncedSearchTerm, { + excludeLogins: CONST.EXPENSIFY_EMAILS_OBJECT, + maxRecentReportsToShow: CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW, + shouldAcceptName: true, + }); + return newOptions; + }, [areOptionsInitialized, defaultOptions, debouncedSearchTerm]); + + /** + * Returns the sections needed for the OptionsSelector + */ + const [sections, header] = useMemo(() => { + const newSections: Section[] = []; + if (!areOptionsInitialized || !didScreenTransitionEnd) { + return [newSections, '']; + } + const fiveRecents = [...chatOptions.recentReports].slice(0, 5); + const restOfRecents = [...chatOptions.recentReports].slice(5); + const contactsWithRestOfRecents = [...restOfRecents, ...chatOptions.personalDetails]; + + const formatResults = formatSectionsFromSearchTerm(debouncedSearchTerm, [], chatOptions.recentReports, chatOptions.personalDetails, personalDetails, true); + newSections.push(formatResults.section); + + newSections.push({ + title: translate('common.recents'), + data: fiveRecents, + shouldShow: fiveRecents.length > 0, + }); + + newSections.push({ + title: translate('common.contacts'), + data: contactsWithRestOfRecents, + shouldShow: contactsWithRestOfRecents.length > 0, + }); + + if ( + chatOptions.userToInvite && + !isCurrentUser({...chatOptions.userToInvite, accountID: chatOptions.userToInvite?.accountID ?? CONST.DEFAULT_NUMBER_ID, status: chatOptions.userToInvite?.status ?? undefined}) + ) { + newSections.push({ + title: undefined, + data: [chatOptions.userToInvite].map((participant) => { + const isPolicyExpenseChat = participant?.isPolicyExpenseChat ?? false; + return isPolicyExpenseChat ? getPolicyExpenseReportOption(participant) : getParticipantsOption(participant, personalDetails); + }), + shouldShow: true, + }); + } + + const headerMessage = getHeaderMessage( + (chatOptions.personalDetails ?? []).length + (chatOptions.recentReports ?? []).length !== 0, + !!chatOptions?.userToInvite, + debouncedSearchTerm.trim(), + ); + + return [newSections, headerMessage]; + }, [areOptionsInitialized, didScreenTransitionEnd, debouncedSearchTerm, chatOptions.recentReports, chatOptions.personalDetails, chatOptions.userToInvite, personalDetails, translate]); + + const selectAccountant = useCallback( + (option: Accountant) => { + onAccountantSelected(lodashPick(option, 'accountID', 'login')); + onFinish(); + }, + [onAccountantSelected, onFinish], + ); + + const handleConfirmSelection = useCallback( + (keyEvent?: GestureResponderEvent | KeyboardEvent, option?: Accountant) => { + if (!option) { + return; + } + + selectAccountant(option); + }, + [selectAccountant], + ); + + const showLoadingPlaceholder = useMemo(() => !areOptionsInitialized || !didScreenTransitionEnd, [areOptionsInitialized, didScreenTransitionEnd]); + + const optionLength = useMemo(() => { + if (!areOptionsInitialized) { + return 0; + } + return sections.reduce((acc, section) => acc + section.data.length, 0); + }, [areOptionsInitialized, sections]); + + const shouldShowListEmptyContent = useMemo(() => optionLength === 0 && !showLoadingPlaceholder, [optionLength, showLoadingPlaceholder]); + + return ( + } + headerMessage={header} + showLoadingPlaceholder={showLoadingPlaceholder} + isLoadingNewOptions={!!isSearchingForReports} + shouldShowListEmptyContent={shouldShowListEmptyContent} + /> + ); +} + +MoneyRequestAccountantSelector.displayName = 'MoneyRequestAccountantSelector'; + +export default memo(MoneyRequestAccountantSelector, (prevProps, nextProps) => prevProps.iouType === nextProps.iouType); diff --git a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx index 7f6d7ff6fd45..cbd836965603 100644 --- a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx +++ b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx @@ -130,6 +130,9 @@ function MoneyRequestParticipantsSelector({ // sees the option to submit an expense from their admin on their own Workspace Chat. includeOwnedWorkspaceChats: iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.CREATE || iouType === CONST.IOU.TYPE.SPLIT, + // Sharing with an accountant involves inviting them to the workspace and that requires admin access. + excludeNonAdminWorkspaces: action === CONST.IOU.ACTION.SHARE, + includeP2P: !isCategorizeOrShareAction, includeInvoiceRooms: iouType === CONST.IOU.TYPE.INVOICE, action, diff --git a/src/pages/iou/request/step/IOURequestStepAccountant.tsx b/src/pages/iou/request/step/IOURequestStepAccountant.tsx new file mode 100644 index 000000000000..2f1c55269be7 --- /dev/null +++ b/src/pages/iou/request/step/IOURequestStepAccountant.tsx @@ -0,0 +1,68 @@ +import React, {useCallback} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import useLocalize from '@hooks/useLocalize'; +import {setMoneyRequestAccountant} from '@libs/actions/IOU'; +import Navigation from '@libs/Navigation/Navigation'; +import {hasActiveAdminWorkspaces as hasActiveAdminWorkspacesUtil} from '@libs/PolicyUtils'; +import {createDraftWorkspaceAndNavigateToConfirmationScreen} from '@libs/ReportUtils'; +import MoneyRequestAccountantSelector from '@pages/iou/request/MoneyRequestAccountantSelector'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {Accountant} from '@src/types/onyx/IOU'; +import StepScreenWrapper from './StepScreenWrapper'; +import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; +import withWritableReportOrNotFound from './withWritableReportOrNotFound'; + +type IOURequestStepAccountantProps = WithWritableReportOrNotFoundProps; + +function IOURequestStepAccountant({ + route: { + params: {transactionID, reportID, iouType, backTo, action}, + }, +}: IOURequestStepAccountantProps) { + const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.email, canBeMissing: false}); + const {translate} = useLocalize(); + + const setAccountant = useCallback( + (accountant: Accountant) => { + setMoneyRequestAccountant(transactionID, accountant, true); + }, + [transactionID], + ); + + const navigateToNextStep = useCallback(() => { + // Sharing with an accountant involves inviting them to the workspace and that requires admin access. + const hasActiveAdminWorkspaces = hasActiveAdminWorkspacesUtil(currentUserLogin); + if (!hasActiveAdminWorkspaces) { + createDraftWorkspaceAndNavigateToConfirmationScreen(transactionID, action); + return; + } + + Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID, undefined, action)); + }, [iouType, transactionID, reportID, action, currentUserLogin]); + + const navigateBack = useCallback(() => { + Navigation.goBack(backTo); + }, [backTo]); + + return ( + + + + ); +} + +IOURequestStepAccountant.displayName = 'IOURequestStepAccountant'; + +export default withWritableReportOrNotFound(IOURequestStepAccountant); diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 7fa438cf210c..38eca12ee130 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -518,6 +518,9 @@ function IOURequestStepConfirmation({ linkedTrackedExpenseReportID: transaction.linkedTrackedExpenseReportID, customUnitRateID, }, + accountantParams: { + accountant: transaction.accountant, + }, }); }, [ diff --git a/src/pages/iou/request/step/withWritableReportOrNotFound.tsx b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx index 81a61ae413c7..e522167e261e 100644 --- a/src/pages/iou/request/step/withWritableReportOrNotFound.tsx +++ b/src/pages/iou/request/step/withWritableReportOrNotFound.tsx @@ -43,6 +43,7 @@ type MoneyRequestRouteName = | typeof SCREENS.MONEY_REQUEST.STEP_REPORT | typeof SCREENS.MONEY_REQUEST.STEP_COMPANY_INFO | typeof SCREENS.MONEY_REQUEST.STEP_ATTENDEES + | typeof SCREENS.MONEY_REQUEST.STEP_ACCOUNTANT | typeof SCREENS.MONEY_REQUEST.STEP_UPGRADE | typeof SCREENS.MONEY_REQUEST.STEP_DESTINATION | typeof SCREENS.MONEY_REQUEST.STEP_TIME @@ -58,9 +59,9 @@ export default function , ref: ForwardedRef) { const {route} = props; - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`); - const [isLoadingApp = true] = useOnyx(ONYXKEYS.IS_LOADING_APP); - const [reportDraft] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${route.params.reportID}`); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`, {canBeMissing: true}); + const [isLoadingApp = true] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); + const [reportDraft] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${route.params.reportID}`, {canBeMissing: true}); const iouTypeParamIsInvalid = !Object.values(CONST.IOU.TYPE) .filter((type) => shouldIncludeDeprecatedIOUType || (type !== CONST.IOU.TYPE.REQUEST && type !== CONST.IOU.TYPE.SEND)) diff --git a/src/types/onyx/IOU.ts b/src/types/onyx/IOU.ts index 32d7b37b0729..9d4d960ab6b3 100644 --- a/src/types/onyx/IOU.ts +++ b/src/types/onyx/IOU.ts @@ -202,5 +202,14 @@ type Attendee = { reportID?: string; }; +/** Model of IOU accountant */ +type Accountant = { + /** Account ID */ + accountID?: number; + + /** Account login */ + login?: string; +}; + export default IOU; -export type {Participant, Split, Attendee}; +export type {Participant, Split, Attendee, Accountant}; diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index c2ab18fcfbc1..b8b2fb95b6a2 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -3,7 +3,7 @@ import type {CreateTrackExpenseParams, IOURequestType, ReplaceReceipt, RequestMo import type CONST from '@src/CONST'; import type ONYXKEYS from '@src/ONYXKEYS'; import type CollectionDataSet from '@src/types/utils/CollectionDataSet'; -import type {Attendee, Participant, Split} from './IOU'; +import type {Accountant, Attendee, Participant, Split} from './IOU'; import type * as OnyxCommon from './OnyxCommon'; import type {Unit} from './Policy'; import type RecentWaypoint from './RecentWaypoint'; @@ -376,6 +376,9 @@ type Transaction = OnyxCommon.OnyxValueWithOfflineFeedback< /** The original transaction amount */ amount: number; + /** Selected accountant */ + accountant?: Accountant; + /** The transaction tax amount */ taxAmount?: number; diff --git a/tests/actions/IOUTest.ts b/tests/actions/IOUTest.ts index d17b6cb662c2..55adfdf78c49 100644 --- a/tests/actions/IOUTest.ts +++ b/tests/actions/IOUTest.ts @@ -56,6 +56,7 @@ import DateUtils from '@src/libs/DateUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Policy, Report, ReportNameValuePairs} from '@src/types/onyx'; +import type {Accountant} from '@src/types/onyx/IOU'; import type {Participant, ReportCollectionDataSet} from '@src/types/onyx/Report'; import type {ReportActions, ReportActionsCollectionDataSet} from '@src/types/onyx/ReportAction'; import type ReportAction from '@src/types/onyx/ReportAction'; @@ -359,6 +360,244 @@ describe('actions/IOU', () => { }); }); }); + + it('share with accountant', async () => { + const accountant: Required = {login: VIT_EMAIL, accountID: VIT_ACCOUNT_ID}; + const policy: Policy = {...createRandomPolicy(1), id: 'ABC'}; + const selfDMReport: Report = { + ...createRandomReport(1), + reportID: '10', + chatType: CONST.REPORT.CHAT_TYPE.SELF_DM, + }; + const policyExpenseChat: Report = { + ...createRandomReport(1), + reportID: '123', + policyID: policy.id, + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + isOwnPolicyExpenseChat: true, + }; + const transaction: Transaction = {...createRandomTransaction(1), transactionID: '555'}; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, transaction); + + // Create a tracked expense + trackExpense({ + report: selfDMReport, + isDraftPolicy: true, + action: CONST.IOU.ACTION.CREATE, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {accountID: RORY_ACCOUNT_ID}, + }, + transactionParams: { + amount: transaction.amount, + currency: transaction.currency, + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: transaction.merchant, + billable: false, + }, + }); + await waitForBatchedUpdates(); + + const selfDMReportActionsOnyx = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`, + waitForCollectionCallback: false, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + expect(Object.values(selfDMReportActionsOnyx ?? {}).length).toBe(2); + + const linkedTrackedExpenseReportAction = Object.values(selfDMReportActionsOnyx ?? {}).find((reportAction) => isMoneyRequestAction(reportAction)); + const reportActionableTrackExpense = Object.values(selfDMReportActionsOnyx ?? {}).find((reportAction) => isActionableTrackExpense(reportAction)); + + mockFetch?.pause?.(); + + // Share the tracked expense with an accountant + trackExpense({ + report: policyExpenseChat, + isDraftPolicy: false, + action: CONST.IOU.ACTION.SHARE, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {reportID: policyExpenseChat.reportID, isPolicyExpenseChat: true}, + }, + policyParams: { + policy, + }, + transactionParams: { + amount: transaction.amount, + currency: transaction.currency, + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: transaction.merchant, + billable: false, + actionableWhisperReportActionID: reportActionableTrackExpense?.reportActionID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID: selfDMReport.reportID, + }, + accountantParams: { + accountant, + }, + }); + await waitForBatchedUpdates(); + + const policyExpenseChatOnyx = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, + waitForCollectionCallback: false, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + const policyOnyx = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, + waitForCollectionCallback: false, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + await mockFetch?.resume?.(); + + // Accountant should be invited to the expense report + expect(policyExpenseChatOnyx?.participants?.[accountant.accountID]).toBeTruthy(); + + // Accountant should be added to the workspace as an admin + expect(policyOnyx?.employeeList?.[accountant.login].role).toBe(CONST.POLICY.ROLE.ADMIN); + }); + + it('share with accountant who is already a member', async () => { + const accountant: Required = {login: VIT_EMAIL, accountID: VIT_ACCOUNT_ID}; + const policy: Policy = {...createRandomPolicy(1), id: 'ABC', employeeList: {[accountant.login]: {email: accountant.login, role: CONST.POLICY.ROLE.USER}}}; + const selfDMReport: Report = { + ...createRandomReport(1), + reportID: '10', + chatType: CONST.REPORT.CHAT_TYPE.SELF_DM, + }; + const policyExpenseChat: Report = { + ...createRandomReport(1), + reportID: '123', + policyID: policy.id, + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + isOwnPolicyExpenseChat: true, + participants: {[accountant.accountID]: {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}}, + }; + const transaction: Transaction = {...createRandomTransaction(1), transactionID: '555'}; + + await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, policyExpenseChat); + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, transaction); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[accountant.accountID]: accountant}); + + // Create a tracked expense + trackExpense({ + report: selfDMReport, + isDraftPolicy: true, + action: CONST.IOU.ACTION.CREATE, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {accountID: RORY_ACCOUNT_ID}, + }, + transactionParams: { + amount: transaction.amount, + currency: transaction.currency, + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: transaction.merchant, + billable: false, + }, + }); + await waitForBatchedUpdates(); + + const selfDMReportActionsOnyx = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${selfDMReport.reportID}`, + waitForCollectionCallback: false, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + expect(Object.values(selfDMReportActionsOnyx ?? {}).length).toBe(2); + + const linkedTrackedExpenseReportAction = Object.values(selfDMReportActionsOnyx ?? {}).find((reportAction) => isMoneyRequestAction(reportAction)); + const reportActionableTrackExpense = Object.values(selfDMReportActionsOnyx ?? {}).find((reportAction) => isActionableTrackExpense(reportAction)); + + mockFetch?.pause?.(); + + // Share the tracked expense with an accountant + trackExpense({ + report: policyExpenseChat, + isDraftPolicy: false, + action: CONST.IOU.ACTION.SHARE, + participantParams: { + payeeEmail: RORY_EMAIL, + payeeAccountID: RORY_ACCOUNT_ID, + participant: {reportID: policyExpenseChat.reportID, isPolicyExpenseChat: true}, + }, + policyParams: { + policy, + }, + transactionParams: { + amount: transaction.amount, + currency: transaction.currency, + created: format(new Date(), CONST.DATE.FNS_FORMAT_STRING), + merchant: transaction.merchant, + billable: false, + actionableWhisperReportActionID: reportActionableTrackExpense?.reportActionID, + linkedTrackedExpenseReportAction, + linkedTrackedExpenseReportID: selfDMReport.reportID, + }, + accountantParams: { + accountant, + }, + }); + await waitForBatchedUpdates(); + + const policyExpenseChatOnyx = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.REPORT}${policyExpenseChat.reportID}`, + waitForCollectionCallback: false, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + const policyOnyx = await new Promise>((resolve) => { + const connection = Onyx.connect({ + key: `${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, + waitForCollectionCallback: false, + callback: (value) => { + Onyx.disconnect(connection); + resolve(value); + }, + }); + }); + + await mockFetch?.resume?.(); + + // Accountant should be still a participant in the expense report + expect(policyExpenseChatOnyx?.participants?.[accountant.accountID]).toBeTruthy(); + + // Accountant role should change to admin + expect(policyOnyx?.employeeList?.[accountant.login].role).toBe(CONST.POLICY.ROLE.ADMIN); + }); }); describe('requestMoney', () => { @@ -4714,7 +4953,7 @@ describe('actions/IOU', () => { ])('%s', async (expectedCommand: ApiCommand, action: IOUAction) => { // When a track expense is created trackExpense({ - report: {reportID: ''}, + report: {reportID: '123', policyID: 'A'}, isDraftPolicy: false, action, participantParams: { @@ -4736,6 +4975,7 @@ describe('actions/IOU', () => { }, linkedTrackedExpenseReportID: '1', }, + accountantParams: action === CONST.IOU.ACTION.SHARE ? {accountant: {accountID: VIT_ACCOUNT_ID, login: VIT_EMAIL}} : undefined, }); await waitForBatchedUpdates();