diff --git a/assets/images/document-plus.svg b/assets/images/document-plus.svg new file mode 100644 index 000000000000..cce2e3027cea --- /dev/null +++ b/assets/images/document-plus.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ios/NewApp_AdHoc.mobileprovision.gpg b/ios/NewApp_AdHoc.mobileprovision.gpg index 2c5350cec2aa..c9b8286cf50f 100644 Binary files a/ios/NewApp_AdHoc.mobileprovision.gpg and b/ios/NewApp_AdHoc.mobileprovision.gpg differ diff --git a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg index bae3cd9f3e21..18fbfec9390f 100644 Binary files a/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg and b/ios/NewApp_AdHoc_Notification_Service.mobileprovision.gpg differ diff --git a/src/CONST.ts b/src/CONST.ts index bb191ac5e028..750f0867b653 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -332,6 +332,7 @@ const CONST = { BETA_COMMENT_LINKING: 'commentLinking', VIOLATIONS: 'violations', REPORT_FIELDS: 'reportFields', + TRACK_EXPENSE: 'trackExpense', P2P_DISTANCE_REQUESTS: 'p2pDistanceRequests', WORKFLOWS_DELAYED_SUBMISSION: 'workflowsDelayedSubmission', }, @@ -513,7 +514,7 @@ const CONST = { EUR: 'EUR', }, get DIRECT_REIMBURSEMENT_CURRENCIES() { - return [this.CURRENCY.USD, this.CURRENCY.AUD, this.CURRENCY.CAD, this.CURRENCY.GBP, this.CURRENCY.NZD, this.CURRENCY.EUR]; + return [this.CURRENCY.USD, this.CURRENCY.AUD, this.CURRENCY.CAD, this.CURRENCY.GBP, this.CURRENCY.EUR]; }, EXAMPLE_PHONE_NUMBER: '+15005550006', CONCIERGE_CHAT_NAME: 'Concierge', @@ -1344,6 +1345,7 @@ const CONST = { SEND: 'send', SPLIT: 'split', REQUEST: 'request', + TRACK_EXPENSE: 'track-expense', }, REQUEST_TYPE: { DISTANCE: 'distance', @@ -1358,6 +1360,7 @@ const CONST = { CANCEL: 'cancel', DELETE: 'delete', APPROVE: 'approve', + TRACK: 'track', }, AMOUNT_MAX_LENGTH: 10, RECEIPT_STATE: { @@ -1472,6 +1475,15 @@ const CONST = { MAKE_MEMBER: 'makeMember', MAKE_ADMIN: 'makeAdmin', }, + MORE_FEATURES: { + ARE_CATEGORIES_ENABLED: 'areCategoriesEnabled', + ARE_TAGS_ENABLED: 'areTagsEnabled', + ARE_DISTANCE_RATES_ENABLED: 'areDistanceRatesEnabled', + ARE_WORKFLOWS_ENABLED: 'areWorkflowsEnabled', + ARE_REPORTFIELDS_ENABLED: 'areReportFieldsEnabled', + ARE_CONNECTIONS_ENABLED: 'areConnectionsEnabled', + ARE_TAXES_ENABLED: 'tax', + }, CATEGORIES_BULK_ACTION_TYPES: { DELETE: 'delete', DISABLE: 'disable', @@ -1487,6 +1499,21 @@ const CONST = { DISABLE: 'disable', ENABLE: 'enable', }, + TAX_RATES_BULK_ACTION_TYPES: { + DELETE: 'delete', + DISABLE: 'disable', + ENABLE: 'enable', + }, + COLLECTION_KEYS: { + DESCRIPTION: 'description', + REIMBURSER_EMAIL: 'reimburserEmail', + REIMBURSEMENT_CHOICE: 'reimbursementChoice', + APPROVAL_MODE: 'approvalMode', + AUTOREPORTING: 'autoReporting', + AUTOREPORTING_FREQUENCY: 'autoReportingFrequency', + AUTOREPORTING_OFFSET: 'autoReportingOffset', + GENERAL_SETTINGS: 'generalSettings', + }, }, CUSTOM_UNITS: { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 99973935b20a..d74e691fe10e 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -16,9 +16,6 @@ const ONYXKEYS = { /** Holds the reportID for the report between the user and their account manager */ ACCOUNT_MANAGER_REPORT_ID: 'accountManagerReportID', - /** Boolean flag only true when first set */ - NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER: 'isFirstTimeNewExpensifyUser', - /** Holds an array of client IDs which is used for multi-tabs on web in order to know * which tab is the leader, and which ones are the followers */ ACTIVE_CLIENTS: 'activeClients', @@ -106,27 +103,52 @@ const ONYXKEYS = { STASHED_SESSION: 'stashedSession', BETAS: 'betas', - /** NVP keys + /** NVP keys */ + + /** Boolean flag only true when first set */ + NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER: 'nvp_isFirstTimeNewExpensifyUser', + /** Contains the user preference for the LHN priority mode */ NVP_PRIORITY_MODE: 'nvp_priorityMode', /** Contains the users's block expiration (if they have one) */ - NVP_BLOCKED_FROM_CONCIERGE: 'private_blockedFromConcierge', + NVP_BLOCKED_FROM_CONCIERGE: 'nvp_private_blockedFromConcierge', /** A unique identifier that each user has that's used to send notifications */ - NVP_PRIVATE_PUSH_NOTIFICATION_ID: 'private_pushNotificationID', + NVP_PRIVATE_PUSH_NOTIFICATION_ID: 'nvp_private_pushNotificationID', /** The NVP with the last payment method used per policy */ - NVP_LAST_PAYMENT_METHOD: 'nvp_lastPaymentMethod', + NVP_LAST_PAYMENT_METHOD: 'nvp_private_lastPaymentMethod', /** This NVP holds to most recent waypoints that a person has used when creating a distance request */ NVP_RECENT_WAYPOINTS: 'expensify_recentWaypoints', /** This NVP will be `true` if the user has ever dismissed the engagement modal on either OldDot or NewDot. If it becomes true it should stay true forever. */ - NVP_HAS_DISMISSED_IDLE_PANEL: 'hasDismissedIdlePanel', + NVP_HAS_DISMISSED_IDLE_PANEL: 'nvp_hasDismissedIdlePanel', /** This NVP contains the choice that the user made on the engagement modal */ - NVP_INTRO_SELECTED: 'introSelected', + NVP_INTRO_SELECTED: 'nvp_introSelected', + + /** This NVP contains the active policyID */ + NVP_ACTIVE_POLICY_ID: 'nvp_expensify_activePolicyID', + + /** This NVP contains the referral banners the user dismissed */ + NVP_DISMISSED_REFERRAL_BANNERS: 'nvp_dismissedReferralBanners', + + /** Indicates which locale should be used */ + NVP_PREFERRED_LOCALE: 'nvp_preferredLocale', + + /** Whether the user has tried focus mode yet */ + NVP_TRY_FOCUS_MODE: 'nvp_tryFocusMode', + + /** Whether the user has been shown the hold educational interstitial yet */ + NVP_HOLD_USE_EXPLAINED: 'holdUseExplained', + + /** Store preferred skintone for emoji */ + PREFERRED_EMOJI_SKIN_TONE: 'nvp_expensify_preferredEmojiSkinTone', + + /** Store frequently used emojis for this user */ + FREQUENTLY_USED_EMOJIS: 'nvp_expensify_frequentlyUsedEmojis', /** The NVP with the last distance rate used per policy */ NVP_LAST_SELECTED_DISTANCE_RATES: 'lastSelectedDistanceRates', @@ -153,9 +175,6 @@ const ONYXKEYS = { ONFIDO_TOKEN: 'onfidoToken', ONFIDO_APPLICANT_ID: 'onfidoApplicantID', - /** Indicates which locale should be used */ - NVP_PREFERRED_LOCALE: 'preferredLocale', - /** User's Expensify Wallet */ USER_WALLET: 'userWallet', @@ -177,12 +196,6 @@ const ONYXKEYS = { /** The user's cash card and imported cards (including the Expensify Card) */ CARD_LIST: 'cardList', - /** Whether the user has tried focus mode yet */ - NVP_TRY_FOCUS_MODE: 'tryFocusMode', - - /** Whether the user has been shown the hold educational interstitial yet */ - NVP_HOLD_USE_EXPLAINED: 'holdUseExplained', - /** Boolean flag used to display the focus mode notification */ FOCUS_MODE_NOTIFICATION: 'focusModeNotification', @@ -195,12 +208,6 @@ const ONYXKEYS = { /** Stores information about the active reimbursement account being set up */ REIMBURSEMENT_ACCOUNT: 'reimbursementAccount', - /** Store preferred skintone for emoji */ - PREFERRED_EMOJI_SKIN_TONE: 'preferredEmojiSkinTone', - - /** Store frequently used emojis for this user */ - FREQUENTLY_USED_EMOJIS: 'frequentlyUsedEmojis', - /** Stores Workspace ID that will be tied to reimbursement account during setup */ REIMBURSEMENT_ACCOUNT_WORKSPACE_ID: 'reimbursementAccountWorkspaceID', @@ -294,8 +301,8 @@ const ONYXKEYS = { POLICY_CATEGORIES: 'policyCategories_', POLICY_RECENTLY_USED_CATEGORIES: 'policyRecentlyUsedCategories_', POLICY_TAGS: 'policyTags_', - POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_', - POLICY_REPORT_FIELDS: 'policyReportFields_', + POLICY_RECENTLY_USED_TAGS: 'nvp_recentlyUsedTags_', + OLD_POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_', WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_', WORKSPACE_INVITE_MESSAGE_DRAFT: 'workspaceInviteMessageDraft_', REPORT: 'report_', @@ -420,6 +427,10 @@ const ONYXKEYS = { POLICY_TAG_NAME_FORM_DRAFT: 'policyTagNameFormDraft', WORKSPACE_NEW_TAX_FORM: 'workspaceNewTaxForm', WORKSPACE_NEW_TAX_FORM_DRAFT: 'workspaceNewTaxFormDraft', + WORKSPACE_TAX_NAME_FORM: 'workspaceTaxNameForm', + WORKSPACE_TAX_NAME_FORM_DRAFT: 'workspaceTaxNameFormDraft', + WORKSPACE_TAX_VALUE_FORM: 'workspaceTaxValueForm', + WORKSPACE_TAX_VALUE_FORM_DRAFT: 'workspaceTaxValueFormDraft', }, } as const; @@ -471,6 +482,8 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.POLICY_TAG_NAME_FORM]: FormTypes.PolicyTagNameForm; [ONYXKEYS.FORMS.WORKSPACE_NEW_TAX_FORM]: FormTypes.WorkspaceNewTaxForm; [ONYXKEYS.FORMS.POLICY_CREATE_DISTANCE_RATE_FORM]: FormTypes.PolicyCreateDistanceRateForm; + [ONYXKEYS.FORMS.WORKSPACE_TAX_NAME_FORM]: FormTypes.WorkspaceTaxNameForm; + [ONYXKEYS.FORMS.WORKSPACE_TAX_VALUE_FORM]: FormTypes.WorkspaceTaxValueForm; }; type OnyxFormDraftValuesMapping = { @@ -486,7 +499,6 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.POLICY_MEMBERS]: OnyxTypes.PolicyMembers; [ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS]: OnyxTypes.PolicyMember; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES]: OnyxTypes.RecentlyUsedCategories; - [ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS]: OnyxTypes.PolicyReportFields; [ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST]: OnyxTypes.PolicyMembers; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: OnyxTypes.InvitedEmailsToAccountIDs; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT]: string; @@ -506,6 +518,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS]: OnyxTypes.TransactionViolations; [ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT]: OnyxTypes.Transaction; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS]: OnyxTypes.RecentlyUsedTags; + [ONYXKEYS.COLLECTION.OLD_POLICY_RECENTLY_USED_TAGS]: OnyxTypes.RecentlyUsedTags; [ONYXKEYS.COLLECTION.SELECTED_TAB]: string; [ONYXKEYS.COLLECTION.PRIVATE_NOTES_DRAFT]: string; [ONYXKEYS.COLLECTION.NEXT_STEP]: OnyxTypes.ReportNextStep; @@ -561,6 +574,8 @@ type OnyxValuesMapping = { [ONYXKEYS.ONFIDO_TOKEN]: string; [ONYXKEYS.ONFIDO_APPLICANT_ID]: string; [ONYXKEYS.NVP_PREFERRED_LOCALE]: OnyxTypes.Locale; + [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: string; + [ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS]: OnyxTypes.DismissedReferralBanners; [ONYXKEYS.USER_WALLET]: OnyxTypes.UserWallet; [ONYXKEYS.WALLET_ONFIDO]: OnyxTypes.WalletOnfido; [ONYXKEYS.WALLET_ADDITIONAL_DETAILS]: OnyxTypes.WalletAdditionalDetails; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 831921f33122..9ce32835c8d7 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -616,6 +616,18 @@ const ROUTES = { route: 'settings/workspaces/:policyID/taxes/new', getRoute: (policyID: string) => `settings/workspaces/${policyID}/taxes/new` as const, }, + WORKSPACE_TAX_EDIT: { + route: 'settings/workspaces/:policyID/tax/:taxID', + getRoute: (policyID: string, taxID: string) => `settings/workspaces/${policyID}/tax/${encodeURI(taxID)}` as const, + }, + WORKSPACE_TAX_NAME: { + route: 'settings/workspaces/:policyID/tax/:taxID/name', + getRoute: (policyID: string, taxID: string) => `settings/workspaces/${policyID}/tax/${encodeURI(taxID)}/name` as const, + }, + WORKSPACE_TAX_VALUE: { + route: 'settings/workspaces/:policyID/tax/:taxID/value', + getRoute: (policyID: string, taxID: string) => `settings/workspaces/${policyID}/tax/${encodeURI(taxID)}/value` as const, + }, WORKSPACE_DISTANCE_RATES: { route: 'settings/workspaces/:policyID/distance-rates', getRoute: (policyID: string) => `settings/workspaces/${policyID}/distance-rates` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index cd7bb934247f..4d4e9ea327c6 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -217,6 +217,9 @@ const SCREENS = { TAGS_EDIT: 'Tags_Edit', TAG_EDIT: 'Tag_Edit', TAXES: 'Workspace_Taxes', + TAX_EDIT: 'Workspace_Tax_Edit', + TAX_NAME: 'Workspace_Tax_Name', + TAX_VALUE: 'Workspace_Tax_Value', TAXES_SETTINGS: 'Workspace_Taxes_Settings', TAXES_SETTINGS_CUSTOM_TAX_NAME: 'Workspace_Taxes_Settings_CustomTaxName', TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT: 'Workspace_Taxes_Settings_WorkspaceCurrency', diff --git a/src/components/AmountPicker/index.tsx b/src/components/AmountPicker/index.tsx index 701c75175c02..45e511f24748 100644 --- a/src/components/AmountPicker/index.tsx +++ b/src/components/AmountPicker/index.tsx @@ -25,7 +25,8 @@ function AmountPicker({value, description, title, errorText = '', onInputChange, const updateInput = (updatedValue: string) => { if (updatedValue !== value) { - onInputChange?.(updatedValue); + // We cast the updatedValue to a number and then back to a string to remove any leading zeros and separating commas + onInputChange?.(String(Number(updatedValue))); } hidePickerModal(); }; diff --git a/src/components/AvatarSkeleton.tsx b/src/components/AvatarSkeleton.tsx index a6781448c3ba..273143f76098 100644 --- a/src/components/AvatarSkeleton.tsx +++ b/src/components/AvatarSkeleton.tsx @@ -6,12 +6,12 @@ import SkeletonViewContentLoader from './SkeletonViewContentLoader'; function AvatarSkeleton() { const theme = useTheme(); - const skeletonCircleRadius = variables.componentSizeSmall / 2; + const skeletonCircleRadius = variables.sidebarAvatarSize / 2; return ( diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index 16f31b9c7eba..396c10151fbf 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -60,7 +60,7 @@ function AvatarWithDisplayName({ const title = ReportUtils.getReportName(report); const subtitle = ReportUtils.getChatRoomSubtitle(report); const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(report); - const isMoneyRequestOrReport = ReportUtils.isMoneyRequestReport(report) || ReportUtils.isMoneyRequest(report); + const isMoneyRequestOrReport = ReportUtils.isMoneyRequestReport(report) || ReportUtils.isMoneyRequest(report) || ReportUtils.isTrackExpenseReport(report); const icons = ReportUtils.getIcons(report, personalDetails, null, '', -1, policy); const ownerPersonalDetails = OptionsListUtils.getPersonalDetailsForAccountIDs(report?.ownerAccountID ? [report.ownerAccountID] : [], personalDetails); const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(Object.values(ownerPersonalDetails) as PersonalDetails[], false); diff --git a/src/components/ButtonWithDropdownMenu/types.ts b/src/components/ButtonWithDropdownMenu/types.ts index 798369292958..83100788761f 100644 --- a/src/components/ButtonWithDropdownMenu/types.ts +++ b/src/components/ButtonWithDropdownMenu/types.ts @@ -12,6 +12,8 @@ type WorkspaceMemberBulkActionType = DeepValueOf; +type WorkspaceTaxRatesBulkActionType = DeepValueOf; + type DropdownOption = { value: TValueType; text: string; @@ -73,4 +75,4 @@ type ButtonWithDropdownMenuProps = { wrapperStyle?: StyleProp; }; -export type {PaymentType, WorkspaceMemberBulkActionType, WorkspaceDistanceRatesBulkActionType, DropdownOption, ButtonWithDropdownMenuProps}; +export type {PaymentType, WorkspaceMemberBulkActionType, WorkspaceDistanceRatesBulkActionType, DropdownOption, ButtonWithDropdownMenuProps, WorkspaceTaxRatesBulkActionType}; diff --git a/src/components/CheckboxWithLabel.tsx b/src/components/CheckboxWithLabel.tsx index 2919debe9cb1..dd169576186e 100644 --- a/src/components/CheckboxWithLabel.tsx +++ b/src/components/CheckboxWithLabel.tsx @@ -108,3 +108,5 @@ function CheckboxWithLabel( CheckboxWithLabel.displayName = 'CheckboxWithLabel'; export default React.forwardRef(CheckboxWithLabel); + +export type {CheckboxWithLabelProps}; diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 73a091815460..7116ba2aab67 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -42,6 +42,7 @@ import Concierge from '@assets/images/concierge.svg'; import Connect from '@assets/images/connect.svg'; import Copy from '@assets/images/copy.svg'; import CreditCard from '@assets/images/creditcard.svg'; +import DocumentPlus from '@assets/images/document-plus.svg'; import DocumentSlash from '@assets/images/document-slash.svg'; import Document from '@assets/images/document.svg'; import DotIndicatorUnfilled from '@assets/images/dot-indicator-unfilled.svg'; @@ -314,4 +315,5 @@ export { ChatBubbleUnread, ChatBubbleReply, Lightbulb, + DocumentPlus, }; diff --git a/src/components/MapView/responder/index.android.ts b/src/components/MapView/responder/index.android.ts deleted file mode 100644 index a0fce71d8ef5..000000000000 --- a/src/components/MapView/responder/index.android.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {PanResponder} from 'react-native'; - -const responder = PanResponder.create({ - onStartShouldSetPanResponder: () => true, - onPanResponderTerminationRequest: () => false, -}); - -export default responder; diff --git a/src/components/MoneyRequestHeader.tsx b/src/components/MoneyRequestHeader.tsx index e70e121569fd..5d3231ca0a41 100644 --- a/src/components/MoneyRequestHeader.tsx +++ b/src/components/MoneyRequestHeader.tsx @@ -71,11 +71,15 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction, const deleteTransaction = useCallback(() => { if (parentReportAction) { const iouTransactionID = parentReportAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? parentReportAction.originalMessage?.IOUTransactionID ?? '' : ''; + if (ReportActionsUtils.isTrackExpenseAction(parentReportAction)) { + IOU.deleteTrackExpense(parentReport?.reportID ?? '', iouTransactionID, parentReportAction, true); + return; + } IOU.deleteMoneyRequest(iouTransactionID, parentReportAction, true); } setIsDeleteModalVisible(false); - }, [parentReportAction, setIsDeleteModalVisible]); + }, [parentReport?.reportID, parentReportAction, setIsDeleteModalVisible]); const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); const isPending = TransactionUtils.isExpensifyCardTransaction(transaction) && TransactionUtils.isPending(transaction); @@ -84,7 +88,7 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction, const canHoldOrUnholdRequest = !isSettled && !isApproved && !isDeletedParentAction; // If the report supports adding transactions to it, then it also supports deleting transactions from it. - const canDeleteRequest = isActionOwner && ReportUtils.canAddOrDeleteTransactions(moneyRequestReport) && !isDeletedParentAction; + const canDeleteRequest = isActionOwner && (ReportUtils.canAddOrDeleteTransactions(moneyRequestReport) || ReportUtils.isTrackExpenseReport(report)) && !isDeletedParentAction; const changeMoneyRequestStatus = () => { const iouTransactionID = parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? parentReportAction.originalMessage?.IOUTransactionID ?? '' : ''; @@ -109,7 +113,8 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction, if (canHoldOrUnholdRequest) { const isRequestIOU = parentReport?.type === 'iou'; const isHoldCreator = ReportUtils.isHoldCreator(transaction, report?.reportID) && isRequestIOU; - const canModifyStatus = isPolicyAdmin || isActionOwner || isApprover; + const isTrackExpenseReport = ReportUtils.isTrackExpenseReport(report); + const canModifyStatus = !isTrackExpenseReport && (isPolicyAdmin || isActionOwner || isApprover); if (isOnHold && (isHoldCreator || (!isRequestIOU && canModifyStatus))) { threeDotsMenuItems.push({ icon: Expensicons.Stopwatch, diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js index 0d1acc31ecdf..138bfc937926 100755 --- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js +++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js @@ -251,6 +251,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ const isTypeRequest = iouType === CONST.IOU.TYPE.REQUEST; const isTypeSplit = iouType === CONST.IOU.TYPE.SPLIT; const isTypeSend = iouType === CONST.IOU.TYPE.SEND; + const isTypeTrackExpense = iouType === CONST.IOU.TYPE.TRACK_EXPENSE; const canEditDistance = isTypeRequest || (canUseP2PDistanceRequests && isTypeSplit); const {unit, rate, currency} = mileageRate; @@ -381,7 +382,9 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ const splitOrRequestOptions = useMemo(() => { let text; - if (isTypeSplit && iouAmount === 0) { + if (isTypeTrackExpense) { + text = translate('iou.trackExpense'); + } else if (isTypeSplit && iouAmount === 0) { text = translate('iou.split'); } else if ((receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute) { text = translate('iou.request'); @@ -398,7 +401,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ value: iouType, }, ]; - }, [isTypeSplit, isTypeRequest, iouType, iouAmount, receiptPath, formattedAmount, isDistanceRequestWithPendingRoute, translate]); + }, [isTypeTrackExpense, isTypeSplit, iouAmount, receiptPath, isTypeRequest, isDistanceRequestWithPendingRoute, iouType, translate, formattedAmount]); const selectedParticipants = useMemo(() => _.filter(pickedParticipants, (participant) => participant.selected), [pickedParticipants]); const personalDetailsOfPayee = useMemo(() => payeePersonalDetails || currentUserPersonalDetails, [payeePersonalDetails, currentUserPersonalDetails]); @@ -446,7 +449,7 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ } else { const formattedSelectedParticipants = _.map(selectedParticipants, (participant) => ({ ...participant, - isDisabled: !participant.isPolicyExpenseChat && ReportUtils.isOptimisticPersonalDetail(participant.accountID), + isDisabled: !participant.isPolicyExpenseChat && !participant.isSelfDM && ReportUtils.isOptimisticPersonalDetail(participant.accountID), })); sections.push({ title: translate('common.to'), @@ -538,6 +541,11 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ const navigateToReportOrUserDetail = (option) => { const activeRoute = Navigation.getActiveRouteWithoutParams(); + if (option.isSelfDM) { + Navigation.navigate(ROUTES.PROFILE.getRoute(currentUserPersonalDetails.accountID, activeRoute)); + return; + } + if (option.accountID) { Navigation.navigate(ROUTES.PROFILE.getRoute(option.accountID, activeRoute)); } else if (option.reportID) { diff --git a/src/components/OptionRow.tsx b/src/components/OptionRow.tsx index 7b45fd963fe7..c72cdd1fd898 100644 --- a/src/components/OptionRow.tsx +++ b/src/components/OptionRow.tsx @@ -229,7 +229,12 @@ function OptionRow({ numberOfLines={isMultilineSupported ? 2 : 1} textStyles={displayNameStyle} shouldUseFullTitle={ - !!option.isChatRoom || !!option.isPolicyExpenseChat || !!option.isMoneyRequestReport || !!option.isThread || !!option.isTaskReport + !!option.isChatRoom || + !!option.isPolicyExpenseChat || + !!option.isMoneyRequestReport || + !!option.isThread || + !!option.isTaskReport || + !!option.isSelfDM } /> {option.alternateText ? ( @@ -340,3 +345,5 @@ export default React.memo( prevProps.option.pendingAction === nextProps.option.pendingAction && prevProps.option.customIcon === nextProps.option.customIcon, ); + +export type {OptionRowProps}; diff --git a/src/components/PopoverMenu.tsx b/src/components/PopoverMenu.tsx index 4ee070e19893..44a446b56653 100644 --- a/src/components/PopoverMenu.tsx +++ b/src/components/PopoverMenu.tsx @@ -215,4 +215,4 @@ function PopoverMenu({ PopoverMenu.displayName = 'PopoverMenu'; export default React.memo(PopoverMenu); -export type {PopoverMenuItem}; +export type {PopoverMenuItem, PopoverMenuProps}; diff --git a/src/components/ReferralProgramCTA.tsx b/src/components/ReferralProgramCTA.tsx index 6db37ce1320a..c93b75bf11ad 100644 --- a/src/components/ReferralProgramCTA.tsx +++ b/src/components/ReferralProgramCTA.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; @@ -8,7 +9,7 @@ import CONST from '@src/CONST'; import Navigation from '@src/libs/Navigation/Navigation'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {DismissedReferralBanners} from '@src/types/onyx/Account'; +import type * as OnyxTypes from '@src/types/onyx'; import Icon from './Icon'; import {Close} from './Icon/Expensicons'; import {PressableWithoutFeedback} from './Pressable'; @@ -16,7 +17,7 @@ import Text from './Text'; import Tooltip from './Tooltip'; type ReferralProgramCTAOnyxProps = { - dismissedReferralBanners: DismissedReferralBanners; + dismissedReferralBanners: OnyxEntry; }; type ReferralProgramCTAProps = ReferralProgramCTAOnyxProps & { @@ -36,7 +37,7 @@ function ReferralProgramCTA({referralContentType, dismissedReferralBanners}: Ref User.dismissReferralBanner(referralContentType); }; - if (!referralContentType || dismissedReferralBanners[referralContentType]) { + if (!referralContentType || dismissedReferralBanners?.[referralContentType]) { return null; } @@ -82,7 +83,6 @@ function ReferralProgramCTA({referralContentType, dismissedReferralBanners}: Ref export default withOnyx({ dismissedReferralBanners: { - key: ONYXKEYS.ACCOUNT, - selector: (data) => data?.dismissedReferralBanners ?? {}, + key: ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS, }, })(ReferralProgramCTA); diff --git a/src/components/ReportActionItem/MoneyReportView.tsx b/src/components/ReportActionItem/MoneyReportView.tsx index 60dbfc07966a..e9b0ce3dae3f 100644 --- a/src/components/ReportActionItem/MoneyReportView.tsx +++ b/src/components/ReportActionItem/MoneyReportView.tsx @@ -29,14 +29,11 @@ type MoneyReportViewProps = { /** Policy that the report belongs to */ policy: OnyxEntry; - /** Policy report fields */ - policyReportFields: PolicyReportField[]; - /** Whether we should display the horizontal rule below the component */ shouldShowHorizontalRule: boolean; }; -function MoneyReportView({report, policy, policyReportFields, shouldShowHorizontalRule}: MoneyReportViewProps) { +function MoneyReportView({report, policy, shouldShowHorizontalRule}: MoneyReportViewProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -60,9 +57,9 @@ function MoneyReportView({report, policy, policyReportFields, shouldShowHorizont ]; const sortedPolicyReportFields = useMemo((): PolicyReportField[] => { - const fields = ReportUtils.getAvailableReportFields(report, policyReportFields); + const fields = ReportUtils.getAvailableReportFields(report, Object.values(policy?.fieldList ?? {})); return fields.sort(({orderWeight: firstOrderWeight}, {orderWeight: secondOrderWeight}) => firstOrderWeight - secondOrderWeight); - }, [policyReportFields, report]); + }, [policy, report]); return ( @@ -75,13 +72,14 @@ function MoneyReportView({report, policy, policyReportFields, shouldShowHorizont const isTitleField = ReportUtils.isReportFieldOfTypeTitle(reportField); const fieldValue = isTitleField ? report.reportName : reportField.value ?? reportField.defaultValue; const isFieldDisabled = ReportUtils.isReportFieldDisabled(report, reportField, policy); + const fieldKey = ReportUtils.getReportFieldKey(reportField.fieldID); return ( { if (isSplitBillAction) { @@ -108,14 +110,24 @@ function MoneyRequestAction({ shouldShowPendingConversionMessage = IOUUtils.isIOUReportPendingCurrencyConversion(iouReport); } - return isDeletedParentAction || isReversedTransaction ? ( - ${translate(isReversedTransaction ? 'parentReportAction.reversedTransaction' : 'parentReportAction.deletedRequest')}`} /> - ) : ( + if (isDeletedParentAction || isReversedTransaction) { + let message: TranslationPaths; + if (isReversedTransaction) { + message = 'parentReportAction.reversedTransaction'; + } else if (isTrackExpenseAction) { + message = 'parentReportAction.deletedExpense'; + } else { + message = 'parentReportAction.deletedRequest'; + } + return ${translate(message)}`} />; + } + return ( ; + return lodashIsEmpty(props.iouReport) && !(props.isBillSplit || props.isTrackExpense) ? null : ; } MoneyRequestPreview.displayName = 'MoneyRequestPreview'; diff --git a/src/components/ReportActionItem/MoneyRequestPreview/types.ts b/src/components/ReportActionItem/MoneyRequestPreview/types.ts index 17dd42b2f794..3b3eda4ec30a 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/types.ts +++ b/src/components/ReportActionItem/MoneyRequestPreview/types.ts @@ -56,6 +56,9 @@ type MoneyRequestPreviewProps = MoneyRequestPreviewOnyxProps & { /** True if this is this IOU is a split instead of a 1:1 request */ isBillSplit: boolean; + /** Whether this IOU is a track expense */ + isTrackExpense: boolean; + /** True if the IOU Preview card is hovered */ isHovered?: boolean; diff --git a/src/components/ReportWelcomeText.tsx b/src/components/ReportWelcomeText.tsx index e9bbd0f27bdc..219199c25bc3 100644 --- a/src/components/ReportWelcomeText.tsx +++ b/src/components/ReportWelcomeText.tsx @@ -3,6 +3,7 @@ import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; @@ -33,6 +34,7 @@ type ReportWelcomeTextProps = ReportWelcomeTextOnyxProps & { function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); + const {canUseTrackExpense} = usePermissions(); const isPolicyExpenseChat = ReportUtils.isPolicyExpenseChat(report); const isChatRoom = ReportUtils.isChatRoom(report); const isSelfDM = ReportUtils.isSelfDM(report); @@ -42,7 +44,7 @@ function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextP const displayNamesWithTooltips = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs(participantAccountIDs, personalDetails), isMultipleParticipant); const isUserPolicyAdmin = PolicyUtils.isPolicyAdmin(policy); const roomWelcomeMessage = ReportUtils.getRoomWelcomeMessage(report, isUserPolicyAdmin); - const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, policy, participantAccountIDs); + const moneyRequestOptions = ReportUtils.getMoneyRequestOptions(report, policy, participantAccountIDs, canUseTrackExpense); const additionalText = moneyRequestOptions.map((item) => translate(`reportActionsView.iouTypes.${item}`)).join(', '); const canEditPolicyDescription = ReportUtils.canEditPolicyDescription(policy); const reportName = ReportUtils.getReportName(report); @@ -158,9 +160,9 @@ function ReportWelcomeText({report, policy, personalDetails}: ReportWelcomeTextP ))} )} - {(moneyRequestOptions.includes(CONST.IOU.TYPE.SEND) || moneyRequestOptions.includes(CONST.IOU.TYPE.REQUEST)) && ( - {translate('reportActionsView.usePlusButton', {additionalText})} - )} + {(moneyRequestOptions.includes(CONST.IOU.TYPE.SEND) || + moneyRequestOptions.includes(CONST.IOU.TYPE.REQUEST) || + moneyRequestOptions.includes(CONST.IOU.TYPE.TRACK_EXPENSE)) && {translate('reportActionsView.usePlusButton', {additionalText})}} ); diff --git a/src/components/SelectionList/BaseListItem.tsx b/src/components/SelectionList/BaseListItem.tsx index 4c1f208ce11d..596951374099 100644 --- a/src/components/SelectionList/BaseListItem.tsx +++ b/src/components/SelectionList/BaseListItem.tsx @@ -85,7 +85,7 @@ function BaseListItem({ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing disabled={isDisabled || item.isDisabledCheckbox} onPress={handleCheckboxPress} - style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled]} + style={[styles.cursorUnset, StyleUtils.getCheckboxPressableStyle(), item.isDisabledCheckbox && styles.cursorDisabled, styles.mr3]} > {item.isSelected && ( diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index ac48b0fa08a9..015fd284c0b4 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -9,6 +9,7 @@ import Button from '@components/Button'; import Checkbox from '@components/Checkbox'; import FixedFooter from '@components/FixedFooter'; import OptionsListSkeletonView from '@components/OptionsListSkeletonView'; +import {PressableWithFeedback} from '@components/Pressable'; import SafeAreaConsumer from '@components/SafeAreaConsumer'; import SectionList from '@components/SectionList'; import ShowMoreButton from '@components/ShowMoreButton'; @@ -454,11 +455,22 @@ function BaseSelectionList( }); /** Calls confirm action when pressing CTRL (CMD) + Enter */ - useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, onConfirm ?? selectFocusedOption, { - captureOnInputs: true, - shouldBubble: !flattenedSections.allOptions[focusedIndex], - isActive: !disableKeyboardShortcuts && isFocused, - }); + useKeyboardShortcut( + CONST.KEYBOARD_SHORTCUTS.CTRL_ENTER, + (e) => { + const focusedOption = flattenedSections.allOptions[focusedIndex]; + if (onConfirm) { + onConfirm(e, focusedOption); + return; + } + selectFocusedOption(); + }, + { + captureOnInputs: true, + shouldBubble: !flattenedSections.allOptions[focusedIndex], + isActive: !disableKeyboardShortcuts && isFocused, + }, + ); return ( ( ) : ( <> {!headerMessage && canSelectMultiple && shouldShowSelectAll && ( - - - {customListHeader ?? ( - - {translate('workspace.people.selectAll')} - - )} + + + + {!customListHeader && ( + e.preventDefault() : undefined} + > + {translate('workspace.people.selectAll')} + + )} + + {customListHeader} )} {!headerMessage && !canSelectMultiple && customListHeader} diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 9e9ba7e5fc27..fac78ee786a0 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -227,7 +227,7 @@ type BaseSelectionListProps = Partial & { confirmButtonText?: string; /** Callback to fire when the confirm button is pressed */ - onConfirm?: (e?: GestureResponderEvent | KeyboardEvent | undefined) => void; + onConfirm?: (e?: GestureResponderEvent | KeyboardEvent | undefined, option?: TItem) => void; /** Whether to show the vertical scroll indicator */ showScrollIndicator?: boolean; diff --git a/src/components/SwipeInterceptPanResponder.tsx b/src/components/SwipeInterceptPanResponder.tsx index fe1545d2f14b..6a3d14b3b24b 100644 --- a/src/components/SwipeInterceptPanResponder.tsx +++ b/src/components/SwipeInterceptPanResponder.tsx @@ -1,6 +1,7 @@ import {PanResponder} from 'react-native'; const SwipeInterceptPanResponder = PanResponder.create({ + onStartShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: () => true, onPanResponderTerminationRequest: () => false, }); diff --git a/src/languages/en.ts b/src/languages/en.ts index cd2d92e25b22..aef5df187a69 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -462,9 +462,14 @@ export default { copyEmailToClipboard: 'Copy email to clipboard', markAsUnread: 'Mark as unread', markAsRead: 'Mark as read', - editAction: ({action}: EditActionParams) => `Edit ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'request' : 'comment'}`, - deleteAction: ({action}: DeleteActionParams) => `Delete ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'request' : 'comment'}`, - deleteConfirmation: ({action}: DeleteConfirmationParams) => `Are you sure you want to delete this ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'request' : 'comment'}?`, + editAction: ({action}: EditActionParams) => + `Edit ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? `${action?.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK ? 'expense' : 'request'}` : 'comment'}`, + deleteAction: ({action}: DeleteActionParams) => + `Delete ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? `${action?.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK ? 'expense' : 'request'}` : 'comment'}`, + deleteConfirmation: ({action}: DeleteConfirmationParams) => + `Are you sure you want to delete this ${ + action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? `${action?.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK ? 'expense' : 'request'}` : 'comment' + }?`, onlyVisible: 'Only visible to', replyInThread: 'Reply in thread', joinThread: 'Join thread', @@ -503,6 +508,8 @@ export default { send: 'send money', split: 'split a bill', request: 'request money', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'track-expense': 'track an expense', }, }, reportAction: { @@ -593,6 +600,7 @@ export default { participants: 'Participants', requestMoney: 'Request money', sendMoney: 'Send money', + trackExpense: 'Track expense', pay: 'Pay', cancelPayment: 'Cancel payment', cancelPaymentConfirmation: 'Are you sure that you want to cancel this payment?', @@ -624,6 +632,7 @@ export default { finished: 'Finished', requestAmount: ({amount}: RequestAmountParams) => `request ${amount}`, requestedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `requested ${formattedAmount}${comment ? ` for ${comment}` : ''}`, + trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `tracking ${formattedAmount}${comment ? ` for ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `split ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `split ${formattedAmount}${comment ? ` for ${comment}` : ''}`, amountEach: ({amount}: AmountEachParams) => `${amount} each`, @@ -655,6 +664,7 @@ export default { updatedTheDistance: ({newDistanceToDisplay, oldDistanceToDisplay, newAmountToDisplay, oldAmountToDisplay}: UpdatedTheDistanceParams) => `changed the distance to ${newDistanceToDisplay} (previously ${oldDistanceToDisplay}), which updated the amount to ${newAmountToDisplay} (previously ${oldAmountToDisplay})`, threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `${formattedAmount} ${comment ? `for ${comment}` : 'request'}`, + threadTrackReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `Tracking ${formattedAmount} ${comment ? `for ${comment}` : ''}`, threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} sent${comment ? ` for ${comment}` : ''}`, tagSelection: 'Select a tag to better organize your spend.', categorySelection: 'Select a category to better organize your spend.', @@ -1071,6 +1081,14 @@ export default { }, }, }, + workflowsDelayedSubmissionPage: { + autoReportingErrorMessage: 'The delayed submission parameter could not be changed. Please try again or contact support.', + autoReportingFrequencyErrorMessage: 'The submission frequency could not be changed. Please try again or contact support.', + monthlyOffsetErrorMessage: 'The monthly frequency could not be changed. Please try again or contact support.', + }, + workflowsApprovalPage: { + genericErrorMessage: 'The approver could not be changed. Please try again or contact support.', + }, workflowsPayerPage: { title: 'Authorized payer', genericErrorMessage: 'The authorized payer could not be changed. Please try again.', @@ -1876,7 +1894,19 @@ export default { errors: { taxRateAlreadyExists: 'This tax name is already in use.', valuePercentageRange: 'Please enter a valid percentage between 0 and 100.', - genericFailureMessage: 'An error occurred while updating the tax rate, please try again.', + deleteFailureMessage: 'An error occurred while deleting the tax rate. Please try again or ask Concierge for help.', + updateFailureMessage: 'An error occurred while updating the tax rate. Please try again or ask Concierge for help.', + createFailureMessage: 'An error occurred while creating the tax rate. Please try again or ask Concierge for help.', + }, + deleteTaxConfirmation: 'Are you sure you want to delete this tax?', + deleteMultipleTaxConfirmation: ({taxAmount}) => `Are you sure you want to delete ${taxAmount} taxes?`, + actions: { + delete: 'Delete rate', + deleteMultiple: 'Delete rates', + disable: 'Disable rate', + disableMultiple: 'Disable rates', + enable: 'Enable rate', + enableMultiple: 'Enable rates', }, }, emptyWorkspace: { @@ -2315,6 +2345,7 @@ export default { deletedReport: '[Deleted report]', deletedMessage: '[Deleted message]', deletedRequest: '[Deleted request]', + deletedExpense: '[Deleted expense]', reversedTransaction: '[Reversed transaction]', deletedTask: '[Deleted task]', hiddenMessage: '[Hidden message]', diff --git a/src/languages/es.ts b/src/languages/es.ts index 3a50e332fd57..5da2ad01bc91 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -453,10 +453,18 @@ export default { copyEmailToClipboard: 'Copiar email al portapapeles', markAsUnread: 'Marcar como no leído', markAsRead: 'Marcar como leído', - editAction: ({action}: EditActionParams) => `Editar ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'solicitud' : 'comentario'}`, - deleteAction: ({action}: DeleteActionParams) => `Eliminar ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'solicitud' : 'comentario'}`, + editAction: ({action}: EditActionParams) => + `Editar ${ + action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? `${action?.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK ? 'gastos' : 'solicitud'}` : 'comentario' + }`, + deleteAction: ({action}: DeleteActionParams) => + `Eliminar ${ + action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? `${action?.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK ? 'gastos' : 'solicitud'}` : 'comentario' + }`, deleteConfirmation: ({action}: DeleteConfirmationParams) => - `¿Estás seguro de que quieres eliminar esta ${action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? 'solicitud' : 'comentario'}`, + `¿Estás seguro de que quieres eliminar esta ${ + action?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU ? `${action?.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK ? 'gastos' : 'solicitud'}` : 'comentario' + }`, onlyVisible: 'Visible sólo para', replyInThread: 'Responder en el hilo', joinThread: 'Unirse al hilo', @@ -496,6 +504,8 @@ export default { send: 'enviar dinero', split: 'dividir una factura', request: 'pedir dinero', + // eslint-disable-next-line @typescript-eslint/naming-convention + 'track-expense': 'rastrear un gasto', }, }, reportAction: { @@ -586,6 +596,7 @@ export default { participants: 'Participantes', requestMoney: 'Pedir dinero', sendMoney: 'Enviar dinero', + trackExpense: 'Seguimiento de gastos', pay: 'Pagar', cancelPayment: 'Cancelar el pago', cancelPaymentConfirmation: '¿Estás seguro de que quieres cancelar este pago?', @@ -617,6 +628,7 @@ export default { finished: 'Finalizado', requestAmount: ({amount}: RequestAmountParams) => `solicitar ${amount}`, requestedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `solicité ${formattedAmount}${comment ? ` para ${comment}` : ''}`, + trackedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `seguimiento ${formattedAmount}${comment ? ` para ${comment}` : ''}`, splitAmount: ({amount}: SplitAmountParams) => `dividir ${amount}`, didSplitAmount: ({formattedAmount, comment}: DidSplitAmountMessageParams) => `dividió ${formattedAmount}${comment ? ` para ${comment}` : ''}`, amountEach: ({amount}: AmountEachParams) => `${amount} cada uno`, @@ -650,6 +662,7 @@ export default { updatedTheDistance: ({newDistanceToDisplay, oldDistanceToDisplay, newAmountToDisplay, oldAmountToDisplay}: UpdatedTheDistanceParams) => `cambió la distancia a ${newDistanceToDisplay} (previamente ${oldDistanceToDisplay}), lo que cambió el importe a ${newAmountToDisplay} (previamente ${oldAmountToDisplay})`, threadRequestReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `${comment ? `${formattedAmount} para ${comment}` : `Solicitud de ${formattedAmount}`}`, + threadTrackReportName: ({formattedAmount, comment}: ThreadRequestReportNameParams) => `Seguimiento ${formattedAmount} ${comment ? `para ${comment}` : ''}`, threadSentMoneyReportName: ({formattedAmount, comment}: ThreadSentMoneyReportNameParams) => `${formattedAmount} enviado${comment ? ` para ${comment}` : ''}`, tagSelection: 'Selecciona una etiqueta para organizar mejor tu dinero.', categorySelection: 'Seleccione una categoría para organizar mejor tu dinero.', @@ -1067,6 +1080,14 @@ export default { }, }, }, + workflowsDelayedSubmissionPage: { + autoReportingErrorMessage: 'El parámetro de envío retrasado no pudo ser cambiado. Por favor, inténtelo de nuevo o contacte al soporte.', + autoReportingFrequencyErrorMessage: 'La frecuencia de envío no pudo ser cambiada. Por favor, inténtelo de nuevo o contacte al soporte.', + monthlyOffsetErrorMessage: 'La frecuencia mensual no pudo ser cambiada. Por favor, inténtelo de nuevo o contacte al soporte.', + }, + workflowsApprovalPage: { + genericErrorMessage: 'El aprobador no pudo ser cambiado. Por favor, inténtelo de nuevo o contacte al soporte.', + }, workflowsPayerPage: { title: 'Pagador autorizado', genericErrorMessage: 'El pagador autorizado no se pudo cambiar. Por favor, inténtalo mas tarde.', @@ -1899,8 +1920,20 @@ export default { value: 'Valor', errors: { taxRateAlreadyExists: 'Ya existe un impuesto con este nombre', - valuePercentageRange: 'Introduzca un porcentaje válido entre 0 y 100', - genericFailureMessage: 'Se produjo un error al actualizar el tipo impositivo, inténtelo nuevamente.', + valuePercentageRange: 'Por favor, introduce un porcentaje entre 0 y 100', + deleteFailureMessage: 'Se ha producido un error al intentar eliminar la tasa de impuesto. Por favor, inténtalo más tarde.', + updateFailureMessage: 'Se ha producido un error al intentar modificar la tasa de impuesto. Por favor, inténtalo más tarde.', + createFailureMessage: 'Se ha producido un error al intentar crear la tasa de impuesto. Por favor, inténtalo más tarde.', + }, + deleteTaxConfirmation: '¿Estás seguro de que quieres eliminar este impuesto?', + deleteMultipleTaxConfirmation: ({taxAmount}) => `¿Estás seguro de que quieres eliminar ${taxAmount} impuestos?`, + actions: { + delete: 'Eliminar tasa', + deleteMultiple: 'Eliminar tasas', + disable: 'Desactivar tasa', + disableMultiple: 'Desactivar tasas', + enable: 'Activar tasa', + enableMultiple: 'Activar tasas', }, }, emptyWorkspace: { @@ -2803,6 +2836,7 @@ export default { deletedReport: '[Informe eliminado]', deletedMessage: '[Mensaje eliminado]', deletedRequest: '[Solicitud eliminada]', + deletedExpense: '[Gasto eliminado]', reversedTransaction: '[Transacción anulada]', deletedTask: '[Tarea eliminada]', hiddenMessage: '[Mensaje oculto]', diff --git a/src/libs/API/parameters/DeletePolicyTaxesParams.ts b/src/libs/API/parameters/DeletePolicyTaxesParams.ts new file mode 100644 index 000000000000..9e0963cdcb28 --- /dev/null +++ b/src/libs/API/parameters/DeletePolicyTaxesParams.ts @@ -0,0 +1,11 @@ +type DeletePolicyTaxesParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array + * Each element is a tax name + */ + taxNames: string; +}; + +export default DeletePolicyTaxesParams; diff --git a/src/libs/API/parameters/RenamePolicyTaxParams.ts b/src/libs/API/parameters/RenamePolicyTaxParams.ts new file mode 100644 index 000000000000..b722f14e7b6e --- /dev/null +++ b/src/libs/API/parameters/RenamePolicyTaxParams.ts @@ -0,0 +1,7 @@ +type SetPolicyCurrencyDefaultParams = { + policyID: string; + taxCode: string; + newName: string; +}; + +export default SetPolicyCurrencyDefaultParams; diff --git a/src/libs/API/parameters/SetPolicyTaxesEnabledParams.ts b/src/libs/API/parameters/SetPolicyTaxesEnabledParams.ts new file mode 100644 index 000000000000..4ed0a05cfdec --- /dev/null +++ b/src/libs/API/parameters/SetPolicyTaxesEnabledParams.ts @@ -0,0 +1,10 @@ +type SetPolicyTaxesEnabledParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array<{taxCode: string, enabled: bool}> + */ + taxFieldsArray: string; +}; + +export default SetPolicyTaxesEnabledParams; diff --git a/src/libs/API/parameters/TrackExpenseParams.ts b/src/libs/API/parameters/TrackExpenseParams.ts new file mode 100644 index 000000000000..f48c8666f109 --- /dev/null +++ b/src/libs/API/parameters/TrackExpenseParams.ts @@ -0,0 +1,30 @@ +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; +import type {Receipt} from '@src/types/onyx/Transaction'; + +type TrackExpenseParams = { + amount: number; + currency: string; + comment: string; + created: string; + merchant: string; + iouReportID?: string; + chatReportID: string; + transactionID: string; + reportActionID: string; + createdChatReportActionID: string; + createdIOUReportActionID?: string; + reportPreviewReportActionID?: string; + receipt: Receipt; + receiptState?: ValueOf; + category?: string; + tag?: string; + taxCode: string; + taxAmount: number; + billable?: boolean; + gpsPoints?: string; + transactionThreadReportID: string; + createdReportActionIDForThread: string; +}; + +export default TrackExpenseParams; diff --git a/src/libs/API/parameters/UpdatePolicyTaxValueParams.ts b/src/libs/API/parameters/UpdatePolicyTaxValueParams.ts new file mode 100644 index 000000000000..1124755ea9ef --- /dev/null +++ b/src/libs/API/parameters/UpdatePolicyTaxValueParams.ts @@ -0,0 +1,7 @@ +type UpdatePolicyTaxValueParams = { + policyID: string; + taxCode: string; + taxAmount: number; +}; + +export default UpdatePolicyTaxValueParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 84ace32d6261..0049489f1fc2 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -164,6 +164,7 @@ export type {default as SetWorkspaceReimbursementParams} from './SetWorkspaceRei export type {default as SetPolicyRequiresTag} from './SetPolicyRequiresTag'; export type {default as RenamePolicyTaglist} from './RenamePolicyTaglist'; export type {default as SwitchToOldDotParams} from './SwitchToOldDotParams'; +export type {default as TrackExpenseParams} from './TrackExpenseParams'; export type {default as EnablePolicyCategoriesParams} from './EnablePolicyCategoriesParams'; export type {default as EnablePolicyConnectionsParams} from './EnablePolicyConnectionsParams'; export type {default as EnablePolicyDistanceRatesParams} from './EnablePolicyDistanceRatesParams'; @@ -184,8 +185,12 @@ export type {default as CreatePolicyDistanceRateParams} from './CreatePolicyDist export type {default as SetPolicyDistanceRatesUnitParams} from './SetPolicyDistanceRatesUnitParams'; export type {default as SetPolicyDistanceRatesDefaultCategoryParams} from './SetPolicyDistanceRatesDefaultCategoryParams'; export type {default as CreatePolicyTagsParams} from './CreatePolicyTagsParams'; +export type {default as SetPolicyTaxesEnabledParams} from './SetPolicyTaxesEnabledParams'; +export type {default as DeletePolicyTaxesParams} from './DeletePolicyTaxesParams'; +export type {default as UpdatePolicyTaxValueParams} from './UpdatePolicyTaxValueParams'; export type {default as RenamePolicyTagsParams} from './RenamePolicyTagsParams'; export type {default as DeletePolicyTagsParams} from './DeletePolicyTagsParams'; export type {default as SetPolicyCustomTaxNameParams} from './SetPolicyCustomTaxNameParams'; export type {default as SetPolicyForeignCurrencyDefaultParams} from './SetPolicyForeignCurrencyDefaultParams'; export type {default as SetPolicyCurrencyDefaultParams} from './SetPolicyCurrencyDefaultParams'; +export type {default as RenamePolicyTaxParams} from './RenamePolicyTaxParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 8d359febfd0f..cc524f987fab 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -168,6 +168,7 @@ const WRITE_COMMANDS = { CANCEL_PAYMENT: 'CancelPayment', ACCEPT_ACH_CONTRACT_FOR_BANK_ACCOUNT: 'AcceptACHContractForBankAccount', SWITCH_TO_OLD_DOT: 'SwitchToOldDot', + TRACK_EXPENSE: 'TrackExpense', ENABLE_POLICY_CATEGORIES: 'EnablePolicyCategories', ENABLE_POLICY_CONNECTIONS: 'EnablePolicyConnections', ENABLE_POLICY_DISTANCE_RATES: 'EnablePolicyDistanceRates', @@ -182,6 +183,10 @@ const WRITE_COMMANDS = { ACCEPT_JOIN_REQUEST: 'AcceptJoinRequest', DECLINE_JOIN_REQUEST: 'DeclineJoinRequest', CREATE_POLICY_TAX: 'CreatePolicyTax', + SET_POLICY_TAXES_ENABLED: 'SetPolicyTaxesEnabled', + DELETE_POLICY_TAXES: 'DeletePolicyTaxes', + UPDATE_POLICY_TAX_VALUE: 'UpdatePolicyTaxValue', + RENAME_POLICY_TAX: 'RenamePolicyTax', CREATE_POLICY_DISTANCE_RATE: 'CreatePolicyDistanceRate', SET_POLICY_DISTANCE_RATES_UNIT: 'SetPolicyDistanceRatesUnit', SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY: 'SetPolicyDistanceRatesDefaultCategory', @@ -351,6 +356,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_WORKSPACE_PAYER]: Parameters.SetWorkspacePayerParams; [WRITE_COMMANDS.SET_WORKSPACE_REIMBURSEMENT]: Parameters.SetWorkspaceReimbursementParams; [WRITE_COMMANDS.SWITCH_TO_OLD_DOT]: Parameters.SwitchToOldDotParams; + [WRITE_COMMANDS.TRACK_EXPENSE]: Parameters.TrackExpenseParams; [WRITE_COMMANDS.ENABLE_POLICY_CATEGORIES]: Parameters.EnablePolicyCategoriesParams; [WRITE_COMMANDS.ENABLE_POLICY_CONNECTIONS]: Parameters.EnablePolicyConnectionsParams; [WRITE_COMMANDS.ENABLE_POLICY_DISTANCE_RATES]: Parameters.EnablePolicyDistanceRatesParams; @@ -365,7 +371,11 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_POLICY_CUSTOM_TAX_NAME]: Parameters.SetPolicyCustomTaxNameParams; [WRITE_COMMANDS.SET_POLICY_TAXES_FOREIGN_CURRENCY_DEFAULT]: Parameters.SetPolicyForeignCurrencyDefaultParams; [WRITE_COMMANDS.CREATE_POLICY_TAX]: Parameters.CreatePolicyTaxParams; + [WRITE_COMMANDS.SET_POLICY_TAXES_ENABLED]: Parameters.SetPolicyTaxesEnabledParams; + [WRITE_COMMANDS.DELETE_POLICY_TAXES]: Parameters.DeletePolicyTaxesParams; + [WRITE_COMMANDS.UPDATE_POLICY_TAX_VALUE]: Parameters.UpdatePolicyTaxValueParams; [WRITE_COMMANDS.CREATE_POLICY_DISTANCE_RATE]: Parameters.CreatePolicyDistanceRateParams; + [WRITE_COMMANDS.RENAME_POLICY_TAX]: Parameters.RenamePolicyTaxParams; [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_UNIT]: Parameters.SetPolicyDistanceRatesUnitParams; [WRITE_COMMANDS.SET_POLICY_DISTANCE_RATES_DEFAULT_CATEGORY]: Parameters.SetPolicyDistanceRatesDefaultCategoryParams; }; diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index 784d339a4a0d..d38700efd53d 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -110,6 +110,21 @@ function getEarliestErrorField(onyxDa return {[key]: getErrorMessageWithTranslationData(errorsForField[key])}; } +/** + * Method used to get the latest error field for any field + */ +function getLatestErrorFieldForAnyField(onyxData: TOnyxData): Errors { + const errorFields = onyxData.errorFields ?? {}; + + if (Object.keys(errorFields).length === 0) { + return {}; + } + + const fieldNames = Object.keys(errorFields); + const latestErrorFields = fieldNames.map((fieldName) => getLatestErrorField(onyxData, fieldName)); + return latestErrorFields.reduce((acc, error) => ({...acc, ...error}), {}); +} + /** * Method used to attach already translated message with isTranslated property * @param errors - An object containing current errors in the form @@ -176,6 +191,7 @@ export { getLatestErrorField, getLatestErrorMessage, getLatestErrorMessageField, + getLatestErrorFieldForAnyField, getMicroSecondOnyxError, getMicroSecondOnyxErrorObject, isReceiptError, diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index 56ac47676a37..07bb22f43b31 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -104,7 +104,7 @@ function isIOUReportPendingCurrencyConversion(iouReport: Report): boolean { * Checks if the iou type is one of request, send, or split. */ function isValidMoneyRequestType(iouType: string): boolean { - const moneyRequestType: string[] = [CONST.IOU.TYPE.REQUEST, CONST.IOU.TYPE.SPLIT, CONST.IOU.TYPE.SEND]; + const moneyRequestType: string[] = [CONST.IOU.TYPE.REQUEST, CONST.IOU.TYPE.SPLIT, CONST.IOU.TYPE.SEND, CONST.IOU.TYPE.TRACK_EXPENSE]; return moneyRequestType.includes(iouType); } diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index b576f0e7601a..bd5bfc46134a 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -284,6 +284,9 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/ExitSurvey/ExitSurveyConfirmPage').default as React.ComponentType, [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY]: () => require('../../../pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage').default as React.ComponentType, [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET]: () => require('../../../pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage').default as React.ComponentType, + [SCREENS.WORKSPACE.TAX_EDIT]: () => require('../../../pages/workspace/taxes/WorkspaceEditTaxPage').default as React.ComponentType, + [SCREENS.WORKSPACE.TAX_NAME]: () => require('../../../pages/workspace/taxes/NamePage').default as React.ComponentType, + [SCREENS.WORKSPACE.TAX_VALUE]: () => require('../../../pages/workspace/taxes/ValuePage').default as React.ComponentType, [SCREENS.WORKSPACE.TAX_CREATE]: () => require('../../../pages/workspace/taxes/WorkspaceCreateTaxPage').default as React.ComponentType, }); diff --git a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts index 7388d6447ffa..17f5049aab91 100755 --- a/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/FULL_SCREEN_TO_RHP_MAPPING.ts @@ -17,6 +17,10 @@ const FULL_SCREEN_TO_RHP_MAPPING: Partial> = { SCREENS.WORKSPACE.TAXES_SETTINGS_CUSTOM_TAX_NAME, SCREENS.WORKSPACE.TAXES_SETTINGS_FOREIGN_CURRENCY_DEFAULT, SCREENS.WORKSPACE.TAXES_SETTINGS_WORKSPACE_CURRENCY_DEFAULT, + SCREENS.WORKSPACE.TAX_CREATE, + SCREENS.WORKSPACE.TAX_EDIT, + SCREENS.WORKSPACE.TAX_NAME, + SCREENS.WORKSPACE.TAX_VALUE, ], [SCREENS.WORKSPACE.TAGS]: [SCREENS.WORKSPACE.TAGS_SETTINGS, SCREENS.WORKSPACE.TAGS_EDIT, SCREENS.WORKSPACE.TAG_CREATE, SCREENS.WORKSPACE.TAG_SETTINGS, SCREENS.WORKSPACE.TAG_EDIT], [SCREENS.WORKSPACE.CATEGORIES]: [SCREENS.WORKSPACE.CATEGORY_CREATE, SCREENS.WORKSPACE.CATEGORY_SETTINGS, SCREENS.WORKSPACE.CATEGORIES_SETTINGS, SCREENS.WORKSPACE.CATEGORY_EDIT], diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 391d584d5a78..130fdf23732f 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -354,6 +354,15 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.TAX_CREATE]: { path: ROUTES.WORKSPACE_TAX_CREATE.route, }, + [SCREENS.WORKSPACE.TAX_EDIT]: { + path: ROUTES.WORKSPACE_TAX_EDIT.route, + }, + [SCREENS.WORKSPACE.TAX_NAME]: { + path: ROUTES.WORKSPACE_TAX_NAME.route, + }, + [SCREENS.WORKSPACE.TAX_VALUE]: { + path: ROUTES.WORKSPACE_TAX_VALUE.route, + }, }, }, [SCREENS.RIGHT_MODAL.PRIVATE_NOTES]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 90fad4f29f22..9b0d9ce4decc 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -242,6 +242,18 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.TAX_CREATE]: { policyID: string; }; + [SCREENS.WORKSPACE.TAX_EDIT]: { + policyID: string; + taxID: string; + }; + [SCREENS.WORKSPACE.TAX_NAME]: { + policyID: string; + taxID: string; + }; + [SCREENS.WORKSPACE.TAX_VALUE]: { + policyID: string; + taxID: string; + }; } & ReimbursementAccountNavigatorParamList; type NewChatNavigatorParamList = { diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index bacd019904a3..b230cbb1dd00 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -249,9 +249,11 @@ Onyx.connect({ }); const policyExpenseReports: OnyxCollection = {}; +const allReports: OnyxCollection = {}; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, callback: (report, key) => { + allReports[key] = report; if (!ReportUtils.isPolicyExpenseChat(report)) { return; } @@ -734,6 +736,35 @@ function createOption( return result; } +/** + * Get the option for a given report. + */ +function getReportOption(participant: Participant): ReportUtils.OptionData { + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${participant.reportID}`]; + + const option = createOption( + report?.visibleChatMemberAccountIDs ?? [], + allPersonalDetails ?? {}, + report ?? null, + {}, + { + showChatPreviewLine: false, + forcePolicyNamePreview: false, + }, + ); + + // Update text & alternateText because createOption returns workspace name only if report is owned by the user + if (option.isSelfDM) { + option.alternateText = Localize.translateLocal('reportActionsView.yourSpace'); + } else { + option.text = ReportUtils.getPolicyName(report); + option.alternateText = Localize.translateLocal('workspace.common.workspace'); + } + option.selected = participant.selected; + option.isSelected = participant.selected; + return option; +} + /** * Get the option for a policy expense report. */ @@ -2073,6 +2104,7 @@ export { formatSectionsFromSearchTerm, transformedTaxRates, getShareLogOptions, + getReportOption, getTaxRatesSection, }; diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 26df03134fd5..071113a70fae 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -26,6 +26,10 @@ function canUseViolations(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.VIOLATIONS) || canUseAllBetas(betas); } +function canUseTrackExpense(betas: OnyxEntry): boolean { + return !!betas?.includes(CONST.BETAS.TRACK_EXPENSE) || canUseAllBetas(betas); +} + function canUseP2PDistanceRequests(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.P2P_DISTANCE_REQUESTS) || canUseAllBetas(betas); } @@ -47,6 +51,7 @@ export default { canUseCommentLinking, canUseLinkPreviews, canUseViolations, + canUseTrackExpense, canUseReportFields, canUseP2PDistanceRequests, canUseWorkflowsDelayedSubmission, diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 675e268045c1..39e6c8932aad 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -4,7 +4,8 @@ import type {ValueOf} from 'type-fest'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {PersonalDetailsList, Policy, PolicyCategories, PolicyMembers, PolicyTagList, PolicyTags} from '@src/types/onyx'; +import type {PersonalDetailsList, Policy, PolicyCategories, PolicyMembers, PolicyTagList, PolicyTags, TaxRate} from '@src/types/onyx'; +import type {PolicyFeatureName} from '@src/types/onyx/Policy'; import type {EmptyObject} from '@src/types/utils/EmptyObject'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import Navigation from './Navigation/Navigation'; @@ -34,7 +35,7 @@ function hasPolicyMemberError(policyMembers: OnyxEntry): boolean * Check if the policy has any tax rate errors. */ function hasTaxRateError(policy: OnyxEntry): boolean { - return Object.values(policy?.taxRates?.taxes ?? {}).some((taxRate) => Object.keys(taxRate?.errors ?? {}).length > 0); + return Object.values(policy?.taxRates?.taxes ?? {}).some((taxRate) => Object.keys(taxRate?.errors ?? {}).length > 0 || Object.values(taxRate?.errorFields ?? {}).some(Boolean)); } /** @@ -276,6 +277,26 @@ function goBackFromInvalidPolicy() { Navigation.navigate(ROUTES.SETTINGS_WORKSPACES); } +/** Get a tax with given ID from policy */ +function getTaxByID(policy: OnyxEntry, taxID: string): TaxRate | undefined { + return policy?.taxRates?.taxes?.[taxID]; +} + +/** + * Whether the tax rate can be deleted and disabled + */ +function canEditTaxRate(policy: Policy, taxID: string): boolean { + return policy.taxRates?.defaultExternalID !== taxID; +} + +function isPolicyFeatureEnabled(policy: OnyxEntry | EmptyObject, featureName: PolicyFeatureName): boolean { + if (featureName === CONST.POLICY.MORE_FEATURES.ARE_TAXES_ENABLED) { + return Boolean(policy?.tax?.trackingEnabled); + } + + return Boolean(policy?.[featureName]); +} + export { getActivePolicies, hasAccountingConnections, @@ -296,6 +317,7 @@ export { getIneligibleInvitees, getTagLists, getTagListName, + canEditTaxRate, getTagList, getCleanedTagName, getCountOfEnabledTagsOfList, @@ -306,7 +328,9 @@ export { getPathWithoutPolicyID, getPolicyMembersByIdWithoutCurrentUser, goBackFromInvalidPolicy, + isPolicyFeatureEnabled, hasTaxRateError, + getTaxByID, hasPolicyCategoriesError, }; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index fbee7ffd7b11..206c3ecd75a6 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -209,6 +209,7 @@ function isTransactionThread(parentReportAction: OnyxEntry | Empty return ( parentReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && (parentReportAction.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || + parentReportAction.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK || (parentReportAction.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.PAY && !!parentReportAction.originalMessage.IOUDetails)) ); } @@ -649,7 +650,7 @@ function getReportPreviewAction(chatReportID: string, iouReportID: string): Onyx * Get the iouReportID for a given report action. */ function getIOUReportIDFromReportActionPreview(reportAction: OnyxEntry): string { - return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW ? reportAction.originalMessage.linkedReportID : ''; + return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW ? reportAction.originalMessage.linkedReportID : '0'; } function isCreatedTaskReportAction(reportAction: OnyxEntry): boolean { @@ -674,6 +675,10 @@ function isSplitBillAction(reportAction: OnyxEntry): boolean { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && reportAction.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT; } +function isTrackExpenseAction(reportAction: OnyxEntry): boolean { + return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && (reportAction.originalMessage as IOUMessage).type === CONST.IOU.REPORT_ACTION_TYPE.TRACK; +} + function isTaskAction(reportAction: OnyxEntry): boolean { const reportActionName = reportAction?.actionName; return ( @@ -996,6 +1001,7 @@ export { isReportPreviewAction, isSentMoneyReportAction, isSplitBillAction, + isTrackExpenseAction, isTaskAction, doesReportHaveVisibleActions, isThreadParentMessage, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index b5351e13ea6c..14041fbe35ce 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -23,7 +23,6 @@ import type { PersonalDetailsList, Policy, PolicyReportField, - PolicyReportFields, Report, ReportAction, ReportMetadata, @@ -482,14 +481,6 @@ Onyx.connect({ callback: (value) => (allPolicies = value), }); -let allPolicyReportFields: OnyxCollection = {}; - -Onyx.connect({ - key: ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS, - waitForCollectionCallback: true, - callback: (value) => (allPolicyReportFields = value), -}); - let allBetas: OnyxEntry; Onyx.connect({ key: ONYXKEYS.BETAS, @@ -938,6 +929,15 @@ function isConciergeChatReport(report: OnyxEntry): boolean { return report?.participantAccountIDs?.length === 1 && Number(report.participantAccountIDs?.[0]) === CONST.ACCOUNT_ID.CONCIERGE && !isChatThread(report); } +function findSelfDMReportID(): string | undefined { + if (!allReports) { + return; + } + + const selfDMReport = Object.values(allReports).find((report) => isSelfDM(report) && !isThread(report)); + return selfDMReport?.reportID; +} + /** * Checks if the supplied report belongs to workspace based on the provided params. If the report's policyID is _FAKE_ or has no value, it means this report is a DM. * In this case report and workspace members must be compared to determine whether the report belongs to the workspace. @@ -1227,6 +1227,18 @@ function isIOURequest(report: OnyxEntry): boolean { return false; } +/** + * A Track Expense Report is a thread where the parent the parentReportAction is a transaction, and + * parentReportAction has type of track. + */ +function isTrackExpenseReport(report: OnyxEntry): boolean { + if (isThread(report)) { + const parentReportAction = ReportActionsUtils.getParentReportAction(report); + return !isEmptyObject(parentReportAction) && ReportActionsUtils.isTrackExpenseAction(parentReportAction); + } + return false; +} + /** * Checks if a report is an IOU or expense request. */ @@ -2078,22 +2090,36 @@ function isReportFieldDisabled(report: OnyxEntry, reportField: OnyxEntry /** * Given a set of report fields, return the field of type formula */ -function getFormulaTypeReportField(reportFields: PolicyReportFields) { - return Object.values(reportFields).find((field) => field.type === 'formula'); +function getFormulaTypeReportField(reportFields: Record) { + return Object.values(reportFields).find((field) => field?.type === 'formula'); } /** * Given a set of report fields, return the field that refers to title */ -function getTitleReportField(reportFields: PolicyReportFields) { +function getTitleReportField(reportFields: Record) { return Object.values(reportFields).find((field) => isReportFieldOfTypeTitle(field)); } +/** + * Get the key for a report field + */ +function getReportFieldKey(reportFieldId: string) { + return `expensify_${reportFieldId}`; +} + /** * Get the report fields attached to the policy given policyID */ -function getReportFieldsByPolicyID(policyID: string) { - return Object.entries(allPolicyReportFields ?? {}).find(([key]) => key.replace(ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS, '') === policyID)?.[1]; +function getReportFieldsByPolicyID(policyID: string): Record { + const policyReportFields = Object.entries(allPolicies ?? {}).find(([key]) => key.replace(ONYXKEYS.COLLECTION.POLICY, '') === policyID); + const fieldList = policyReportFields?.[1]?.fieldList; + + if (!policyReportFields || !fieldList) { + return {}; + } + + return fieldList; } /** @@ -2102,7 +2128,7 @@ function getReportFieldsByPolicyID(policyID: string) { function getAvailableReportFields(report: Report, policyReportFields: PolicyReportField[]): PolicyReportField[] { // Get the report fields that are attached to a report. These will persist even if a field is deleted from the policy. - const reportFields = Object.values(report.reportFields ?? {}); + const reportFields = Object.values(report.fieldList ?? {}); const reportIsSettled = isSettled(report.reportID); // If the report is settled, we don't want to show any new field that gets added to the policy. @@ -2113,7 +2139,24 @@ function getAvailableReportFields(report: Report, policyReportFields: PolicyRepo // If the report is unsettled, we want to merge the new fields that get added to the policy with the fields that // are attached to the report. const mergedFieldIds = Array.from(new Set([...policyReportFields.map(({fieldID}) => fieldID), ...reportFields.map(({fieldID}) => fieldID)])); - return mergedFieldIds.map((id) => report?.reportFields?.[id] ?? policyReportFields.find(({fieldID}) => fieldID === id)) as PolicyReportField[]; + + const fields = mergedFieldIds.map((id) => { + const field = report?.fieldList?.[getReportFieldKey(id)]; + + if (field) { + return field; + } + + const policyReportField = policyReportFields.find(({fieldID}) => fieldID === id); + + if (policyReportField) { + return policyReportField; + } + + return null; + }); + + return fields.filter(Boolean) as PolicyReportField[]; } /** @@ -2121,7 +2164,7 @@ function getAvailableReportFields(report: Report, policyReportFields: PolicyRepo */ function getMoneyRequestReportName(report: OnyxEntry, policy: OnyxEntry | undefined = undefined): string { const isReportSettled = isSettled(report?.reportID ?? ''); - const reportFields = isReportSettled ? report?.reportFields : getReportFieldsByPolicyID(report?.policyID ?? ''); + const reportFields = isReportSettled ? report?.fieldList : getReportFieldsByPolicyID(report?.policyID ?? ''); const titleReportField = getFormulaTypeReportField(reportFields ?? {}); if (titleReportField && report?.reportName && reportFieldsEnabled(report)) { @@ -2211,6 +2254,11 @@ function canEditMoneyRequest(reportAction: OnyxEntry): boolean { return true; } + // TODO: Uncomment this line when BE starts working properly (Editing Track Expense) + // if (reportAction.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK) { + // return true; + // } + if (reportAction.originalMessage.type !== CONST.IOU.REPORT_ACTION_TYPE.CREATE) { return false; } @@ -2382,10 +2430,25 @@ function getTransactionReportName(reportAction: OnyxEntry, policy: OnyxEntry, o /** * Helper method to define what money request options we want to show for particular method. - * There are 3 money request options: Request, Split and Send: + * There are 4 money request options: Request, Split, Send and Track expense: * - Request option should show for: * - DMs * - own policy expense chats @@ -4561,13 +4649,16 @@ function canRequestMoney(report: OnyxEntry, policy: OnyxEntry, o * - chat/ policy rooms with more than 1 participants * - groups chats with 3 and more participants * - corporate workspace chats + * - Track expense option should show for: + * - Self DMs + * - admin rooms * * None of the options should show in chat threads or if there is some special Expensify account * as a participant of the report. */ -function getMoneyRequestOptions(report: OnyxEntry, policy: OnyxEntry, reportParticipants: number[]): Array> { +function getMoneyRequestOptions(report: OnyxEntry, policy: OnyxEntry, reportParticipants: number[], canUseTrackExpense = true): Array> { // In any thread or task report, we do not allow any new money requests yet - if (isChatThread(report) || isTaskReport(report) || isSelfDM(report)) { + if (isChatThread(report) || isTaskReport(report) || (!canUseTrackExpense && isSelfDM(report))) { return []; } @@ -4583,6 +4674,10 @@ function getMoneyRequestOptions(report: OnyxEntry, policy: OnyxEntry 1; let options: Array> = []; + if (isSelfDM(report)) { + options = [CONST.IOU.TYPE.TRACK_EXPENSE]; + } + // User created policy rooms and default rooms like #admins or #announce will always have the Split Bill option // unless there are no other participants at all (e.g. #admins room for a policy with only 1 admin) // DM chats will have the Split Bill option only when there are at least 2 other people in the chat. @@ -4591,6 +4686,11 @@ function getMoneyRequestOptions(report: OnyxEntry, policy: OnyxEntry) amount: formattedAmount, }); } - translationKey = ReportActionsUtils.isSplitBillAction(reportAction) ? 'iou.didSplitAmount' : 'iou.requestedAmount'; + if (ReportActionsUtils.isSplitBillAction(reportAction)) { + translationKey = 'iou.didSplitAmount'; + } else if (ReportActionsUtils.isTrackExpenseAction(reportAction)) { + translationKey = 'iou.trackedAmount'; + } else { + translationKey = 'iou.requestedAmount'; + } return Localize.translateLocal(translationKey, { formattedAmount, comment: transactionDetails?.comment ?? '', @@ -5539,16 +5645,19 @@ export { hasUpdatedTotal, isReportFieldDisabled, getAvailableReportFields, + getReportFieldKey, reportFieldsEnabled, getAllAncestorReportActionIDs, getPendingChatMembers, canEditRoomVisibility, canEditPolicyDescription, getPolicyDescriptionText, + findSelfDMReportID, getIndicatedMissingPaymentMethod, isJoinRequestInAdminRoom, canAddOrDeleteTransactions, shouldCreateNewMoneyRequestReport, + isTrackExpenseReport, hasActionsWithErrors, }; diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index 5876ccf5d7d7..cacab8333868 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -471,8 +471,9 @@ function isValidPercentage(value: string): boolean { /** * Validates the given value if it is correct tax name. */ -function isExistingTaxName(value: string, taxRates: TaxRates): boolean { - return !!Object.values(taxRates).find((taxRate) => taxRate.name === value); +function isExistingTaxName(taxName: string, taxRates: TaxRates): boolean { + const trimmedTaxName = taxName.trim(); + return !!Object.values(taxRates).find((taxRate) => taxRate.name === trimmedTaxName); } export { diff --git a/src/libs/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts index 23cb53a317b0..f808f602a1c6 100644 --- a/src/libs/WorkspacesSettingsUtils.ts +++ b/src/libs/WorkspacesSettingsUtils.ts @@ -92,8 +92,9 @@ function hasGlobalWorkspaceSettingsRBR(policies: OnyxCollection, policyM function hasWorkspaceSettingsRBR(policy: Policy) { const policyMemberError = allPolicyMembers ? hasPolicyMemberError(allPolicyMembers[`${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policy.id}`]) : false; + const taxRateError = hasTaxRateError(policy); - return Object.keys(reimbursementAccount?.errors ?? {}).length > 0 || hasPolicyError(policy) || hasCustomUnitsError(policy) || policyMemberError; + return Object.keys(reimbursementAccount?.errors ?? {}).length > 0 || hasPolicyError(policy) || hasCustomUnitsError(policy) || policyMemberError || taxRateError; } function getChatTabBrickRoad(policyID?: string): BrickRoad | undefined { diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 562ed501eebc..94b794241ffa 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -22,6 +22,7 @@ import type { SplitBillParams, StartSplitBillParams, SubmitReportParams, + TrackExpenseParams, UpdateMoneyRequestParams, } from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; @@ -84,6 +85,19 @@ type MoneyRequestInformation = { onyxData: OnyxData; }; +type TrackExpenseInformation = { + iouReport?: OnyxTypes.Report; + chatReport: OnyxTypes.Report; + transaction: OnyxTypes.Transaction; + iouAction: OptimisticIOUReportAction; + createdChatReportActionID: string; + createdIOUReportActionID?: string; + reportPreviewAction?: OnyxTypes.ReportAction; + transactionThreadReportID: string; + createdReportActionIDForThread: string; + onyxData: OnyxData; +}; + type SplitData = { chatReportID: string; transactionID: string; @@ -794,6 +808,178 @@ function buildOnyxDataForMoneyRequest( return [optimisticData, successData, failureData]; } +/** Builds the Onyx data for track expense */ +function buildOnyxDataForTrackExpense( + chatReport: OnyxEntry, + transaction: OnyxTypes.Transaction, + iouAction: OptimisticIOUReportAction, + transactionThreadReport: OptimisticChatReport, + transactionThreadCreatedReportAction: OptimisticCreatedReportAction, + policy?: OnyxEntry, + policyTagList?: OnyxEntry, + policyCategories?: OnyxEntry, +): [OnyxUpdate[], OnyxUpdate[], OnyxUpdate[]] { + const isScanRequest = TransactionUtils.isScanRequest(transaction); + const clearedPendingFields = Object.fromEntries(Object.keys(transaction.pendingFields ?? {}).map((key) => [key, null])); + const optimisticData: OnyxUpdate[] = []; + + if (chatReport) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport.reportID}`, + value: { + ...chatReport, + lastMessageText: iouAction.message?.[0].text, + lastMessageHtml: iouAction.message?.[0].html, + lastReadTime: DateUtils.getDBTime(), + }, + }); + } + + optimisticData.push( + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, + value: transaction, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [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, + }, + ); + + const successData: OnyxUpdate[] = []; + + successData.push( + { + 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: { + [iouAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport.reportID}`, + value: { + [transactionThreadCreatedReportAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + }, + }, + ); + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: { + lastReadTime: chatReport?.lastReadTime, + lastMessageText: chatReport?.lastMessageText, + lastMessageHtml: chatReport?.lastMessageHtml, + }, + }, + { + 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.genericCreateFailureMessage'), + pendingAction: null, + pendingFields: clearedPendingFields, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [iouAction.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, isScanRequest), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReport.reportID}`, + value: { + [transactionThreadCreatedReportAction.reportActionID]: { + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), + }, + }, + }, + ]; + + // 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]; +} + /** * Gathers all the data needed to make a money request. 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 @@ -1002,6 +1188,137 @@ function getMoneyRequestInformation( }; } +/** + * Gathers all the data needed to make 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 + */ +function getTrackExpenseInformation( + parentChatReport: OnyxEntry | EmptyObject, + participant: Participant, + comment: string, + amount: number, + currency: string, + created: string, + merchant: string, + receipt: Receipt | undefined, + category: string | undefined, + tag: string | undefined, + billable: boolean | undefined, + policy: OnyxEntry | undefined, + policyTagList: OnyxEntry | undefined, + policyCategories: OnyxEntry | undefined, + payeeEmail = currentUserEmail, +): TrackExpenseInformation | EmptyObject { + // STEP 1: Get existing chat report + let chatReport = !isEmptyObject(parentChatReport) && parentChatReport?.reportID ? parentChatReport : null; + + // The chatReport always exist and we can get it from Onyx if chatReport is null. + if (!chatReport) { + chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${participant.reportID}`] ?? null; + } + + // If we still don't have a report, it likely doens't exist and we will early return here as it should not happen + // Maybe later, we can build an optimistic selfDM chat. + if (!chatReport) { + return {}; + } + + // STEP 2: Get the money request report. + // TODO: This is deferred to later as we are not sure if we create iouReport at all in future. + // We can build an optimistic iouReport here if needed. + + // 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 existingTransaction = allTransactionDrafts[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`]; + const isDistanceRequest = existingTransaction && existingTransaction.iouRequestType === CONST.IOU.REQUEST_TYPE.DISTANCE; + let optimisticTransaction = TransactionUtils.buildOptimisticTransaction( + amount, + currency, + chatReport.reportID, + comment, + created, + '', + '', + merchant, + receiptObject, + filename, + null, + category, + tag, + billable, + isDistanceRequest ? {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD} : undefined, + ); + + // If there is an existing transaction (which is the case for distance requests), then the data from the existing transaction + // needs to be manually merged into the optimistic transaction. This is because buildOnyxDataForMoneyRequest() uses `Onyx.set()` for the transaction + // data. This is a big can of worms to change it to `Onyx.merge()` as explored in https://expensify.slack.com/archives/C05DWUDHVK7/p1692139468252109. + // I want to clean this up at some point, but it's possible this will live in the code for a while so I've created https://github.com/Expensify/App/issues/25417 + // to remind me to do this. + if (isDistanceRequest) { + optimisticTransaction = fastMerge(existingTransaction, optimisticTransaction, false); + } + + // STEP 4: Build optimistic reportActions. We need: + // 1. IOU action for the chatReport + // 2. The transaction thread, which requires the iouAction, and CREATED action for the transaction thread + const currentTime = DateUtils.getDBTime(); + const iouAction = ReportUtils.buildOptimisticIOUReportAction( + CONST.IOU.REPORT_ACTION_TYPE.TRACK, + amount, + currency, + comment, + [participant], + optimisticTransaction.transactionID, + undefined, + '0', + false, + false, + receiptObject, + false, + currentTime, + ); + const optimisticTransactionThread = ReportUtils.buildTransactionThread(iouAction, chatReport); + const optimisticCreatedActionForTransactionThread = ReportUtils.buildOptimisticCreatedReportAction(payeeEmail); + + // The IOU action and the transactionThread are co-dependent as parent-child, so we need to link them together + iouAction.childReportID = optimisticTransactionThread.reportID; + + // STEP 5: Build Onyx Data + const [optimisticData, successData, failureData] = buildOnyxDataForTrackExpense( + chatReport, + optimisticTransaction, + iouAction, + optimisticTransactionThread, + optimisticCreatedActionForTransactionThread, + policy, + policyTagList, + policyCategories, + ); + + return { + chatReport, + iouReport: undefined, + transaction: optimisticTransaction, + iouAction, + createdChatReportActionID: '0', + createdIOUReportActionID: undefined, + reportPreviewAction: undefined, + transactionThreadReportID: optimisticTransactionThread.reportID, + createdReportActionIDForThread: optimisticCreatedActionForTransactionThread.reportActionID, + onyxData: { + optimisticData, + successData, + failureData, + }, + }; +} + /** Requests money based on a distance (eg. mileage from a map) */ function createDistanceRequest( report: OnyxTypes.Report, @@ -1399,6 +1716,185 @@ function getUpdateMoneyRequestParams( }; } +/** + * @param transactionID + * @param transactionThreadReportID + * @param transactionChanges + * @param [transactionChanges.created] Present when updated the date field + * @param onlyIncludeChangedFields + * When 'true', then the returned params will only include the transaction details for the fields that were changed. + * When `false`, then the returned params will include all the transaction details, regardless of which fields were changed. + * This setting is necessary while the UpdateDistanceRequest API is refactored to be fully 1:1:1 in https://github.com/Expensify/App/issues/28358 + */ +function getUpdateTrackExpenseParams( + transactionID: string, + transactionThreadReportID: string, + transactionChanges: TransactionChanges, + onlyIncludeChangedFields: boolean, +): UpdateMoneyRequestData { + const optimisticData: OnyxUpdate[] = []; + const successData: OnyxUpdate[] = []; + const failureData: OnyxUpdate[] = []; + + // Step 1: Set any "pending fields" (ones updated while the user was offline) to have error messages in the failureData + const pendingFields = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE])); + const clearedPendingFields = Object.fromEntries(Object.keys(transactionChanges).map((key) => [key, null])); + const errorFields = Object.fromEntries(Object.keys(pendingFields).map((key) => [key, {[DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericEditFailureMessage')}])); + + // Step 2: Get all the collections being updated + const transactionThread = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; + const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThread?.parentReportID}`] ?? null; + const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); + let updatedTransaction = transaction ? TransactionUtils.getUpdatedTransaction(transaction, transactionChanges, false) : null; + const transactionDetails = ReportUtils.getTransactionDetails(updatedTransaction); + + if (transactionDetails?.waypoints) { + // This needs to be a JSON string since we're sending this to the MapBox API + transactionDetails.waypoints = JSON.stringify(transactionDetails.waypoints); + } + + const dataToIncludeInParams: Partial | undefined = onlyIncludeChangedFields + ? Object.fromEntries(Object.entries(transactionDetails ?? {}).filter(([key]) => Object.keys(transactionChanges).includes(key))) + : transactionDetails; + + const params: UpdateMoneyRequestParams = { + ...dataToIncludeInParams, + reportID: chatReport?.reportID, + transactionID, + }; + + const hasPendingWaypoints = 'waypoints' in transactionChanges; + if (transaction && updatedTransaction && hasPendingWaypoints) { + updatedTransaction = { + ...updatedTransaction, + amount: CONST.IOU.DEFAULT_AMOUNT, + modifiedAmount: CONST.IOU.DEFAULT_AMOUNT, + modifiedMerchant: Localize.translateLocal('iou.routePending'), + }; + + // Delete the draft transaction when editing waypoints when the server responds successfully and there are no errors + successData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, + value: null, + }); + + // Revert the transaction's amount to the original value on failure. + // The IOU Report will be fully reverted in the failureData further below. + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + amount: transaction.amount, + modifiedAmount: transaction.modifiedAmount, + modifiedMerchant: transaction.modifiedMerchant, + }, + }); + } + + // Step 3: Build the modified expense report actions + // We don't create a modified report action if we're updating the waypoints, + // since there isn't actually any optimistic data we can create for them and the report action is created on the server + // with the response from the MapBox API + const updatedReportAction = ReportUtils.buildOptimisticModifiedExpenseReportAction(transactionThread, transaction, transactionChanges, false); + if (!hasPendingWaypoints) { + params.reportActionID = updatedReportAction.reportActionID; + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, + value: { + [updatedReportAction.reportActionID]: updatedReportAction as OnyxTypes.ReportAction, + }, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, + value: { + [updatedReportAction.reportActionID]: {pendingAction: null}, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread?.reportID}`, + value: { + [updatedReportAction.reportActionID]: { + ...(updatedReportAction as OnyxTypes.ReportAction), + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericEditFailureMessage'), + }, + }, + }); + } + + // Step 4: Update the report preview message (and report header) so LHN amount tracked is correct. + // Optimistically modify the transaction and the transaction thread + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + ...updatedTransaction, + pendingFields, + isLoading: hasPendingWaypoints, + errorFields: null, + }, + }); + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: { + lastActorAccountID: updatedReportAction.actorAccountID, + }, + }); + + if (isScanning && ('amount' in transactionChanges || 'currency' in transactionChanges)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [transactionThread?.parentReportActionID ?? '']: { + whisperedToAccountIDs: [], + }, + }, + }); + } + + // Clear out the error fields and loading states on success + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingFields: clearedPendingFields, + isLoading: false, + errorFields: null, + }, + }); + + // Clear out loading states, pending fields, and add the error fields + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: { + pendingFields: clearedPendingFields, + isLoading: false, + errorFields, + }, + }); + + // Reset the transaction thread to its original state + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`, + value: transactionThread, + }); + + return { + params, + onyxData: {optimisticData, successData, failureData}, + }; +} + /** Updates the created date of a money request */ function updateMoneyRequestDate( transactionID: string, @@ -1411,7 +1907,14 @@ function updateMoneyRequestDate( const transactionChanges: TransactionChanges = { created: value, }; - const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTags, policyCategories, true); + const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; + let data: UpdateMoneyRequestData; + if (ReportUtils.isTrackExpenseReport(transactionThreadReport)) { + data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, true); + } else { + data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTags, policyCategories, true); + } + const {params, onyxData} = data; API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DATE, params, onyxData); } @@ -1443,7 +1946,14 @@ function updateMoneyRequestMerchant( const transactionChanges: TransactionChanges = { merchant: value, }; - const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); + const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; + let data: UpdateMoneyRequestData; + if (ReportUtils.isTrackExpenseReport(transactionThreadReport)) { + data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, true); + } else { + data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); + } + const {params, onyxData} = data; API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_MERCHANT, params, onyxData); } @@ -1475,7 +1985,14 @@ function updateMoneyRequestDistance( const transactionChanges: TransactionChanges = { waypoints, }; - const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); + const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; + let data: UpdateMoneyRequestData; + if (ReportUtils.isTrackExpenseReport(transactionThreadReport)) { + data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, true); + } else { + data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); + } + const {params, onyxData} = data; API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DISTANCE, params, onyxData); } @@ -1507,7 +2024,14 @@ function updateMoneyRequestDescription( const transactionChanges: TransactionChanges = { comment, }; - const {params, onyxData} = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); + const transactionThreadReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`] ?? null; + let data: UpdateMoneyRequestData; + if (ReportUtils.isTrackExpenseReport(transactionThreadReport)) { + data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, true); + } else { + data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); + } + const {params, onyxData} = data; API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_DESCRIPTION, params, onyxData); } @@ -1622,6 +2146,93 @@ function requestMoney( Report.notifyNewAction(activeReportID, payeeAccountID); } +/** + * Track an expense + */ +function trackExpense( + report: OnyxTypes.Report, + amount: number, + currency: string, + created: string, + merchant: string, + payeeEmail: string, + payeeAccountID: number, + participant: Participant, + comment: string, + receipt: Receipt, + category?: string, + tag?: string, + taxCode = '', + taxAmount = 0, + billable?: boolean, + policy?: OnyxEntry, + policyTagList?: OnyxEntry, + policyCategories?: OnyxEntry, + gpsPoints = undefined, +) { + const currentCreated = DateUtils.enrichMoneyRequestTimestamp(created); + const { + iouReport, + chatReport, + transaction, + iouAction, + createdChatReportActionID, + createdIOUReportActionID, + reportPreviewAction, + transactionThreadReportID, + createdReportActionIDForThread, + onyxData, + } = getTrackExpenseInformation( + report, + participant, + comment, + amount, + currency, + currentCreated, + merchant, + receipt, + category, + tag, + billable, + policy, + policyTagList, + policyCategories, + payeeEmail, + ); + const activeReportID = report.reportID; + + const parameters: TrackExpenseParams = { + amount, + currency, + comment, + created: currentCreated, + merchant, + iouReportID: iouReport?.reportID, + chatReportID: chatReport.reportID, + transactionID: transaction.transactionID, + reportActionID: iouAction.reportActionID, + createdChatReportActionID, + createdIOUReportActionID, + reportPreviewReportActionID: reportPreviewAction?.reportActionID, + receipt, + receiptState: receipt?.state, + category, + tag, + taxCode, + taxAmount, + billable, + // This needs to be a string of JSON because of limitations with the fetch() API and nested objects + gpsPoints: gpsPoints ? JSON.stringify(gpsPoints) : undefined, + transactionThreadReportID, + createdReportActionIDForThread, + }; + + API.write(WRITE_COMMANDS.TRACK_EXPENSE, parameters, onyxData); + resetMoneyRequestInfo(); + Navigation.dismissModal(activeReportID); + Report.notifyNewAction(activeReportID, payeeAccountID); +} + /** * Build the Onyx data and IOU split necessary for splitting a bill with 3+ users. * 1. Build the optimistic Onyx data for the group chat, i.e. chatReport and iouReportAction creating the former if it doesn't yet exist. @@ -3238,6 +3849,167 @@ function deleteMoneyRequest(transactionID: string, reportAction: OnyxTypes.Repor } } +function deleteTrackExpense(chatReportID: string, transactionID: string, reportAction: OnyxTypes.ReportAction, isSingleTransactionView = false) { + // STEP 1: Get all collections we're updating + const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${chatReportID}`] ?? null; + const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; + const transactionViolations = allTransactionViolations[`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`]; + const transactionThreadID = reportAction.childReportID; + let transactionThread = null; + if (transactionThreadID) { + transactionThread = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadID}`] ?? null; + } + + // STEP 2: Decide if we need to: + // 1. Delete the transactionThread - delete if there are no visible comments in the thread + // 2. Update the moneyRequestPreview to show [Deleted request] - update if the transactionThread exists AND it isn't being deleted + const shouldDeleteTransactionThread = transactionThreadID ? (reportAction?.childVisibleActionCount ?? 0) === 0 : false; + const shouldShowDeletedRequestMessage = !!transactionThreadID && !shouldDeleteTransactionThread; + + // STEP 3: Update the IOU reportAction. + const updatedReportAction = { + [reportAction.reportActionID]: { + pendingAction: shouldShowDeletedRequestMessage ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + previousMessage: reportAction.message, + message: [ + { + type: 'COMMENT', + html: '', + text: '', + isEdited: true, + isDeletedParentAction: shouldShowDeletedRequestMessage, + }, + ], + originalMessage: { + IOUTransactionID: null, + }, + errors: undefined, + }, + } as OnyxTypes.ReportActions; + + const lastVisibleAction = ReportActionsUtils.getLastVisibleAction(chatReport?.reportID ?? '', updatedReportAction); + const reportLastMessageText = ReportActionsUtils.getLastVisibleMessage(chatReport?.reportID ?? '', updatedReportAction).lastMessageText; + + // STEP 4: Build Onyx data + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: null, + }, + ]; + + if (Permissions.canUseViolations(betas)) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: null, + }); + } + + if (shouldDeleteTransactionThread) { + optimisticData.push( + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadID}`, + value: null, + }, + ); + } + + optimisticData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: updatedReportAction, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: { + lastMessageText: reportLastMessageText, + lastVisibleActionCreated: lastVisibleAction?.created, + }, + }, + ); + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [reportAction.reportActionID]: { + pendingAction: null, + errors: null, + }, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, + value: transaction, + }, + ]; + + if (Permissions.canUseViolations(betas)) { + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: transactionViolations, + }); + } + + if (shouldDeleteTransactionThread) { + failureData.push({ + onyxMethod: Onyx.METHOD.SET, + key: `${ONYXKEYS.COLLECTION.REPORT}${transactionThreadID}`, + value: transactionThread, + }); + } + + failureData.push( + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, + value: { + [reportAction.reportActionID]: { + ...reportAction, + pendingAction: null, + errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericDeleteFailureMessage'), + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${chatReport?.reportID}`, + value: chatReport, + }, + ); + + const parameters: DeleteMoneyRequestParams = { + transactionID, + reportActionID: reportAction.reportActionID, + }; + + // STEP 6: Make the API request + API.write(WRITE_COMMANDS.DELETE_MONEY_REQUEST, parameters, {optimisticData, successData, failureData}); + CachedPDFPaths.clearByKey(transactionID); + + // STEP 7: Navigate the user depending on which page they are on and which resources were deleted + if (isSingleTransactionView && shouldDeleteTransactionThread) { + // Pop the deleted report screen before navigating. This prevents navigating to the Concierge chat due to the missing report. + Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(chatReport?.reportID ?? '')); + } +} + /** * @param managerID - Account ID of the person sending the money * @param recipient - The user receiving the money @@ -4160,9 +4932,11 @@ function setMoneyRequestParticipantsFromReport(transactionID: string, report: On // If the report is iou or expense report, we should get the chat report to set participant for request money const chatReport = ReportUtils.isMoneyRequestReport(report) ? ReportUtils.getReport(report.chatReportID) : report; const currentUserAccountID = currentUserPersonalDetails.accountID; - const participants: Participant[] = ReportUtils.isPolicyExpenseChat(chatReport) - ? [{reportID: chatReport?.reportID, isPolicyExpenseChat: true, selected: true}] - : (chatReport?.participantAccountIDs ?? []).filter((accountID) => currentUserAccountID !== accountID).map((accountID) => ({accountID, selected: true})); + const shouldAddAsReport = !isEmptyObject(chatReport) && ReportUtils.isSelfDM(chatReport); + const participants: Participant[] = + ReportUtils.isPolicyExpenseChat(chatReport) || shouldAddAsReport + ? [{reportID: chatReport?.reportID, isPolicyExpenseChat: ReportUtils.isPolicyExpenseChat(chatReport), selected: true}] + : (chatReport?.participantAccountIDs ?? []).filter((accountID) => currentUserAccountID !== accountID).map((accountID) => ({accountID, selected: true})); Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {participants, participantsAutoAssigned: true}); } @@ -4385,6 +5159,7 @@ export { setMoneyRequestParticipants, createDistanceRequest, deleteMoneyRequest, + deleteTrackExpense, splitBill, splitBillAndOpenReport, setDraftSplitTransaction, @@ -4440,6 +5215,7 @@ export { cancelPayment, navigateToStartStepIfScanFileCannotBeRead, savePreferredPaymentMethod, + trackExpense, canIOUBePaid, canApproveIOU, }; diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index 1385a99fac0f..b4fe8b3e585f 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -252,6 +252,14 @@ function updateLastAccessedWorkspace(policyID: OnyxEntry) { Onyx.set(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID, policyID); } +/** + * Checks if the currency is supported for direct reimbursement + * USD currency is the only one supported in NewDot for now + */ +function isCurrencySupportedForDirectReimbursement(currency: string) { + return currency === CONST.CURRENCY.USD; +} + /** * Check if the user has any active free policies (aka workspaces) */ @@ -471,6 +479,7 @@ function setWorkspaceAutoReporting(policyID: string, enabled: boolean, frequency }, autoReportingFrequency: policy.autoReportingFrequency ?? null, pendingFields: {autoReporting: null}, + errorFields: {autoReporting: ErrorUtils.getMicroSecondOnyxError('workflowsDelayedSubmissionPage.autoReportingErrorMessage')}, }, }, ]; @@ -511,6 +520,7 @@ function setWorkspaceAutoReportingFrequency(policyID: string, frequency: ValueOf value: { autoReportingFrequency: policy.autoReportingFrequency ?? null, pendingFields: {autoReportingFrequency: null}, + errorFields: {autoReportingFrequency: ErrorUtils.getMicroSecondOnyxError('workflowsDelayedSubmissionPage.autoReportingFrequencyErrorMessage')}, }, }, ]; @@ -551,6 +561,7 @@ function setWorkspaceAutoReportingMonthlyOffset(policyID: string, autoReportingO value: { autoReportingOffset: policy.autoReportingOffset ?? null, pendingFields: {autoReportingOffset: null}, + errorFields: {autoReportingOffset: ErrorUtils.getMicroSecondOnyxError('workflowsDelayedSubmissionPage.monthlyOffsetErrorMessage')}, }, }, ]; @@ -596,6 +607,7 @@ function setWorkspaceApprovalMode(policyID: string, approver: string, approvalMo approver: policy.approver ?? null, approvalMode: policy.approvalMode ?? null, pendingFields: {approvalMode: null}, + errorFields: {approvalMode: ErrorUtils.getMicroSecondOnyxError('workflowsApprovalPage.genericErrorMessage')}, }, }, ]; @@ -666,8 +678,8 @@ function setWorkspacePayer(policyID: string, reimburserEmail: string, reimburser API.write(WRITE_COMMANDS.SET_WORKSPACE_PAYER, params, {optimisticData, failureData, successData}); } -function clearWorkspacePayerError(policyID: string) { - Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {errorFields: {reimburserEmail: null}}); +function clearPolicyErrorField(policyID: string, fieldName: string) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {errorFields: {[fieldName]: null}}); } function setWorkspaceReimbursement(policyID: string, reimbursementChoice: ValueOf, reimburserAccountID: number, reimburserEmail: string) { @@ -1367,6 +1379,7 @@ function updateGeneralSettings(policyID: string, name: string, currency: string) }, }, ]; + const failureData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -3403,7 +3416,15 @@ function navigateWhenEnableFeature(policyID: string, featureRoute: Route) { return; } - Navigation.navigate(featureRoute); + /** + * The app needs to set a navigation action to the microtask queue, it guarantees to execute Onyx.update first, then the navigation action. + * More details - https://github.com/Expensify/App/issues/37785#issuecomment-1989056726. + */ + new Promise((resolve) => { + resolve(); + }).then(() => { + Navigation.navigate(featureRoute); + }); } function enablePolicyCategories(policyID: string, enabled: boolean) { @@ -4297,7 +4318,6 @@ export { renamePolicyCategory, clearCategoryErrors, setWorkspacePayer, - clearWorkspacePayerError, setWorkspaceReimbursement, openPolicyWorkflowsPage, setPolicyRequiresTag, @@ -4326,5 +4346,7 @@ export { setWorkspaceCurrencyDefault, setForeignCurrencyDefault, setPolicyCustomTaxName, + clearPolicyErrorField, + isCurrencySupportedForDirectReimbursement, clearPolicyDistanceRatesErrorFields, }; diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 49ecfce36cf0..97dc3a6319d0 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -1589,29 +1589,30 @@ function updateReportName(reportID: string, value: string, previousValue: string } function updateReportField(reportID: string, reportField: PolicyReportField, previousReportField: PolicyReportField) { - const recentlyUsedValues = allRecentlyUsedReportFields?.[reportField.fieldID] ?? []; + const fieldKey = ReportUtils.getReportFieldKey(reportField.fieldID); + const recentlyUsedValues = allRecentlyUsedReportFields?.[fieldKey] ?? []; const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { - reportFields: { - [reportField.fieldID]: reportField, + fieldList: { + [fieldKey]: reportField, }, pendingFields: { - [reportField.fieldID]: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + [fieldKey]: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, }, }, }, ]; - if (reportField.type === 'dropdown') { + if (reportField.type === 'dropdown' && reportField.value) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.RECENTLY_USED_REPORT_FIELDS, value: { - [reportField.fieldID]: [...new Set([...recentlyUsedValues, reportField.value])], + [fieldKey]: [...new Set([...recentlyUsedValues, reportField.value])], }, }); } @@ -1621,14 +1622,14 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { - reportFields: { - [reportField.fieldID]: previousReportField, + fieldList: { + [fieldKey]: previousReportField, }, pendingFields: { - [reportField.fieldID]: null, + [fieldKey]: null, }, errorFields: { - [reportField.fieldID]: ErrorUtils.getMicroSecondOnyxError('report.genericUpdateReportFieldFailureMessage'), + [fieldKey]: ErrorUtils.getMicroSecondOnyxError('report.genericUpdateReportFieldFailureMessage'), }, }, }, @@ -1639,7 +1640,7 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.RECENTLY_USED_REPORT_FIELDS, value: { - [reportField.fieldID]: recentlyUsedValues, + [fieldKey]: recentlyUsedValues, }, }); } @@ -1650,10 +1651,10 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, value: { pendingFields: { - [reportField.fieldID]: null, + [fieldKey]: null, }, errorFields: { - [reportField.fieldID]: null, + [fieldKey]: null, }, }, }, @@ -1661,7 +1662,7 @@ function updateReportField(reportID: string, reportField: PolicyReportField, pre const parameters = { reportID, - reportFields: JSON.stringify({[`expensify_${reportField.fieldID}`]: reportField}), + reportFields: JSON.stringify({[fieldKey]: reportField}), }; API.write(WRITE_COMMANDS.SET_REPORT_FIELD, parameters, {optimisticData, failureData, successData}); diff --git a/src/libs/actions/TaxRate.ts b/src/libs/actions/TaxRate.ts index 1bad1de0a9f5..3f2420c76f87 100644 --- a/src/libs/actions/TaxRate.ts +++ b/src/libs/actions/TaxRate.ts @@ -1,14 +1,25 @@ +import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import type {FormOnyxValues} from '@components/Form/types'; import * as API from '@libs/API'; -import type {CreatePolicyTaxParams} from '@libs/API/parameters'; +import type {CreatePolicyTaxParams, DeletePolicyTaxesParams, RenamePolicyTaxParams, SetPolicyTaxesEnabledParams, UpdatePolicyTaxValueParams} from '@libs/API/parameters'; import {WRITE_COMMANDS} from '@libs/API/types'; +import * as ValidationUtils from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import * as ErrorUtils from '@src/libs/ErrorUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {TaxRate, TaxRates} from '@src/types/onyx'; -import type {PendingAction} from '@src/types/onyx/OnyxCommon'; +import INPUT_IDS from '@src/types/form/WorkspaceNewTaxForm'; +import type {Policy, TaxRate, TaxRates} from '@src/types/onyx'; +import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type {OnyxData} from '@src/types/onyx/Request'; +let allPolicies: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + waitForCollectionCallback: true, + callback: (value) => (allPolicies = value), +}); + /** * Get tax value with percentage */ @@ -20,6 +31,34 @@ function covertTaxNameToID(name: string) { return `id_${name.toUpperCase().replaceAll(' ', '_')}`; } +/** + * Function to validate tax name + */ +const validateTaxName = (policy: Policy, values: FormOnyxValues) => { + const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.NAME]); + + const name = values[INPUT_IDS.NAME]; + if (policy?.taxRates?.taxes && ValidationUtils.isExistingTaxName(name, policy.taxRates.taxes)) { + errors[INPUT_IDS.NAME] = 'workspace.taxes.errors.taxRateAlreadyExists'; + } + + return errors; +}; + +/** + * Function to validate tax value + */ +const validateTaxValue = (values: FormOnyxValues) => { + const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.VALUE]); + + const value = values[INPUT_IDS.VALUE]; + if (!ValidationUtils.isValidPercentage(value)) { + errors[INPUT_IDS.VALUE] = 'workspace.taxes.errors.valuePercentageRange'; + } + + return errors; +}; + /** * Get new tax ID */ @@ -39,7 +78,8 @@ function getNextTaxCode(name: string, taxRates?: TaxRates): string { function createPolicyTax(policyID: string, taxRate: TaxRate) { if (!taxRate.code) { - throw new Error('Tax code is required when creating a new tax rate.'); + console.debug('Policy or tax rates not found'); + return; } const onyxData: OnyxData = { @@ -83,7 +123,7 @@ function createPolicyTax(policyID: string, taxRate: TaxRate) { taxRates: { taxes: { [taxRate.code]: { - errors: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.genericFailureMessage'), + errors: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.createFailureMessage'), }, }, }, @@ -105,7 +145,24 @@ function createPolicyTax(policyID: string, taxRate: TaxRate) { API.write(WRITE_COMMANDS.CREATE_POLICY_TAX, parameters, onyxData); } -function clearTaxRateError(policyID: string, taxID: string, pendingAction?: PendingAction) { +function clearTaxRateFieldError(policyID: string, taxID: string, field: keyof TaxRate) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + taxRates: { + taxes: { + [taxID]: { + pendingFields: { + [field]: null, + }, + errorFields: { + [field]: null, + }, + }, + }, + }, + }); +} + +function clearTaxRateError(policyID: string, taxID: string, pendingAction?: OnyxCommon.PendingAction) { if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { taxRates: { @@ -119,10 +176,288 @@ function clearTaxRateError(policyID: string, taxID: string, pendingAction?: Pend Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { taxRates: { taxes: { - [taxID]: {pendingAction: null, errors: null}, + [taxID]: {pendingAction: null, errors: null, errorFields: null}, }, }, }); } -export {createPolicyTax, clearTaxRateError, getNextTaxCode, getTaxValueWithPercentage}; +type TaxRateEnabledMap = Record>; + +function setPolicyTaxesEnabled(policyID: string, taxesIDsToUpdate: string[], isEnabled: boolean) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const originalTaxes = {...policy?.taxRates?.taxes}; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: taxesIDsToUpdate.reduce((acc, taxID) => { + acc[taxID] = { + isDisabled: !isEnabled, + pendingFields: {isDisabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errorFields: {isDisabled: null}, + }; + return acc; + }, {}), + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: taxesIDsToUpdate.reduce((acc, taxID) => { + acc[taxID] = {pendingFields: {isDisabled: null}, errorFields: {isDisabled: null}}; + return acc; + }, {}), + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: taxesIDsToUpdate.reduce((acc, taxID) => { + acc[taxID] = { + isDisabled: !!originalTaxes[taxID].isDisabled, + pendingFields: {isDisabled: null}, + errorFields: {isDisabled: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.updateFailureMessage')}, + }; + return acc; + }, {}), + }, + }, + }, + ], + }; + + const parameters = { + policyID, + taxFieldsArray: JSON.stringify(taxesIDsToUpdate.map((taxID) => ({taxCode: taxID, enabled: isEnabled}))), + } satisfies SetPolicyTaxesEnabledParams; + + API.write(WRITE_COMMANDS.SET_POLICY_TAXES_ENABLED, parameters, onyxData); +} + +type TaxRateDeleteMap = Record< + string, + | (Pick & { + errors: OnyxCommon.Errors | null; + }) + | null +>; + +function deletePolicyTaxes(policyID: string, taxesToDelete: string[]) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const policyTaxRates = policy?.taxRates?.taxes; + + if (!policyTaxRates) { + console.debug('Policy or tax rates not found'); + return; + } + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: taxesToDelete.reduce((acc, taxID) => { + acc[taxID] = {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, errors: null}; + return acc; + }, {}), + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: taxesToDelete.reduce((acc, taxID) => { + acc[taxID] = null; + return acc; + }, {}), + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: taxesToDelete.reduce((acc, taxID) => { + acc[taxID] = { + pendingAction: null, + errors: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.deleteFailureMessage'), + }; + return acc; + }, {}), + }, + }, + }, + ], + }; + + const parameters = { + policyID, + taxNames: JSON.stringify(taxesToDelete.map((taxID) => policyTaxRates[taxID].name)), + } satisfies DeletePolicyTaxesParams; + + API.write(WRITE_COMMANDS.DELETE_POLICY_TAXES, parameters, onyxData); +} + +function updatePolicyTaxValue(policyID: string, taxID: string, taxValue: number) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const originalTaxRate = {...policy?.taxRates?.taxes[taxID]}; + const stringTaxValue = `${taxValue}%`; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: { + [taxID]: { + value: stringTaxValue, + pendingFields: {value: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errorFields: {value: null}, + }, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: { + [taxID]: {pendingFields: {value: null}, errorFields: {value: null}}, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: { + [taxID]: { + value: originalTaxRate.value, + pendingFields: {value: null}, + errorFields: {value: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.updateFailureMessage')}, + }, + }, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + taxCode: taxID, + taxAmount: Number(taxValue), + } satisfies UpdatePolicyTaxValueParams; + + API.write(WRITE_COMMANDS.UPDATE_POLICY_TAX_VALUE, parameters, onyxData); +} + +function renamePolicyTax(policyID: string, taxID: string, newName: string) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + const originalTaxRate = {...policy?.taxRates?.taxes[taxID]}; + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: { + [taxID]: { + name: newName, + pendingFields: {name: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errorFields: {name: null}, + }, + }, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: { + [taxID]: {pendingFields: {name: null}, errorFields: {name: null}}, + }, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + taxRates: { + taxes: { + [taxID]: { + name: originalTaxRate.name, + pendingFields: {name: null}, + errorFields: {name: ErrorUtils.getMicroSecondOnyxError('workspace.taxes.errors.updateFailureMessage')}, + }, + }, + }, + }, + }, + ], + }; + + const parameters = { + policyID, + taxCode: taxID, + newName, + } satisfies RenamePolicyTaxParams; + + API.write(WRITE_COMMANDS.RENAME_POLICY_TAX, parameters, onyxData); +} + +export { + createPolicyTax, + getNextTaxCode, + clearTaxRateError, + clearTaxRateFieldError, + getTaxValueWithPercentage, + setPolicyTaxesEnabled, + validateTaxName, + validateTaxValue, + deletePolicyTaxes, + updatePolicyTaxValue, + renamePolicyTax, +}; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index 6655d78cb0a8..2d23edfba93f 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -30,6 +30,7 @@ import PusherUtils from '@libs/PusherUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import playSound, {SOUNDS} from '@libs/Sound'; import playSoundExcludingMobile from '@libs/Sound/playSoundExcludingMobile'; +import Visibility from '@libs/Visibility'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -489,80 +490,84 @@ const isChannelMuted = (reportId: string) => function playSoundForMessageType(pushJSON: OnyxServerUpdate[]) { const reportActionsOnly = pushJSON.filter((update) => update.key?.includes('reportActions_')); // "reportActions_5134363522480668" -> "5134363522480668" - const reportIDs = reportActionsOnly.map((value) => value.key.split('_')[1]); + const reportID = reportActionsOnly + .map((value) => value.key.split('_')[1]) + .find((reportKey) => reportKey === Navigation.getTopmostReportId() && Visibility.isVisible() && Visibility.hasFocus()); - Promise.all(reportIDs.map((reportID) => isChannelMuted(reportID))) - .then((muted) => muted.every((isMuted) => isMuted)) - .then((isSoundMuted) => { - if (isSoundMuted) { - return; + if (!reportID) { + return; + } + + isChannelMuted(reportID).then((isSoundMuted) => { + if (isSoundMuted) { + return; + } + + try { + const flatten = reportActionsOnly.flatMap((update) => { + const value = update.value as OnyxCollection; + + if (!value) { + return []; + } + + return Object.values(value); + }) as ReportAction[]; + + for (const data of flatten) { + // Someone completes a task + if (data.actionName === 'TASKCOMPLETED') { + return playSound(SOUNDS.SUCCESS); + } } - try { - const flatten = reportActionsOnly.flatMap((update) => { - const value = update.value as OnyxCollection; + const types = flatten.map((data) => data?.originalMessage).filter(Boolean) as OriginalMessage[]; - if (!value) { - return []; - } + for (const message of types) { + // someone sent money + if ('IOUDetails' in message) { + return playSound(SOUNDS.SUCCESS); + } - return Object.values(value); - }) as ReportAction[]; + // mention user + if ('html' in message && typeof message.html === 'string' && message.html.includes(`@${currentEmail}`)) { + return playSoundExcludingMobile(SOUNDS.ATTENTION); + } + + // mention @here + if ('html' in message && typeof message.html === 'string' && message.html.includes('')) { + return playSoundExcludingMobile(SOUNDS.ATTENTION); + } - for (const data of flatten) { - // Someone completes a task - if (data.actionName === 'TASKCOMPLETED') { - return playSound(SOUNDS.SUCCESS); - } + // assign a task + if ('taskReportID' in message) { + return playSound(SOUNDS.ATTENTION); } - const types = flatten.map((data) => data?.originalMessage).filter(Boolean) as OriginalMessage[]; - - for (const message of types) { - // someone sent money - if ('IOUDetails' in message) { - return playSound(SOUNDS.SUCCESS); - } - - // mention user - if ('html' in message && typeof message.html === 'string' && message.html.includes(`@${currentEmail}`)) { - return playSoundExcludingMobile(SOUNDS.ATTENTION); - } - - // mention @here - if ('html' in message && typeof message.html === 'string' && message.html.includes('')) { - return playSoundExcludingMobile(SOUNDS.ATTENTION); - } - - // assign a task - if ('taskReportID' in message) { - return playSound(SOUNDS.ATTENTION); - } - - // request money - if ('IOUTransactionID' in message) { - return playSound(SOUNDS.ATTENTION); - } - - // Someone completes a money request - if ('IOUReportID' in message) { - return playSound(SOUNDS.SUCCESS); - } - - // plain message - if ('html' in message) { - return playSoundExcludingMobile(SOUNDS.RECEIVE); - } + // request money + if ('IOUTransactionID' in message) { + return playSound(SOUNDS.ATTENTION); } - } catch (e) { - let errorMessage = String(e); - if (e instanceof Error) { - errorMessage = e.message; + + // Someone completes a money request + if ('IOUReportID' in message) { + return playSound(SOUNDS.SUCCESS); } - Log.client(`Unexpected error occurred while parsing the data to play a sound: ${errorMessage}`); + // plain message + if ('html' in message) { + return playSoundExcludingMobile(SOUNDS.RECEIVE); + } } - }); + } catch (e) { + let errorMessage = String(e); + if (e instanceof Error) { + errorMessage = e.message; + } + + Log.client(`Unexpected error occurred while parsing the data to play a sound: ${errorMessage}`); + } + }); } /** @@ -960,11 +965,9 @@ function dismissReferralBanner(type: ValueOf we're either logged-in or shown 2FA screen + // !isSignedIn - confirms we're not signed-in yet as there's possible one last step (2FA validation) + const shouldPopToTop = (autoAuthState === CONST.AUTO_AUTH_STATE.NOT_STARTED || autoAuthState === CONST.AUTO_AUTH_STATE.JUST_SIGNED_IN) && !isSignedIn; + + if (shouldPopToTop) { + Navigation.isNavigationReady().then(() => Navigation.resetToHome()); + } +} + +export default desktopLoginRedirect; diff --git a/src/libs/desktopLoginRedirect/index.ts b/src/libs/desktopLoginRedirect/index.ts new file mode 100644 index 000000000000..14f5750c3de9 --- /dev/null +++ b/src/libs/desktopLoginRedirect/index.ts @@ -0,0 +1,5 @@ +import type {AutoAuthState} from '@src/types/onyx/Session'; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function desktopLoginRedirect(autoAuthState: AutoAuthState, isSignedIn: boolean) {} +export default desktopLoginRedirect; diff --git a/src/libs/migrateOnyx.ts b/src/libs/migrateOnyx.ts index 1202275067a5..5ce899cdd316 100644 --- a/src/libs/migrateOnyx.ts +++ b/src/libs/migrateOnyx.ts @@ -1,5 +1,6 @@ import Log from './Log'; import KeyReportActionsDraftByReportActionID from './migrations/KeyReportActionsDraftByReportActionID'; +import NVPMigration from './migrations/NVPMigration'; import RemoveEmptyReportActionsDrafts from './migrations/RemoveEmptyReportActionsDrafts'; import RenameReceiptFilename from './migrations/RenameReceiptFilename'; import TransactionBackupsToCollection from './migrations/TransactionBackupsToCollection'; @@ -10,7 +11,7 @@ export default function (): Promise { return new Promise((resolve) => { // Add all migrations to an array so they are executed in order - const migrationPromises = [RenameReceiptFilename, KeyReportActionsDraftByReportActionID, TransactionBackupsToCollection, RemoveEmptyReportActionsDrafts]; + const migrationPromises = [RenameReceiptFilename, KeyReportActionsDraftByReportActionID, TransactionBackupsToCollection, RemoveEmptyReportActionsDrafts, NVPMigration]; // Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the // previous promise to finish before moving onto the next one. diff --git a/src/libs/migrations/NVPMigration.ts b/src/libs/migrations/NVPMigration.ts new file mode 100644 index 000000000000..9ab774328f78 --- /dev/null +++ b/src/libs/migrations/NVPMigration.ts @@ -0,0 +1,86 @@ +import after from 'lodash/after'; +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '@src/ONYXKEYS'; + +// These are the oldKeyName: newKeyName of the NVPs we can migrate without any processing +const migrations = { + // eslint-disable-next-line @typescript-eslint/naming-convention + nvp_lastPaymentMethod: ONYXKEYS.NVP_LAST_PAYMENT_METHOD, + isFirstTimeNewExpensifyUser: ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER, + preferredLocale: ONYXKEYS.NVP_PREFERRED_LOCALE, + preferredEmojiSkinTone: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE, + frequentlyUsedEmojis: ONYXKEYS.FREQUENTLY_USED_EMOJIS, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_blockedFromConcierge: ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE, + // eslint-disable-next-line @typescript-eslint/naming-convention + private_pushNotificationID: ONYXKEYS.NVP_PRIVATE_PUSH_NOTIFICATION_ID, + tryFocusMode: ONYXKEYS.NVP_TRY_FOCUS_MODE, + introSelected: ONYXKEYS.NVP_INTRO_SELECTED, + hasDismissedIdlePanel: ONYXKEYS.NVP_HAS_DISMISSED_IDLE_PANEL, +}; + +// This migration changes the keys of all the NVP related keys so that they are standardized +export default function () { + return new Promise((resolve) => { + // Resolve the migration when all the keys have been migrated. The number of keys is the size of the `migrations` object in addition to the ACCOUNT and OLD_POLICY_RECENTLY_USED_TAGS keys (which is why there is a +2). + const resolveWhenDone = after(Object.entries(migrations).length + 2, () => resolve()); + + for (const [oldKey, newKey] of Object.entries(migrations)) { + const connectionID = Onyx.connect({ + // @ts-expect-error oldKey is a variable + key: oldKey, + callback: (value) => { + Onyx.disconnect(connectionID); + if (value === null) { + resolveWhenDone(); + return; + } + // @ts-expect-error These keys are variables, so we can't check the type + Onyx.multiSet({ + [newKey]: value, + [oldKey]: null, + }).then(resolveWhenDone); + }, + }); + } + const connectionIDAccount = Onyx.connect({ + key: ONYXKEYS.ACCOUNT, + callback: (value) => { + Onyx.disconnect(connectionIDAccount); + // @ts-expect-error we are removing this property, so it is not in the type anymore + if (!value?.activePolicyID) { + resolveWhenDone(); + return; + } + // @ts-expect-error we are removing this property, so it is not in the type anymore + const activePolicyID = value.activePolicyID; + const newValue = {...value}; + // @ts-expect-error we are removing this property, so it is not in the type anymore + delete newValue.activePolicyID; + Onyx.multiSet({ + [ONYXKEYS.NVP_ACTIVE_POLICY_ID]: activePolicyID, + [ONYXKEYS.ACCOUNT]: newValue, + }).then(resolveWhenDone); + }, + }); + const connectionIDRecentlyUsedTags = Onyx.connect({ + key: ONYXKEYS.COLLECTION.OLD_POLICY_RECENTLY_USED_TAGS, + waitForCollectionCallback: true, + callback: (value) => { + Onyx.disconnect(connectionIDRecentlyUsedTags); + if (!value) { + resolveWhenDone(); + return; + } + const newValue = {}; + for (const key of Object.keys(value)) { + // @ts-expect-error We have no fixed types here + newValue[`nvp_${key}`] = value[key]; + // @ts-expect-error We have no fixed types here + newValue[key] = null; + } + Onyx.multiSet(newValue).then(resolveWhenDone); + }, + }); + }); +} diff --git a/src/pages/EditReportFieldDatePage.tsx b/src/pages/EditReportFieldDatePage.tsx index 45d2f31073ec..3d60884d3cfc 100644 --- a/src/pages/EditReportFieldDatePage.tsx +++ b/src/pages/EditReportFieldDatePage.tsx @@ -19,8 +19,8 @@ type EditReportFieldDatePageProps = { /** Name of the policy report field */ fieldName: string; - /** ID of the policy report field */ - fieldID: string; + /** Key of the policy report field */ + fieldKey: string; /** Flag to indicate if the field can be left blank */ isRequired: boolean; @@ -29,7 +29,7 @@ type EditReportFieldDatePageProps = { onSubmit: (form: FormOnyxValues) => void; }; -function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, fieldID}: EditReportFieldDatePageProps) { +function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, fieldKey}: EditReportFieldDatePageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const inputRef = useRef(null); @@ -37,12 +37,12 @@ function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, f const validate = useCallback( (value: FormOnyxValues) => { const errors: FormInputErrors = {}; - if (isRequired && value[fieldID].trim() === '') { - errors[fieldID] = 'common.error.fieldRequired'; + if (isRequired && value[fieldKey].trim() === '') { + errors[fieldKey] = 'common.error.fieldRequired'; } return errors; }, - [fieldID, isRequired], + [fieldKey, isRequired], ); return ( @@ -67,8 +67,8 @@ function EditReportFieldDatePage({fieldName, isRequired, onSubmit, fieldValue, f {/* @ts-expect-error TODO: Remove this once DatePicker (https://github.com/Expensify/App/issues/25148) is migrated to TypeScript. */} InputComponent={DatePicker} - inputID={fieldID} - name={fieldID} + inputID={fieldKey} + name={fieldKey} defaultValue={fieldValue} label={fieldName} accessibilityLabel={fieldName} diff --git a/src/pages/EditReportFieldDropdownPage.tsx b/src/pages/EditReportFieldDropdownPage.tsx index 1ad3c766221b..a314120fb0c6 100644 --- a/src/pages/EditReportFieldDropdownPage.tsx +++ b/src/pages/EditReportFieldDropdownPage.tsx @@ -17,8 +17,8 @@ type EditReportFieldDropdownPageComponentProps = { /** Name of the policy report field */ fieldName: string; - /** ID of the policy report field */ - fieldID: string; + /** Key of the policy report field */ + fieldKey: string; /** ID of the policy this report field belongs to */ // eslint-disable-next-line react/no-unused-prop-types @@ -37,12 +37,12 @@ type EditReportFieldDropdownPageOnyxProps = { type EditReportFieldDropdownPageProps = EditReportFieldDropdownPageComponentProps & EditReportFieldDropdownPageOnyxProps; -function EditReportFieldDropdownPage({fieldName, onSubmit, fieldID, fieldValue, fieldOptions, recentlyUsedReportFields}: EditReportFieldDropdownPageProps) { +function EditReportFieldDropdownPage({fieldName, onSubmit, fieldKey, fieldValue, fieldOptions, recentlyUsedReportFields}: EditReportFieldDropdownPageProps) { const [searchValue, setSearchValue] = useState(''); const styles = useThemeStyles(); const {getSafeAreaMargins} = useStyleUtils(); const {translate} = useLocalize(); - const recentlyUsedOptions = useMemo(() => recentlyUsedReportFields?.[fieldID] ?? [], [recentlyUsedReportFields, fieldID]); + const recentlyUsedOptions = useMemo(() => recentlyUsedReportFields?.[fieldKey] ?? [], [recentlyUsedReportFields, fieldKey]); const [headerMessage, setHeaderMessage] = useState(''); const sections = useMemo(() => { @@ -93,7 +93,11 @@ function EditReportFieldDropdownPage({fieldName, onSubmit, fieldID, fieldValue, boldStyle sections={sections} value={searchValue} - onSelectRow={(option: Record) => onSubmit({[fieldID]: option.text})} + onSelectRow={(option: Record) => + onSubmit({ + [fieldKey]: fieldValue === option.text ? '' : option.text, + }) + } onChangeText={setSearchValue} highlightSelectedOptions isRowMultilineSupported diff --git a/src/pages/EditReportFieldPage.tsx b/src/pages/EditReportFieldPage.tsx index 4124a9ebef98..8c8376468c0f 100644 --- a/src/pages/EditReportFieldPage.tsx +++ b/src/pages/EditReportFieldPage.tsx @@ -9,7 +9,7 @@ import Navigation from '@libs/Navigation/Navigation'; import * as ReportUtils from '@libs/ReportUtils'; import * as ReportActions from '@src/libs/actions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Policy, PolicyReportFields, Report} from '@src/types/onyx'; +import type {Policy, Report} from '@src/types/onyx'; import EditReportFieldDatePage from './EditReportFieldDatePage'; import EditReportFieldDropdownPage from './EditReportFieldDropdownPage'; import EditReportFieldTextPage from './EditReportFieldTextPage'; @@ -18,9 +18,6 @@ type EditReportFieldPageOnyxProps = { /** The report object for the expense report */ report: OnyxEntry; - /** Policy report fields */ - policyReportFields: OnyxEntry; - /** Policy to which the report belongs to */ policy: OnyxEntry; }; @@ -42,8 +39,9 @@ type EditReportFieldPageProps = EditReportFieldPageOnyxProps & { }; }; -function EditReportFieldPage({route, policy, report, policyReportFields}: EditReportFieldPageProps) { - const reportField = report?.reportFields?.[route.params.fieldID] ?? policyReportFields?.[route.params.fieldID]; +function EditReportFieldPage({route, policy, report}: EditReportFieldPageProps) { + const fieldKey = ReportUtils.getReportFieldKey(route.params.fieldID); + const reportField = report?.fieldList?.[fieldKey] ?? policy?.fieldList?.[fieldKey]; const isDisabled = ReportUtils.isReportFieldDisabled(report, reportField ?? null, policy); if (!reportField || !report || isDisabled) { @@ -65,11 +63,11 @@ function EditReportFieldPage({route, policy, report, policyReportFields}: EditRe const isReportFieldTitle = ReportUtils.isReportFieldOfTypeTitle(reportField); const handleReportFieldChange = (form: FormOnyxValues) => { - const value = form[reportField.fieldID] || ''; + const value = form[fieldKey]; if (isReportFieldTitle) { ReportActions.updateReportName(report.reportID, value, report.reportName ?? ''); } else { - ReportActions.updateReportField(report.reportID, {...reportField, value}, reportField); + ReportActions.updateReportField(report.reportID, {...reportField, value: value === '' ? null : value}, reportField); } Navigation.dismissModal(report?.reportID); @@ -81,7 +79,7 @@ function EditReportFieldPage({route, policy, report, policyReportFields}: EditRe return ( !(value in reportField.disabledOptions))} onSubmit={handleReportFieldChange} /> ); @@ -121,9 +119,6 @@ export default withOnyx( report: { key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`, }, - policyReportFields: { - key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS}${route.params.policyID}`, - }, policy: { key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY}${route.params.policyID}`, }, diff --git a/src/pages/EditReportFieldTextPage.tsx b/src/pages/EditReportFieldTextPage.tsx index 9cda559280a9..1a6cf96fb37a 100644 --- a/src/pages/EditReportFieldTextPage.tsx +++ b/src/pages/EditReportFieldTextPage.tsx @@ -19,8 +19,8 @@ type EditReportFieldTextPageProps = { /** Name of the policy report field */ fieldName: string; - /** ID of the policy report field */ - fieldID: string; + /** Key of the policy report field */ + fieldKey: string; /** Flag to indicate if the field can be left blank */ isRequired: boolean; @@ -29,7 +29,7 @@ type EditReportFieldTextPageProps = { onSubmit: (form: FormOnyxValues) => void; }; -function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, fieldID}: EditReportFieldTextPageProps) { +function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, fieldKey}: EditReportFieldTextPageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const inputRef = useRef(null); @@ -37,12 +37,12 @@ function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, f const validate = useCallback( (values: FormOnyxValues) => { const errors: FormInputErrors = {}; - if (isRequired && values[fieldID].trim() === '') { - errors[fieldID] = 'common.error.fieldRequired'; + if (isRequired && values[fieldKey].trim() === '') { + errors[fieldKey] = 'common.error.fieldRequired'; } return errors; }, - [fieldID, isRequired], + [fieldKey, isRequired], ); return ( @@ -66,8 +66,8 @@ function EditReportFieldTextPage({fieldName, onSubmit, fieldValue, isRequired, f ; /** An object that holds data about which referral banners have been dismissed */ - dismissedReferralBanners: DismissedReferralBanners; + dismissedReferralBanners: OnyxEntry; /** Whether we are searching for reports in the server */ isSearchingForReports: OnyxEntry; @@ -265,7 +264,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, isSearchingF shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} shouldShowOptions={isOptionsDataReady && didScreenTransitionEnd} shouldShowConfirmButton - shouldShowReferralCTA={!dismissedReferralBanners[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]} + shouldShowReferralCTA={!dismissedReferralBanners?.[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]} referralContentType={CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT} confirmButtonText={selectedOptions.length > 1 ? translate('newChatPage.createGroup') : translate('newChatPage.createChat')} textInputAlert={isOffline ? [`${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}`, {isTranslated: true}] : ''} @@ -287,8 +286,7 @@ NewChatPage.displayName = 'NewChatPage'; export default withOnyx({ dismissedReferralBanners: { - key: ONYXKEYS.ACCOUNT, - selector: (data) => data?.dismissedReferralBanners ?? {}, + key: ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS, }, reports: { key: ONYXKEYS.COLLECTION.REPORT, diff --git a/src/pages/ValidateLoginPage/index.website.tsx b/src/pages/ValidateLoginPage/index.website.tsx index 2acad7815754..b8e8709215e8 100644 --- a/src/pages/ValidateLoginPage/index.website.tsx +++ b/src/pages/ValidateLoginPage/index.website.tsx @@ -4,6 +4,7 @@ import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import ExpiredValidateCodeModal from '@components/ValidateCode/ExpiredValidateCodeModal'; import JustSignedInModal from '@components/ValidateCode/JustSignedInModal'; import ValidateCodeModal from '@components/ValidateCode/ValidateCodeModal'; +import desktopLoginRedirect from '@libs/desktopLoginRedirect'; import Navigation from '@libs/Navigation/Navigation'; import * as Session from '@userActions/Session'; import CONST from '@src/CONST'; @@ -43,6 +44,11 @@ function ValidateLoginPage({ // The user has initiated the sign in process on the same browser, in another tab. Session.signInWithValidateCode(Number(accountID), validateCode); + + // Since on Desktop we don't have multi-tab functionality to handle the login flow, + // we need to `popToTop` the stack after `signInWithValidateCode` in order to + // perform login for both 2FA and non-2FA accounts. + desktopLoginRedirect(autoAuthState, isSignedIn); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 009d55e6d139..e22b7c6cf1bc 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -171,7 +171,7 @@ function ReportScreen({ managerID: reportProp?.managerID, total: reportProp?.total, nonReimbursableTotal: reportProp?.nonReimbursableTotal, - reportFields: reportProp?.reportFields, + fieldList: reportProp?.fieldList, ownerAccountID: reportProp?.ownerAccountID, currency: reportProp?.currency, participantAccountIDs: reportProp?.participantAccountIDs, @@ -208,7 +208,7 @@ function ReportScreen({ reportProp?.managerID, reportProp?.total, reportProp?.nonReimbursableTotal, - reportProp?.reportFields, + reportProp?.fieldList, reportProp?.ownerAccountID, reportProp?.currency, reportProp?.participantAccountIDs, @@ -255,7 +255,7 @@ function ReportScreen({ : null, [reportActions, parentReportAction], ); - const isSingleTransactionView = ReportUtils.isMoneyRequest(report); + const isSingleTransactionView = ReportUtils.isMoneyRequest(report) || ReportUtils.isTrackExpenseReport(report); const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`] ?? null; const isTopMostReportId = currentReportID === getReportID(route); const didSubscribeToReportLeavingEvents = useRef(false); diff --git a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx index 793fbf9b1e7e..9bf32bb92b35 100644 --- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx +++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.tsx @@ -12,7 +12,6 @@ import calculateAnchorPosition from '@libs/calculateAnchorPosition'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as IOU from '@userActions/IOU'; import * as Report from '@userActions/Report'; -import CONST from '@src/CONST'; import type {ReportAction} from '@src/types/onyx'; import BaseReportActionContextMenu from './BaseReportActionContextMenu'; import type {ContextMenuAction} from './ContextMenuActions'; @@ -256,8 +255,12 @@ function PopoverReportActionContextMenu(_props: unknown, ref: ForwardedRef { callbackWhenDeleteModalHide.current = () => (onComfirmDeleteModal.current = runAndResetCallback(onComfirmDeleteModal.current)); const reportAction = reportActionRef.current; - if (ReportActionsUtils.isMoneyRequestAction(reportAction) && reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU) { - IOU.deleteMoneyRequest(reportAction?.originalMessage?.IOUTransactionID ?? '', reportAction); + if (ReportActionsUtils.isMoneyRequestAction(reportAction)) { + if (ReportActionsUtils.isTrackExpenseAction(reportAction)) { + IOU.deleteTrackExpense(reportIDRef.current, reportAction?.originalMessage?.IOUTransactionID ?? '', reportAction); + } else { + IOU.deleteMoneyRequest(reportAction?.originalMessage?.IOUTransactionID ?? '', reportAction); + } } else if (reportAction) { Report.deleteReportComment(reportIDRef.current, reportAction); } diff --git a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx index 6a21845f47ad..95533db02f06 100644 --- a/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx +++ b/src/pages/home/report/ReportActionCompose/AttachmentPickerWithMenuItems.tsx @@ -13,6 +13,7 @@ import PopoverMenu from '@components/PopoverMenu'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; import usePrevious from '@hooks/usePrevious'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -115,6 +116,7 @@ function AttachmentPickerWithMenuItems({ const styles = useThemeStyles(); const {translate} = useLocalize(); const {windowHeight} = useWindowDimensions(); + const {canUseTrackExpense} = usePermissions(); /** * Returns the list of IOU Options @@ -136,12 +138,17 @@ function AttachmentPickerWithMenuItems({ text: translate('iou.sendMoney'), onSelected: () => IOU.startMoneyRequest(CONST.IOU.TYPE.SEND, report?.reportID ?? ''), }, + [CONST.IOU.TYPE.TRACK_EXPENSE]: { + icon: Expensicons.DocumentPlus, + text: translate('iou.trackExpense'), + onSelected: () => IOU.startMoneyRequest(CONST.IOU.TYPE.TRACK_EXPENSE, report?.reportID ?? ''), + }, }; - return ReportUtils.getMoneyRequestOptions(report, policy, reportParticipantIDs ?? []).map((option) => ({ + return ReportUtils.getMoneyRequestOptions(report, policy, reportParticipantIDs ?? [], canUseTrackExpense).map((option) => ({ ...options[option], })); - }, [report, policy, reportParticipantIDs, translate]); + }, [translate, report, policy, reportParticipantIDs, canUseTrackExpense]); /** * Determines if we can show the task option diff --git a/src/pages/home/report/ReportActionItem.tsx b/src/pages/home/report/ReportActionItem.tsx index f0280b27efa0..08a7836f928d 100644 --- a/src/pages/home/report/ReportActionItem.tsx +++ b/src/pages/home/report/ReportActionItem.tsx @@ -58,6 +58,7 @@ import * as ReportActions from '@userActions/ReportActions'; import * as Session from '@userActions/Session'; import * as User from '@userActions/User'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; @@ -97,9 +98,6 @@ type ReportActionItemOnyxProps = { /** The user's wallet account */ userWallet: OnyxEntry; - /** All policy report fields */ - policyReportFields: OnyxEntry; - /** The policy which the user has access to and which the report is tied to */ policy: OnyxEntry; }; @@ -156,7 +154,6 @@ function ReportActionItem({ userWallet, shouldHideThreadDividerLine = false, shouldShowSubscriptAvatar = false, - policyReportFields, policy, onPress = undefined, }: ReportActionItemProps) { @@ -414,7 +411,10 @@ function ReportActionItem({ isIOUReport(action) && action.originalMessage && // For the pay flow, we only want to show MoneyRequestAction when sending money. When paying, we display a regular system message - (action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT || isSendingMoney) + (action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.CREATE || + action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT || + action.originalMessage.type === CONST.IOU.REPORT_ACTION_TYPE.TRACK || + isSendingMoney) ) { // There is no single iouReport for bill splits, so only 1:1 requests require an iouReportID const iouReportID = action.originalMessage.IOUReportID ? action.originalMessage.IOUReportID.toString() : '0'; @@ -667,6 +667,14 @@ function ReportActionItem({ if (ReportActionsUtils.isTransactionThread(parentReportAction)) { const isReversedTransaction = ReportActionsUtils.isReversedTransaction(parentReportAction); if (ReportActionsUtils.isDeletedParentAction(parentReportAction) || isReversedTransaction) { + let message: TranslationPaths; + if (isReversedTransaction) { + message = 'parentReportAction.reversedTransaction'; + } else if (ReportActionsUtils.isTrackExpenseAction(parentReportAction)) { + message = 'parentReportAction.deletedExpense'; + } else { + message = 'parentReportAction.deletedRequest'; + } return ( @@ -677,9 +685,7 @@ function ReportActionItem({ showHeader report={report} > - ${translate(isReversedTransaction ? 'parentReportAction.reversedTransaction' : 'parentReportAction.deletedRequest')}`} - /> + ${translate(message)}`} /> @@ -734,7 +740,6 @@ function ReportActionItem({ @@ -881,10 +886,6 @@ export default withOnyx({ }, initialValue: {} as OnyxTypes.Report, }, - policyReportFields: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY_REPORT_FIELDS}${report.policyID ?? 0}`, - initialValue: {}, - }, policy: { key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID ?? 0}`, initialValue: {} as OnyxTypes.Policy, @@ -925,8 +926,7 @@ export default withOnyx({ prevProps.report?.total === nextProps.report?.total && prevProps.report?.nonReimbursableTotal === nextProps.report?.nonReimbursableTotal && prevProps.linkedReportActionID === nextProps.linkedReportActionID && - lodashIsEqual(prevProps.policyReportFields, nextProps.policyReportFields) && - lodashIsEqual(prevProps.report.reportFields, nextProps.report.reportFields) && + lodashIsEqual(prevProps.report.fieldList, nextProps.report.fieldList) && lodashIsEqual(prevProps.policy, nextProps.policy) && lodashIsEqual(prevParentReportAction, nextParentReportAction) ); diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js index ec27112ab4b7..abf932eff96d 100644 --- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js +++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js @@ -9,6 +9,7 @@ import withNavigation from '@components/withNavigation'; import withNavigationFocus from '@components/withNavigationFocus'; import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; @@ -75,6 +76,7 @@ function FloatingActionButtonAndPopover(props) { const {translate} = useLocalize(); const [isCreateMenuActive, setIsCreateMenuActive] = useState(false); const fabRef = useRef(null); + const {canUseTrackExpense} = usePermissions(); const prevIsFocused = usePrevious(props.isFocused); @@ -187,13 +189,28 @@ function FloatingActionButtonAndPopover(props) { ), ), }, - ...[ - { - icon: Expensicons.Task, - text: translate('newTaskPage.assignTask'), - onSelected: () => interceptAnonymousUser(() => Task.clearOutTaskInfoAndNavigate()), - }, - ], + ...(canUseTrackExpense + ? [ + { + icon: Expensicons.DocumentPlus, + text: translate('iou.trackExpense'), + onSelected: () => + interceptAnonymousUser(() => + IOU.startMoneyRequest( + CONST.IOU.TYPE.TRACK_EXPENSE, + // When starting to create a track expense from the global FAB, we need to retrieve selfDM reportID. + // If it doesn't exist, we generate a random optimistic reportID and use it for all of the routes in the creation flow. + ReportUtils.findSelfDMReportID() || ReportUtils.generateReportID(), + ), + ), + }, + ] + : []), + { + icon: Expensicons.Task, + text: translate('newTaskPage.assignTask'), + onSelected: () => interceptAnonymousUser(() => Task.clearOutTaskInfoAndNavigate()), + }, { icon: Expensicons.Heart, text: translate('sidebarScreen.saveTheWorld'), diff --git a/src/pages/iou/request/IOURequestStartPage.js b/src/pages/iou/request/IOURequestStartPage.js index 589808824285..cb078fac133c 100644 --- a/src/pages/iou/request/IOURequestStartPage.js +++ b/src/pages/iou/request/IOURequestStartPage.js @@ -78,6 +78,7 @@ function IOURequestStartPage({ [CONST.IOU.TYPE.REQUEST]: translate('iou.requestMoney'), [CONST.IOU.TYPE.SEND]: translate('iou.sendMoney'), [CONST.IOU.TYPE.SPLIT]: translate('iou.splitBill'), + [CONST.IOU.TYPE.TRACK_EXPENSE]: translate('iou.trackExpense'), }; const transactionRequestType = useRef(TransactionUtils.getRequestType(transaction)); const previousIOURequestType = usePrevious(transactionRequestType.current); @@ -109,7 +110,7 @@ function IOURequestStartPage({ const isExpenseChat = ReportUtils.isPolicyExpenseChat(report); const isExpenseReport = ReportUtils.isExpenseReport(report); - const shouldDisplayDistanceRequest = canUseP2PDistanceRequests || isExpenseChat || isExpenseReport || isFromGlobalCreate; + const shouldDisplayDistanceRequest = iouType !== CONST.IOU.TYPE.TRACK_EXPENSE && (canUseP2PDistanceRequests || isExpenseChat || isExpenseReport || isFromGlobalCreate); // Allow the user to create the request if we are creating the request in global menu or the report can create the request const isAllowedToCreateRequest = _.isEmpty(report.reportID) || ReportUtils.canCreateRequest(report, policy, iouType); @@ -157,7 +158,7 @@ function IOURequestStartPage({ title={tabTitles[iouType]} onBackButtonPress={navigateBack} /> - {iouType === CONST.IOU.TYPE.REQUEST || iouType === CONST.IOU.TYPE.SPLIT ? ( + {iouType !== CONST.IOU.TYPE.SEND ? ( { - onParticipantsAdded([ - { - ..._.pick(option, 'accountID', 'login', 'isPolicyExpenseChat', 'reportID', 'searchText'), - selected: true, - }, - ]); - onFinish(); - }; + const addSingleParticipant = useCallback( + (option) => { + onParticipantsAdded([ + { + ..._.pick(option, 'accountID', 'login', 'isPolicyExpenseChat', 'reportID', 'searchText'), + selected: true, + }, + ]); + onFinish(); + }, + [onFinish, onParticipantsAdded], + ); /** * Removes a selected option from list if already selected. If not already selected add this option to the list. @@ -257,13 +260,22 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({ const shouldShowSplitBillErrorMessage = participants.length > 1 && hasPolicyExpenseChatParticipant; const isAllowedToSplit = (canUseP2PDistanceRequests || iouRequestType !== CONST.IOU.REQUEST_TYPE.DISTANCE) && iouType !== CONST.IOU.TYPE.SEND; - const handleConfirmSelection = useCallback(() => { - if (shouldShowSplitBillErrorMessage) { - return; - } + const handleConfirmSelection = useCallback( + (keyEvent, option) => { + const shouldAddSingleParticipant = option && !participants.length; + if (shouldShowSplitBillErrorMessage || (!participants.length && !option)) { + return; + } - onFinish(CONST.IOU.TYPE.SPLIT); - }, [shouldShowSplitBillErrorMessage, onFinish]); + if (shouldAddSingleParticipant) { + addSingleParticipant(option); + return; + } + + onFinish(CONST.IOU.TYPE.SPLIT); + }, + [shouldShowSplitBillErrorMessage, onFinish, addSingleParticipant, participants], + ); const footerContent = useMemo( () => ( @@ -360,8 +372,7 @@ MoneyTemporaryForRefactorRequestParticipantsSelector.displayName = 'MoneyTempora export default withOnyx({ dismissedReferralBanners: { - key: ONYXKEYS.ACCOUNT, - selector: (data) => data.dismissedReferralBanners || {}, + key: ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS, }, reports: { key: ONYXKEYS.COLLECTION.REPORT, diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.js b/src/pages/iou/request/step/IOURequestStepConfirmation.js index 2c869354d96f..3dd6f08c0ce0 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.js +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.js @@ -100,6 +100,9 @@ function IOURequestStepConfirmation({ if (iouType === CONST.IOU.TYPE.SPLIT) { return translate('iou.split'); } + if (iouType === CONST.IOU.TYPE.TRACK_EXPENSE) { + return translate('iou.trackExpense'); + } if (iouType === CONST.IOU.TYPE.SEND) { return translate('common.send'); } @@ -109,8 +112,8 @@ function IOURequestStepConfirmation({ const participants = useMemo( () => _.map(transaction.participants, (participant) => { - const isPolicyExpenseChat = lodashGet(participant, 'isPolicyExpenseChat', false); - return isPolicyExpenseChat ? OptionsListUtils.getPolicyExpenseReportOption(participant) : OptionsListUtils.getParticipantsOption(participant, personalDetails); + const participantReportID = lodashGet(participant, 'reportID', ''); + return participantReportID ? OptionsListUtils.getReportOption(participant) : OptionsListUtils.getParticipantsOption(participant, personalDetails); }), [transaction.participants, personalDetails], ); @@ -131,7 +134,7 @@ function IOURequestStepConfirmation({ if (policyExpenseChat) { Policy.openDraftWorkspaceRequest(policyExpenseChat.policyID); } - }, [participants, transaction.billable, policy, transactionID]); + }, [isOffline, participants, transaction.billable, policy, transactionID]); const defaultBillable = lodashGet(policy, 'defaultBillable', false); useEffect(() => { @@ -187,13 +190,6 @@ function IOURequestStepConfirmation({ IOU.navigateToStartStepIfScanFileCannotBeRead(receiptFilename, receiptPath, onSuccess, requestType, iouType, transactionID, reportID, receiptType); }, [receiptType, receiptPath, receiptFilename, requestType, iouType, transactionID, reportID]); - useEffect(() => { - const policyExpenseChat = _.find(participants, (participant) => participant.isPolicyExpenseChat); - if (policyExpenseChat) { - Policy.openDraftWorkspaceRequest(policyExpenseChat.policyID); - } - }, [isOffline, participants, transaction.billable, policy]); - /** * @param {Array} selectedParticipants * @param {String} trimmedComment @@ -226,6 +222,54 @@ function IOURequestStepConfirmation({ [report, transaction, transactionTaxCode, transactionTaxAmount, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, policy, policyTags, policyCategories], ); + /** + * @param {Array} selectedParticipants + * @param {String} trimmedComment + * @param {File} [receiptObj] + */ + const trackExpense = useCallback( + (selectedParticipants, trimmedComment, receiptObj, gpsPoints) => { + IOU.trackExpense( + report, + transaction.amount, + transaction.currency, + transaction.created, + transaction.merchant, + currentUserPersonalDetails.login, + currentUserPersonalDetails.accountID, + selectedParticipants[0], + trimmedComment, + receiptObj, + transaction.category, + transaction.tag, + transactionTaxCode, + transactionTaxAmount, + transaction.billable, + policy, + policyTags, + policyCategories, + gpsPoints, + ); + }, + [ + report, + transaction.amount, + transaction.currency, + transaction.created, + transaction.merchant, + transaction.category, + transaction.tag, + transaction.billable, + currentUserPersonalDetails.login, + currentUserPersonalDetails.accountID, + transactionTaxCode, + transactionTaxAmount, + policy, + policyTags, + policyCategories, + ], + ); + /** * @param {Array} selectedParticipants * @param {String} trimmedComment @@ -319,6 +363,41 @@ function IOURequestStepConfirmation({ return; } + if (iouType === CONST.IOU.TYPE.TRACK_EXPENSE) { + if (receiptFile) { + // If the transaction amount is zero, then the money is being requested through the "Scan" flow and the GPS coordinates need to be included. + if (transaction.amount === 0) { + getCurrentPosition( + (successData) => { + trackExpense(selectedParticipants, trimmedComment, receiptFile, { + lat: successData.coords.latitude, + long: successData.coords.longitude, + }); + }, + (errorData) => { + Log.info('[IOURequestStepConfirmation] getCurrentPosition failed', false, errorData); + // When there is an error, the money can still be requested, it just won't include the GPS coordinates + trackExpense(selectedParticipants, trimmedComment, receiptFile); + }, + { + // It's OK to get a cached location that is up to an hour old because the only accuracy needed is the country the user is in + maximumAge: 1000 * 60 * 60, + + // 15 seconds, don't wait too long because the server can always fall back to using the IP address + timeout: 15000, + }, + ); + return; + } + + // Otherwise, the money is being requested through the "Manual" flow with an attached image and the GPS coordinates are not needed. + trackExpense(selectedParticipants, trimmedComment, receiptFile); + return; + } + trackExpense(selectedParticipants, trimmedComment, receiptFile); + return; + } + if (receiptFile) { // If the transaction amount is zero, then the money is being requested through the "Scan" flow and the GPS coordinates need to be included. if (transaction.amount === 0) { @@ -357,7 +436,18 @@ function IOURequestStepConfirmation({ requestMoney(selectedParticipants, trimmedComment); }, - [iouType, transaction, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, report, requestType, createDistanceRequest, requestMoney, receiptFile], + [ + transaction, + iouType, + receiptFile, + requestType, + requestMoney, + currentUserPersonalDetails.login, + currentUserPersonalDetails.accountID, + report.reportID, + trackExpense, + createDistanceRequest, + ], ); /** @@ -417,7 +507,7 @@ function IOURequestStepConfirmation({ { - if (participants.length) { - return; - } - onAddParticipants( - [ - { - accountID: option.accountID, - login: option.login, - isPolicyExpenseChat: option.isPolicyExpenseChat, - reportID: option.reportID, - selected: true, - searchText: option.searchText, - }, - ], - false, - ); - navigateToRequest(); - }; + const addSingleParticipant = useCallback( + (option) => { + if (participants.length) { + return; + } + onAddParticipants( + [ + { + accountID: option.accountID, + login: option.login, + isPolicyExpenseChat: option.isPolicyExpenseChat, + reportID: option.reportID, + selected: true, + searchText: option.searchText, + }, + ], + false, + ); + navigateToRequest(); + }, + [navigateToRequest, onAddParticipants, participants.length], + ); /** * Removes a selected option from list if already selected. If not already selected add this option to the list. @@ -275,13 +278,23 @@ function MoneyRequestParticipantsSelector({ const shouldShowSplitBillErrorMessage = participants.length > 1 && hasPolicyExpenseChatParticipant; const isAllowedToSplit = (canUseP2PDistanceRequests || !isDistanceRequest) && iouType !== CONST.IOU.TYPE.SEND; - const handleConfirmSelection = useCallback(() => { - if (shouldShowSplitBillErrorMessage) { - return; - } + const handleConfirmSelection = useCallback( + (keyEvent, option) => { + const shouldAddSingleParticipant = option && !participants.length; - navigateToSplit(); - }, [shouldShowSplitBillErrorMessage, navigateToSplit]); + if (shouldShowSplitBillErrorMessage || (!participants.length && !option)) { + return; + } + + if (shouldAddSingleParticipant) { + addSingleParticipant(option); + return; + } + + navigateToSplit(); + }, + [shouldShowSplitBillErrorMessage, navigateToSplit, addSingleParticipant, participants.length], + ); const footerContent = useMemo( () => ( @@ -373,8 +386,7 @@ MoneyRequestParticipantsSelector.defaultProps = defaultProps; export default withOnyx({ dismissedReferralBanners: { - key: ONYXKEYS.ACCOUNT, - selector: (data) => data.dismissedReferralBanners || {}, + key: ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS, }, reports: { key: ONYXKEYS.COLLECTION.REPORT, diff --git a/src/pages/workspace/FeatureEnabledAccessOrNotFoundWrapper.tsx b/src/pages/workspace/FeatureEnabledAccessOrNotFoundWrapper.tsx new file mode 100644 index 000000000000..3bcdc1fe3303 --- /dev/null +++ b/src/pages/workspace/FeatureEnabledAccessOrNotFoundWrapper.tsx @@ -0,0 +1,74 @@ +/* eslint-disable rulesdir/no-negated-variables */ +import React, {useEffect} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import * as Policy from '@userActions/Policy'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; +import type {PolicyFeatureName} from '@src/types/onyx/Policy'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type FeatureEnabledAccessOrNotFoundOnyxProps = { + /** The report currently being looked at */ + policy: OnyxEntry; + + /** Indicated whether the report data is loading */ + isLoadingReportData: OnyxEntry; +}; + +type FeatureEnabledAccessOrNotFoundComponentProps = FeatureEnabledAccessOrNotFoundOnyxProps & { + /** The children to render */ + children: ((props: FeatureEnabledAccessOrNotFoundOnyxProps) => React.ReactNode) | React.ReactNode; + + /** The report currently being looked at */ + policyID: string; + + /** The current feature name that the user tries to get access */ + featureName: PolicyFeatureName; +}; + +function FeatureEnabledAccessOrNotFoundComponent(props: FeatureEnabledAccessOrNotFoundComponentProps) { + const isPolicyIDInRoute = !!props.policyID?.length; + const shouldShowFullScreenLoadingIndicator = props.isLoadingReportData !== false && (!Object.entries(props.policy ?? {}).length || !props.policy?.id); + const shouldShowNotFoundPage = isEmptyObject(props.policy) || !props.policy?.id || !PolicyUtils.isPolicyFeatureEnabled(props.policy, props.featureName); + + useEffect(() => { + if (!isPolicyIDInRoute || !isEmptyObject(props.policy)) { + // If the workspace is not required or is already loaded, we don't need to call the API + return; + } + + Policy.openWorkspace(props.policyID, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isPolicyIDInRoute, props.policyID]); + + if (shouldShowFullScreenLoadingIndicator) { + return ; + } + + if (shouldShowNotFoundPage) { + return ( + Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} + shouldForceFullScreen + /> + ); + } + + return typeof props.children === 'function' ? props.children(props) : props.children; +} + +export default withOnyx({ + policy: { + key: ({policyID}) => `${ONYXKEYS.COLLECTION.POLICY}${policyID ?? ''}`, + }, + isLoadingReportData: { + key: ONYXKEYS.IS_LOADING_REPORT_DATA, + }, +})(FeatureEnabledAccessOrNotFoundComponent); diff --git a/src/pages/workspace/WorkspaceNewRoomPage.tsx b/src/pages/workspace/WorkspaceNewRoomPage.tsx index 69f2d74b6be7..d92c650fa9c7 100644 --- a/src/pages/workspace/WorkspaceNewRoomPage.tsx +++ b/src/pages/workspace/WorkspaceNewRoomPage.tsx @@ -35,7 +35,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {NewRoomForm} from '@src/types/form/NewRoomForm'; import INPUT_IDS from '@src/types/form/NewRoomForm'; -import type {Account, Policy, Report as ReportType, Session} from '@src/types/onyx'; +import type {Policy, Report as ReportType, Session} from '@src/types/onyx'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -53,7 +53,7 @@ type WorkspaceNewRoomPageOnyxProps = { session: OnyxEntry; /** policyID for main workspace */ - activePolicyID: OnyxEntry['activePolicyID']>; + activePolicyID: OnyxEntry>; }; type WorkspaceNewRoomPageProps = WorkspaceNewRoomPageOnyxProps; @@ -343,8 +343,7 @@ export default withOnyx account?.activePolicyID ?? null, + key: ONYXKEYS.NVP_ACTIVE_POLICY_ID, initialValue: null, }, })(WorkspaceNewRoomPage); diff --git a/src/pages/workspace/WorkspaceProfilePage.tsx b/src/pages/workspace/WorkspaceProfilePage.tsx index d110a5752382..7169d8a4ab7c 100644 --- a/src/pages/workspace/WorkspaceProfilePage.tsx +++ b/src/pages/workspace/WorkspaceProfilePage.tsx @@ -20,6 +20,7 @@ import useLocalize from '@hooks/useLocalize'; import useThemeIllustrations from '@hooks/useThemeIllustrations'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; @@ -173,7 +174,11 @@ function WorkspaceProfilePage({policy, currencyList = {}, route}: WorkSpaceProfi /> {(!StringUtils.isEmptyString(policy?.description ?? '') || !readOnly) && ( - + Policy.clearPolicyErrorField(policy?.id ?? '', CONST.POLICY.COLLECTION_KEYS.DESCRIPTION)} + > )} - + Policy.clearPolicyErrorField(policy?.id ?? '', CONST.POLICY.COLLECTION_KEYS.GENERAL_SETTINGS)} + errorRowStyles={[styles.mt2]} + > (null); const {isSmallScreenWidth} = useWindowDimensions(); + const session = useSession(); const policyName = policy?.name ?? ''; const id = policy?.id ?? ''; + const adminEmail = session?.email ?? ''; const urlWithTrailingSlash = Url.addTrailingForwardSlash(environmentURL); - const url = `${urlWithTrailingSlash}${ROUTES.WORKSPACE_PROFILE.getRoute(id)}`; + const url = `${urlWithTrailingSlash}${ROUTES.WORKSPACE_JOIN_USER.getRoute(id, adminEmail)}`; + return ( - - - setDeleteCategoryConfirmModalVisible(false)} - title={translate('workspace.categories.deleteCategory')} - prompt={translate('workspace.categories.deleteCategoryPrompt')} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - /> - - Policy.clearCategoryErrors(route.params.policyID, route.params.categoryName)} - > - - - {translate('workspace.categories.enableCategory')} - - - - - + + setDeleteCategoryConfirmModalVisible(false)} + title={translate('workspace.categories.deleteCategory')} + prompt={translate('workspace.categories.deleteCategoryPrompt')} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger /> - - + + Policy.clearCategoryErrors(route.params.policyID, route.params.categoryName)} + > + + + {translate('workspace.categories.enableCategory')} + + + + + + + + ); diff --git a/src/pages/workspace/categories/CreateCategoryPage.tsx b/src/pages/workspace/categories/CreateCategoryPage.tsx index 80370d2197fa..b31207e73208 100644 --- a/src/pages/workspace/categories/CreateCategoryPage.tsx +++ b/src/pages/workspace/categories/CreateCategoryPage.tsx @@ -10,8 +10,10 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {SettingsNavigatorParamList} from '@navigation/types'; import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; import * as Policy from '@userActions/Policy'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type {PolicyCategories} from '@src/types/onyx'; @@ -38,21 +40,26 @@ function CreateCategoryPage({route, policyCategories}: CreateCategoryPageProps) return ( - - - - + + + + + ); diff --git a/src/pages/workspace/categories/EditCategoryPage.tsx b/src/pages/workspace/categories/EditCategoryPage.tsx index 11df2f195f9d..0e5ed0589934 100644 --- a/src/pages/workspace/categories/EditCategoryPage.tsx +++ b/src/pages/workspace/categories/EditCategoryPage.tsx @@ -10,8 +10,10 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {SettingsNavigatorParamList} from '@navigation/types'; import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; import * as Policy from '@userActions/Policy'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -40,22 +42,27 @@ function EditCategoryPage({route, policyCategories}: EditCategoryPageProps) { return ( - - Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(route.params.policyID, route.params.categoryName))} - /> - - + + Navigation.goBack(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(route.params.policyID, route.params.categoryName))} + /> + + + ); diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 3f929e46ab67..f3456c3875f5 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -28,6 +28,7 @@ import Navigation from '@libs/Navigation/Navigation'; import * as PolicyUtils from '@libs/PolicyUtils'; import type {WorkspacesCentralPaneNavigatorParamList} from '@navigation/types'; import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; import * as Policy from '@userActions/Policy'; import CONST from '@src/CONST'; @@ -263,62 +264,67 @@ function WorkspaceCategoriesPage({policy, policyCategories, route}: WorkspaceCat return ( - - - {!isSmallScreenWidth && getHeaderButtons()} - - setDeleteCategoriesConfirmModalVisible(false)} - title={translate(selectedCategoriesArray.length === 1 ? 'workspace.categories.deleteCategory' : 'workspace.categories.deleteCategories')} - prompt={translate(selectedCategoriesArray.length === 1 ? 'workspace.categories.deleteCategoryPrompt' : 'workspace.categories.deleteCategoriesPrompt')} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - /> - {isSmallScreenWidth && {getHeaderButtons()}} - - {translate('workspace.categories.subtitle')} - - {isLoading && ( - - )} - {shouldShowEmptyState && ( - - )} - {!shouldShowEmptyState && ( - + {!isSmallScreenWidth && getHeaderButtons()} + + setDeleteCategoriesConfirmModalVisible(false)} + title={translate(selectedCategoriesArray.length === 1 ? 'workspace.categories.deleteCategory' : 'workspace.categories.deleteCategories')} + prompt={translate(selectedCategoriesArray.length === 1 ? 'workspace.categories.deleteCategoryPrompt' : 'workspace.categories.deleteCategoriesPrompt')} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger /> - )} - + {isSmallScreenWidth && {getHeaderButtons()}} + + {translate('workspace.categories.subtitle')} + + {isLoading && ( + + )} + {shouldShowEmptyState && ( + + )} + {!shouldShowEmptyState && ( + + )} + + ); diff --git a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx index 6939bac56894..0ec937b19ba2 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesSettingsPage.tsx @@ -11,7 +11,9 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {setWorkspaceRequiresCategory} from '@libs/actions/Policy'; import type {SettingsNavigatorParamList} from '@navigation/types'; import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; +import CONST from '@src/CONST'; import type SCREENS from '@src/SCREENS'; type WorkspaceCategoriesSettingsPageProps = StackScreenProps; @@ -27,34 +29,39 @@ function WorkspaceCategoriesSettingsPage({route}: WorkspaceCategoriesSettingsPag return ( - {({policy}) => ( - - - - - - - {translate('workspace.categories.requiresCategory')} - + + {({policy}) => ( + + + + + + + {translate('workspace.categories.requiresCategory')} + + - - - - - )} + + + + )} + ); diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx index 0b2ef794a4ca..32531da0dd25 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesPage.tsx @@ -24,6 +24,7 @@ import * as CurrencyUtils from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {WorkspacesCentralPaneNavigatorParamList} from '@navigation/types'; import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; import * as Policy from '@userActions/Policy'; import ButtonWithDropdownMenu from '@src/components/ButtonWithDropdownMenu'; @@ -237,53 +238,58 @@ function PolicyDistanceRatesPage({policy, route}: PolicyDistanceRatesPageProps) return ( - - - {!isSmallScreenWidth && headerButtons} - - {isSmallScreenWidth && {headerButtons}} - - {translate('workspace.distanceRates.centrallyManage')} - - {isLoading && ( - - )} - {Object.values(customUnitRates).length > 0 && ( - + {!isSmallScreenWidth && headerButtons} + + {isSmallScreenWidth && {headerButtons}} + + {translate('workspace.distanceRates.centrallyManage')} + + {isLoading && ( + + )} + {Object.values(customUnitRates).length > 0 && ( + + )} + setIsWarningModalVisible(false)} + isVisible={isWarningModalVisible} + title={translate('workspace.distanceRates.oopsNotSoFast')} + prompt={translate('workspace.distanceRates.workspaceNeeds')} + confirmText={translate('common.buttonConfirm')} + shouldShowCancelButton={false} /> - )} - setIsWarningModalVisible(false)} - isVisible={isWarningModalVisible} - title={translate('workspace.distanceRates.oopsNotSoFast')} - prompt={translate('workspace.distanceRates.workspaceNeeds')} - confirmText={translate('common.buttonConfirm')} - shouldShowCancelButton={false} - /> - + + ); diff --git a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx index f650a618250e..83b096db1301 100644 --- a/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx +++ b/src/pages/workspace/distanceRates/PolicyDistanceRatesSettingsPage.tsx @@ -11,8 +11,10 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import type {SettingsNavigatorParamList} from '@navigation/types'; import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; import * as Policy from '@userActions/Policy'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; @@ -55,42 +57,47 @@ function PolicyDistanceRatesSettingsPage({policy, route}: PolicyDistanceRatesSet return ( - - - clearErrorFields('attributes')} + - - - {policy?.areCategoriesEnabled && ( + clearErrorFields('defaultCategory')} + onClose={() => clearErrorFields('attributes')} > - - )} - + {policy?.areCategoriesEnabled && ( + clearErrorFields('defaultCategory')} + > + + + )} + + ); diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx index d83f1b1d77a7..709e51cba383 100644 --- a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx +++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage.tsx @@ -44,7 +44,7 @@ function WorkspaceRatePage(props: WorkspaceRatePageProps) { const submit = (values: FormOnyxValues) => { const rate = values.rate; Policy.setRateForReimburseView((parseFloat(rate) * CONST.POLICY.CUSTOM_UNIT_RATE_BASE_OFFSET).toFixed(1)); - Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy?.id ?? '')); + Navigation.goBack(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy?.id ?? '')); }; const validate = useCallback( diff --git a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage.tsx b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage.tsx index 36efc239fe69..1d30c068e30d 100644 --- a/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage.tsx +++ b/src/pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage.tsx @@ -38,7 +38,7 @@ function WorkspaceUnitPage(props: WorkspaceUnitPageProps) { const updateUnit = (unit: UnitItemType) => { Policy.setUnitForReimburseView(unit.value); - Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy?.id ?? '')); + Navigation.goBack(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy?.id ?? '')); }; const defaultValue = useMemo(() => { diff --git a/src/pages/workspace/tags/EditTagPage.tsx b/src/pages/workspace/tags/EditTagPage.tsx index c22ba9154146..92d7c0a11ac9 100644 --- a/src/pages/workspace/tags/EditTagPage.tsx +++ b/src/pages/workspace/tags/EditTagPage.tsx @@ -17,6 +17,7 @@ import * as PolicyUtils from '@libs/PolicyUtils'; import * as ValidationUtils from '@libs/ValidationUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; import * as Policy from '@userActions/Policy'; import CONST from '@src/CONST'; @@ -66,36 +67,41 @@ function EditTagPage({route, policyTags}: EditTagPageProps) { return ( - - - - - - + + + + + ); diff --git a/src/pages/workspace/tags/TagSettingsPage.tsx b/src/pages/workspace/tags/TagSettingsPage.tsx index 5f164a25e5fe..107689bc46b9 100644 --- a/src/pages/workspace/tags/TagSettingsPage.tsx +++ b/src/pages/workspace/tags/TagSettingsPage.tsx @@ -21,8 +21,10 @@ import * as PolicyUtils from '@libs/PolicyUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; import * as Policy from '@userActions/Policy'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -67,61 +69,66 @@ function TagSettingsPage({route, policyTags}: TagSettingsPageProps) { return ( - - setIsDeleteTagModalOpen(true), - }, - ]} - /> - setIsDeleteTagModalOpen(false)} - shouldSetModalVisibility={false} - prompt={translate('workspace.tags.deleteTagConfirmation')} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - /> - - Policy.clearPolicyTagErrors(route.params.policyID, route.params.tagName)} - > - - - {translate('workspace.tags.enableTag')} - - - - - + setIsDeleteTagModalOpen(true), + }, + ]} + /> + setIsDeleteTagModalOpen(false)} + shouldSetModalVisibility={false} + prompt={translate('workspace.tags.deleteTagConfirmation')} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger /> - - + + Policy.clearPolicyTagErrors(route.params.policyID, route.params.tagName)} + > + + + {translate('workspace.tags.enableTag')} + + + + + + + + ); diff --git a/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx b/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx index 04c0cf8038d0..346d56891dd5 100644 --- a/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx +++ b/src/pages/workspace/tags/WorkspaceCreateTagPage.tsx @@ -18,6 +18,7 @@ import * as PolicyUtils from '@libs/PolicyUtils'; import * as ValidationUtils from '@libs/ValidationUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; import * as Policy from '@userActions/Policy'; import CONST from '@src/CONST'; @@ -70,35 +71,40 @@ function CreateTagPage({route, policyTags}: CreateTagPageProps) { return ( - - - - - - + + + + + ); diff --git a/src/pages/workspace/tags/WorkspaceEditTagsPage.tsx b/src/pages/workspace/tags/WorkspaceEditTagsPage.tsx index 98ae6f726d73..0072d37ef631 100644 --- a/src/pages/workspace/tags/WorkspaceEditTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceEditTagsPage.tsx @@ -16,6 +16,9 @@ import * as Policy from '@libs/actions/Policy'; import Navigation from '@libs/Navigation/Navigation'; import * as PolicyUtils from '@libs/PolicyUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; +import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; +import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -52,33 +55,42 @@ function WorkspaceEditTagsPage({route, policyTags}: WorkspaceEditTagsPageProps) ); return ( - - - - - - - - + + + + + + + + + + + + + + ); } diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index 126d548c2c8a..a355cc062f3d 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -27,6 +27,7 @@ import Navigation from '@libs/Navigation/Navigation'; import * as PolicyUtils from '@libs/PolicyUtils'; import type {WorkspacesCentralPaneNavigatorParamList} from '@navigation/types'; import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; import * as Policy from '@userActions/Policy'; import CONST from '@src/CONST'; @@ -254,62 +255,67 @@ function WorkspaceTagsPage({policyTags, route}: WorkspaceTagsPageProps) { return ( - - - {!isSmallScreenWidth && getHeaderButtons()} - - setDeleteTagsConfirmModalVisible(false)} - title={translate(selectedTagsArray.length === 1 ? 'workspace.tags.deleteTag' : 'workspace.tags.deleteTags')} - prompt={translate(selectedTagsArray.length === 1 ? 'workspace.tags.deleteTagConfirmation' : 'workspace.tags.deleteTagsConfirmation')} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - /> - {isSmallScreenWidth && {getHeaderButtons()}} - - {translate('workspace.tags.subtitle')} - - {isLoading && ( - - )} - {tagList.length === 0 && !isLoading && ( - - )} - {tagList.length > 0 && ( - Policy.clearPolicyTagErrors(route.params.policyID, item.value)} + + {!isSmallScreenWidth && getHeaderButtons()} + + setDeleteTagsConfirmModalVisible(false)} + title={translate(selectedTagsArray.length === 1 ? 'workspace.tags.deleteTag' : 'workspace.tags.deleteTags')} + prompt={translate(selectedTagsArray.length === 1 ? 'workspace.tags.deleteTagConfirmation' : 'workspace.tags.deleteTagsConfirmation')} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger /> - )} - + {isSmallScreenWidth && {getHeaderButtons()}} + + {translate('workspace.tags.subtitle')} + + {isLoading && ( + + )} + {tagList.length === 0 && !isLoading && ( + + )} + {tagList.length > 0 && ( + Policy.clearPolicyTagErrors(route.params.policyID, item.value)} + /> + )} + + ); diff --git a/src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx index 67b033b68f72..b421698b8f2f 100644 --- a/src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsSettingsPage.tsx @@ -16,7 +16,9 @@ import Navigation from '@libs/Navigation/Navigation'; import * as PolicyUtils from '@libs/PolicyUtils'; import type {SettingsNavigatorParamList} from '@navigation/types'; import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; @@ -39,49 +41,53 @@ function WorkspaceTagsSettingsPage({route, policyTags}: WorkspaceTagsSettingsPag }, [route.params.policyID], ); - return ( - {({policy}) => ( - - - - - - - {translate('workspace.tags.requiresTag')} - + + {({policy}) => ( + + + + + + + {translate('workspace.tags.requiresTag')} + + - - - - Navigation.navigate(ROUTES.WORKSPACE_EDIT_TAGS.getRoute(route.params.policyID))} - shouldShowRightIcon - /> - - - - )} + + + Navigation.navigate(ROUTES.WORKSPACE_EDIT_TAGS.getRoute(route.params.policyID))} + shouldShowRightIcon + /> + + + + )} + ); diff --git a/src/pages/workspace/taxes/NamePage.tsx b/src/pages/workspace/taxes/NamePage.tsx new file mode 100644 index 000000000000..1efb983be19e --- /dev/null +++ b/src/pages/workspace/taxes/NamePage.tsx @@ -0,0 +1,120 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import ExpensiMark from 'expensify-common/lib/ExpensiMark'; +import React, {useCallback, useState} from 'react'; +import {View} from 'react-native'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import TextInput from '@components/TextInput'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {renamePolicyTax, validateTaxName} from '@libs/actions/TaxRate'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; +import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; +import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/WorkspaceTaxNameForm'; + +type NamePageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps; + +const parser = new ExpensiMark(); + +function NamePage({ + route: { + params: {policyID, taxID}, + }, + policy, +}: NamePageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const currentTaxRate = PolicyUtils.getTaxByID(policy, taxID); + const {inputCallbackRef} = useAutoFocusInput(); + + const [name, setName] = useState(() => parser.htmlToMarkdown(currentTaxRate?.name ?? '')); + + const goBack = useCallback(() => Navigation.goBack(ROUTES.WORKSPACE_TAX_EDIT.getRoute(policyID ?? '', taxID)), [policyID, taxID]); + + const submit = () => { + renamePolicyTax(policyID, taxID, name); + goBack(); + }; + + const validate = useCallback( + (values: FormOnyxValues) => { + if (!policy) { + return {}; + } + if (values[INPUT_IDS.NAME] === currentTaxRate?.name) { + return {}; + } + return validateTaxName(policy, values); + }, + [currentTaxRate?.name, policy], + ); + + if (!currentTaxRate) { + return ; + } + + return ( + + + + + + + + + + + + + + + + ); +} + +NamePage.displayName = 'NamePage'; + +export default withPolicyAndFullscreenLoading(NamePage); diff --git a/src/pages/workspace/taxes/ValuePage.tsx b/src/pages/workspace/taxes/ValuePage.tsx new file mode 100644 index 000000000000..d008b11ecb15 --- /dev/null +++ b/src/pages/workspace/taxes/ValuePage.tsx @@ -0,0 +1,103 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useCallback, useState} from 'react'; +import AmountForm from '@components/AmountForm'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {updatePolicyTaxValue, validateTaxValue} from '@libs/actions/TaxRate'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; +import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; +import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/WorkspaceTaxValueForm'; + +type ValuePageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps; + +function ValuePage({ + route: { + params: {policyID, taxID}, + }, + policy, +}: ValuePageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const currentTaxRate = PolicyUtils.getTaxByID(policy, taxID); + const [value, setValue] = useState(currentTaxRate?.value?.replace('%', '')); + + const goBack = useCallback(() => Navigation.goBack(ROUTES.WORKSPACE_TAX_EDIT.getRoute(policyID ?? '', taxID)), [policyID, taxID]); + + const submit = useCallback( + (values: FormOnyxValues) => { + updatePolicyTaxValue(policyID, taxID, Number(values.value)); + goBack(); + }, + [goBack, policyID, taxID], + ); + + if (!currentTaxRate) { + return ; + } + + return ( + + + + + + + + %} + /> + + + + + + ); +} + +ValuePage.displayName = 'ValuePage'; + +export default withPolicyAndFullscreenLoading(ValuePage); diff --git a/src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx b/src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx index c0790bb8abd8..ccc0d4ad9e7b 100644 --- a/src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceCreateTaxPage.tsx @@ -11,11 +11,11 @@ import Text from '@components/Text'; import TextPicker from '@components/TextPicker'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {createPolicyTax, getNextTaxCode, getTaxValueWithPercentage} from '@libs/actions/TaxRate'; +import {createPolicyTax, getNextTaxCode, getTaxValueWithPercentage, validateTaxName, validateTaxValue} from '@libs/actions/TaxRate'; import Navigation from '@libs/Navigation/Navigation'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import * as ValidationUtils from '@libs/ValidationUtils'; import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -36,25 +36,6 @@ function WorkspaceCreateTaxPage({ const styles = useThemeStyles(); const {translate} = useLocalize(); - const validate = useCallback( - (values: FormOnyxValues): FormInputErrors => { - const errors = ValidationUtils.getFieldRequiredErrors(values, [INPUT_IDS.VALUE, INPUT_IDS.NAME]); - - const value = values[INPUT_IDS.VALUE]; - if (!ValidationUtils.isValidPercentage(value)) { - errors[INPUT_IDS.VALUE] = 'workspace.taxes.errors.valuePercentageRange'; - } - - const name = values[INPUT_IDS.NAME]; - if (policy?.taxRates?.taxes && ValidationUtils.isExistingTaxName(name, policy.taxRates.taxes)) { - errors[INPUT_IDS.NAME] = 'workspace.taxes.errors.taxRateAlreadyExists'; - } - - return errors; - }, - [policy?.taxRates?.taxes], - ); - const submitForm = useCallback( ({value, ...values}: FormOnyxValues) => { const taxRate = { @@ -68,52 +49,70 @@ function WorkspaceCreateTaxPage({ [policy?.taxRates?.taxes, policyID], ); + const validateForm = useCallback( + (values: FormOnyxValues): FormInputErrors => { + if (!policy) { + return {}; + } + return { + ...validateTaxName(policy, values), + ...validateTaxValue(values), + }; + }, + [policy], + ); + return ( - - - - - - - (v ? getTaxValueWithPercentage(v) : '')} - description={translate('workspace.taxes.value')} - rightLabel={translate('common.required')} - hideCurrencySymbol - extraSymbol={%} - /> - - - - + + + + + + + (v ? getTaxValueWithPercentage(v) : '')} + description={translate('workspace.taxes.value')} + rightLabel={translate('common.required')} + hideCurrencySymbol + extraSymbol={%} + /> + + + + + ); diff --git a/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx b/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx new file mode 100644 index 000000000000..ec04b77df3ca --- /dev/null +++ b/src/pages/workspace/taxes/WorkspaceEditTaxPage.tsx @@ -0,0 +1,163 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useMemo, useState} from 'react'; +import {View} from 'react-native'; +import ConfirmModal from '@components/ConfirmModal'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import type {ThreeDotsMenuItem} from '@components/HeaderWithBackButton/types'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Switch from '@components/Switch'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; +import {clearTaxRateFieldError, deletePolicyTaxes, setPolicyTaxesEnabled} from '@libs/actions/TaxRate'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; +import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; +import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type WorkspaceEditTaxPageBaseProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps; + +function WorkspaceEditTaxPage({ + route: { + params: {policyID, taxID}, + }, + policy, +}: WorkspaceEditTaxPageBaseProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const currentTaxRate = PolicyUtils.getTaxByID(policy, taxID); + const {windowWidth} = useWindowDimensions(); + const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); + const canEdit = policy && PolicyUtils.canEditTaxRate(policy, taxID); + + const toggleTaxRate = () => { + if (!currentTaxRate) { + return; + } + setPolicyTaxesEnabled(policyID, [taxID], !!currentTaxRate.isDisabled); + }; + + const deleteTaxRate = () => { + if (!policyID) { + return; + } + deletePolicyTaxes(policyID, [taxID]); + setIsDeleteModalVisible(false); + Navigation.goBack(); + }; + + const threeDotsMenuItems: ThreeDotsMenuItem[] = useMemo( + () => [ + { + icon: Expensicons.Trashcan, + text: translate('common.delete'), + onSelected: () => setIsDeleteModalVisible(true), + }, + ], + [translate], + ); + + if (!currentTaxRate) { + return ; + } + + return ( + + + + + + + clearTaxRateFieldError(policyID, taxID, 'isDisabled')} + > + + + {translate('workspace.taxes.actions.enable')} + + + + + clearTaxRateFieldError(policyID, taxID, 'name')} + > + Navigation.navigate(ROUTES.WORKSPACE_TAX_NAME.getRoute(`${policyID}`, taxID))} + /> + + clearTaxRateFieldError(policyID, taxID, 'value')} + > + Navigation.navigate(ROUTES.WORKSPACE_TAX_VALUE.getRoute(`${policyID}`, taxID))} + /> + + + setIsDeleteModalVisible(false)} + prompt={translate('workspace.taxes.deleteTaxConfirmation')} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger + /> + + + + + ); +} + +WorkspaceEditTaxPage.displayName = 'WorkspaceEditTaxPage'; + +export default withPolicyAndFullscreenLoading(WorkspaceEditTaxPage); diff --git a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx index 8eb730c0134f..bad82d827c5d 100644 --- a/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx +++ b/src/pages/workspace/taxes/WorkspaceTaxesPage.tsx @@ -1,7 +1,10 @@ import type {StackScreenProps} from '@react-navigation/stack'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; import Button from '@components/Button'; +import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; +import type {DropdownOption, WorkspaceTaxRatesBulkActionType} from '@components/ButtonWithDropdownMenu/types'; +import ConfirmModal from '@components/ConfirmModal'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -17,10 +20,13 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import {openPolicyTaxesPage} from '@libs/actions/Policy'; -import {clearTaxRateError} from '@libs/actions/TaxRate'; +import {clearTaxRateError, deletePolicyTaxes, setPolicyTaxesEnabled} from '@libs/actions/TaxRate'; +import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; import type {WorkspacesCentralPaneNavigatorParamList} from '@navigation/types'; import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import FeatureEnabledAccessOrNotFoundWrapper from '@pages/workspace/FeatureEnabledAccessOrNotFoundWrapper'; import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; @@ -30,17 +36,24 @@ import type SCREENS from '@src/SCREENS'; type WorkspaceTaxesPageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps; -function WorkspaceTaxesPage({policy, route}: WorkspaceTaxesPageProps) { +function WorkspaceTaxesPage({ + policy, + route: { + params: {policyID}, + }, +}: WorkspaceTaxesPageProps) { const {isSmallScreenWidth} = useWindowDimensions(); const styles = useThemeStyles(); const theme = useTheme(); const {translate} = useLocalize(); const [selectedTaxesIDs, setSelectedTaxesIDs] = useState([]); + const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); const defaultExternalID = policy?.taxRates?.defaultExternalID; const foreignTaxDefault = policy?.taxRates?.foreignTaxDefault; + const dropdownButtonRef = useRef(null); const fetchTaxes = () => { - openPolicyTaxesPage(route.params.policyID); + openPolicyTaxesPage(policyID); }; const {isOffline} = useNetwork({onReconnect: fetchTaxes}); @@ -66,34 +79,34 @@ function WorkspaceTaxesPage({policy, route}: WorkspaceTaxesPageProps) { [defaultExternalID, foreignTaxDefault, translate], ); - const taxesList = useMemo( - () => - Object.entries(policy?.taxRates?.taxes ?? {}) - .map(([key, value]) => ({ - text: value.name, - alternateText: textForDefault(key), - keyForList: key, - isSelected: !!selectedTaxesIDs.includes(key), - isDisabledCheckbox: key === defaultExternalID, - pendingAction: value.pendingAction, - errors: value.errors, - rightElement: ( - - - {value.isDisabled ? translate('workspace.common.disabled') : translate('workspace.common.enabled')} - - - - + const taxesList = useMemo(() => { + if (!policy) { + return []; + } + return Object.entries(policy.taxRates?.taxes ?? {}) + .map(([key, value]) => ({ + text: value.name, + alternateText: textForDefault(key), + keyForList: key, + isSelected: !!selectedTaxesIDs.includes(key), + isDisabledCheckbox: !PolicyUtils.canEditTaxRate(policy, key), + isDisabled: value.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + pendingAction: value.pendingAction ?? (Object.keys(value.pendingFields ?? {}).length > 0 ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : null), + errors: value.errors ?? ErrorUtils.getLatestErrorFieldForAnyField(value), + rightElement: ( + + {value.isDisabled ? translate('workspace.common.disabled') : translate('workspace.common.enabled')} + + - ), - })) - .sort((a, b) => a.text.localeCompare(b.text)), - [policy?.taxRates?.taxes, textForDefault, defaultExternalID, selectedTaxesIDs, styles, theme.icon, translate], - ); + + ), + })) + .sort((a, b) => (a.text ?? a.keyForList ?? '').localeCompare(b.text ?? b.keyForList ?? '')); + }, [policy, textForDefault, selectedTaxesIDs, styles.flexRow, styles.disabledText, styles.alignSelfCenter, styles.p1, styles.pl2, translate, theme.icon]); const isLoading = !isOffline && taxesList === undefined; @@ -129,68 +142,159 @@ function WorkspaceTaxesPage({policy, route}: WorkspaceTaxesPageProps) { ); - const headerButtons = ( + const deleteTaxes = useCallback(() => { + if (!policyID) { + return; + } + deletePolicyTaxes(policyID, selectedTaxesIDs); + setSelectedTaxesIDs([]); + setIsDeleteModalVisible(false); + }, [policyID, selectedTaxesIDs]); + + const toggleTaxes = useCallback( + (isEnabled: boolean) => { + if (!policyID) { + return; + } + setPolicyTaxesEnabled(policyID, selectedTaxesIDs, isEnabled); + setSelectedTaxesIDs([]); + }, + [policyID, selectedTaxesIDs], + ); + + const navigateToEditTaxRate = (taxRate: ListItem) => { + if (!taxRate.keyForList) { + return; + } + setSelectedTaxesIDs([]); + Navigation.navigate(ROUTES.WORKSPACE_TAX_EDIT.getRoute(policyID, taxRate.keyForList)); + }; + + const dropdownMenuOptions = useMemo(() => { + const isMultiple = selectedTaxesIDs.length > 1; + const options: Array> = [ + { + icon: Expensicons.Trashcan, + text: isMultiple ? translate('workspace.taxes.actions.deleteMultiple') : translate('workspace.taxes.actions.delete'), + value: CONST.POLICY.TAX_RATES_BULK_ACTION_TYPES.DELETE, + onSelected: () => setIsDeleteModalVisible(true), + }, + ]; + + // `Disable rates` when at least one enabled rate is selected. + if (selectedTaxesIDs.some((taxID) => !policy?.taxRates?.taxes[taxID]?.isDisabled)) { + options.push({ + icon: Expensicons.DocumentSlash, + text: isMultiple ? translate('workspace.taxes.actions.disableMultiple') : translate('workspace.taxes.actions.disable'), + value: CONST.POLICY.TAX_RATES_BULK_ACTION_TYPES.DISABLE, + onSelected: () => toggleTaxes(false), + }); + } + + // `Enable rates` when at least one disabled rate is selected. + if (selectedTaxesIDs.some((taxID) => policy?.taxRates?.taxes[taxID]?.isDisabled)) { + options.push({ + icon: Expensicons.Document, + text: isMultiple ? translate('workspace.taxes.actions.enableMultiple') : translate('workspace.taxes.actions.enable'), + value: CONST.POLICY.TAX_RATES_BULK_ACTION_TYPES.ENABLE, + onSelected: () => toggleTaxes(true), + }); + } + return options; + }, [policy?.taxRates?.taxes, selectedTaxesIDs, toggleTaxes, translate]); + + const headerButtons = !selectedTaxesIDs.length ? (