diff --git a/src/CONST.ts b/src/CONST.ts index be69046930bc..6626b798d314 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -308,6 +308,7 @@ const CONST = { BETA_COMMENT_LINKING: 'commentLinking', VIOLATIONS: 'violations', REPORT_FIELDS: 'reportFields', + WORKFLOWS_DELAYED_SUBMISSION: 'workflowsDelayedSubmission', }, BUTTON_STATES: { DEFAULT: 'default', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 22ebffd52eec..fb99108c7e97 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -486,6 +486,14 @@ const ROUTES = { route: 'workspace/:policyID/workflows', getRoute: (policyID: string) => `workspace/${policyID}/workflows` as const, }, + WORKSPACE_WORKFLOWS_AUTOREPORTING_FREQUENCY: { + route: 'workspace/:policyID/settings/workflows/auto-reporting-frequency', + getRoute: (policyID: string) => `workspace/${policyID}/settings/workflows/auto-reporting-frequency` as const, + }, + WORKSPACE_WORKFLOWS_AUTOREPORTING_MONTHLY_OFFSET: { + route: 'workspace/:policyID/settings/workflows/auto-reporting-frequency/monthly-offset', + getRoute: (policyID: string) => `workspace/${policyID}/settings/workflows/auto-reporting-frequency/monthly-offset` as const, + }, WORKSPACE_CARD: { route: 'workspace/:policyID/card', getRoute: (policyID: string) => `workspace/${policyID}/card` as const, @@ -526,6 +534,10 @@ const ROUTES = { route: 'workspace/:policyID/categories', getRoute: (policyID: string) => `workspace/${policyID}/categories` as const, }, + WORKSPACE_CATEGORY_SETTINGS: { + route: 'workspace/:policyID/categories/:categoryName', + getRoute: (policyID: string, categoryName: string) => `workspace/${policyID}/categories/${encodeURI(categoryName)}` as const, + }, WORKSPACE_CATEGORIES_SETTINGS: { route: 'workspace/:policyID/categories/settings', getRoute: (policyID: string) => `workspace/${policyID}/categories/settings` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index ac75968e68b9..cc7df01524f7 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -216,9 +216,12 @@ const SCREENS = { CATEGORIES: 'Workspace_Categories', CURRENCY: 'Workspace_Profile_Currency', WORKFLOWS: 'Workspace_Workflows', + WORKFLOWS_AUTO_REPORTING_FREQUENCY: 'Workspace_Workflows_Auto_Reporting_Frequency', + WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET: 'Workspace_Workflows_Auto_Reporting_Monthly_Offset', DESCRIPTION: 'Workspace_Profile_Description', SHARE: 'Workspace_Profile_Share', NAME: 'Workspace_Profile_Name', + CATEGORY_SETTINGS: 'Category_Settings', CATEGORIES_SETTINGS: 'Categories_Settings', }, diff --git a/src/components/Attachments/AttachmentCarousel/CarouselItem.js b/src/components/Attachments/AttachmentCarousel/CarouselItem.js index b2c9fed64467..e924cb8c13e9 100644 --- a/src/components/Attachments/AttachmentCarousel/CarouselItem.js +++ b/src/components/Attachments/AttachmentCarousel/CarouselItem.js @@ -109,6 +109,7 @@ function CarouselItem({item, onPress, isFocused, isModalHovered}) { isHovered={isModalHovered} isFocused={isFocused} optionalVideoDuration={item.duration} + isUsedInCarousel /> diff --git a/src/components/LocaleContextProvider.tsx b/src/components/LocaleContextProvider.tsx index 25b468181b87..eb7d9324d2ab 100644 --- a/src/components/LocaleContextProvider.tsx +++ b/src/components/LocaleContextProvider.tsx @@ -50,6 +50,9 @@ type LocaleContextProps = { /** Gets the locale digit corresponding to a standard digit */ toLocaleDigit: (digit: string) => string; + /** Formats a number into its localized ordinal representation */ + toLocaleOrdinal: (number: number) => string; + /** Gets the standard digit corresponding to a locale digit */ fromLocaleDigit: (digit: string) => string; @@ -65,6 +68,7 @@ const LocaleContext = createContext({ updateLocale: () => '', formatPhoneNumber: () => '', toLocaleDigit: () => '', + toLocaleOrdinal: () => '', fromLocaleDigit: () => '', preferredLocale: CONST.LOCALES.DEFAULT, }); @@ -98,6 +102,8 @@ function LocaleContextProvider({preferredLocale, currentUserPersonalDetails = {} const toLocaleDigit = useMemo(() => (digit) => LocaleDigitUtils.toLocaleDigit(locale, digit), [locale]); + const toLocaleOrdinal = useMemo(() => (number) => LocaleDigitUtils.toLocaleOrdinal(locale, number), [locale]); + const fromLocaleDigit = useMemo(() => (localeDigit) => LocaleDigitUtils.fromLocaleDigit(locale, localeDigit), [locale]); const contextValue = useMemo( @@ -109,10 +115,11 @@ function LocaleContextProvider({preferredLocale, currentUserPersonalDetails = {} updateLocale, formatPhoneNumber, toLocaleDigit, + toLocaleOrdinal, fromLocaleDigit, preferredLocale: locale, }), - [translate, numberFormat, datetimeToRelative, datetimeToCalendarTime, updateLocale, formatPhoneNumber, toLocaleDigit, fromLocaleDigit, locale], + [translate, numberFormat, datetimeToRelative, datetimeToCalendarTime, updateLocale, formatPhoneNumber, toLocaleDigit, toLocaleOrdinal, fromLocaleDigit, locale], ); return {children}; diff --git a/src/components/VideoPlayer/BaseVideoPlayer.js b/src/components/VideoPlayer/BaseVideoPlayer.js index 060bc890d5e7..360095ec7eb4 100644 --- a/src/components/VideoPlayer/BaseVideoPlayer.js +++ b/src/components/VideoPlayer/BaseVideoPlayer.js @@ -9,7 +9,6 @@ import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeed import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext'; import VideoPopoverMenu from '@components/VideoPopoverMenu'; import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; import * as Browser from '@libs/Browser'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; @@ -43,7 +42,6 @@ function BaseVideoPlayer({ isVideoHovered, }) { const styles = useThemeStyles(); - const {isSmallScreenWidth} = useWindowDimensions(); const {pauseVideo, playVideo, currentlyPlayingURL, updateSharedElements, sharedElement, originalParent, shareVideoPlayerElements, currentVideoPlayerRef, updateCurrentlyPlayingURL} = usePlaybackContext(); const [duration, setDuration] = useState(videoDuration * 1000); @@ -99,20 +97,24 @@ function BaseVideoPlayer({ const handlePlaybackStatusUpdate = useCallback( (e) => { - if (shouldReplayVideo(e, isPlaying, duration, position)) { + const isVideoPlaying = e.isPlaying || false; + const currentDuration = e.durationMillis || videoDuration * 1000; + const currentPositon = e.positionMillis || 0; + + if (shouldReplayVideo(e, isVideoPlaying, currentDuration, currentPositon)) { videoPlayerRef.current.setStatusAsync({positionMillis: 0, shouldPlay: true}); } - const isVideoPlaying = e.isPlaying || false; + preventPausingWhenExitingFullscreen(isVideoPlaying); setIsPlaying(isVideoPlaying); setIsLoading(!e.isLoaded || Number.isNaN(e.durationMillis)); // when video is ready to display duration is not NaN setIsBuffering(e.isBuffering || false); - setDuration(e.durationMillis || videoDuration * 1000); - setPosition(e.positionMillis || 0); + setDuration(currentDuration); + setPosition(currentPositon); onPlaybackStatusUpdate(e); }, - [onPlaybackStatusUpdate, preventPausingWhenExitingFullscreen, videoDuration, isPlaying, duration, position], + [onPlaybackStatusUpdate, preventPausingWhenExitingFullscreen, videoDuration], ); const handleFullscreenUpdate = useCallback( @@ -168,7 +170,7 @@ function BaseVideoPlayer({ } originalParent.appendChild(sharedElement); }; - }, [bindFunctions, currentVideoPlayerRef, currentlyPlayingURL, isSmallScreenWidth, originalParent, sharedElement, shouldUseSharedVideoElement, url]); + }, [bindFunctions, currentVideoPlayerRef, currentlyPlayingURL, originalParent, sharedElement, shouldUseSharedVideoElement, url]); return ( <> @@ -176,36 +178,36 @@ function BaseVideoPlayer({ {(isHovered) => ( - {shouldUseSharedVideoElement ? ( - <> - - {/* We are adding transparent absolute View between appended video component and control buttons to enable + { + togglePlayCurrentVideo(); + }} + style={styles.flex1} + > + {shouldUseSharedVideoElement ? ( + <> + + {/* We are adding transparent absolute View between appended video component and control buttons to enable catching onMouse events from Attachment Carousel. Due to late appending React doesn't handle element's events properly. */} - - - ) : ( - { - if (!el) { - return; - } - videoPlayerElementParentRef.current = el; - if (el.childNodes && el.childNodes[0]) { - videoPlayerElementRef.current = el.childNodes[0]; - } - }} - > - { - togglePlayCurrentVideo(); - }} + + + ) : ( + { + if (!el) { + return; + } + videoPlayerElementParentRef.current = el; + if (el.childNodes && el.childNodes[0]) { + videoPlayerElementRef.current = el.childNodes[0]; + } + }} > - - )} + + )} + {(isLoading || isBuffering) && } diff --git a/src/languages/en.ts b/src/languages/en.ts index c475b256587a..2a0139c64c07 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1037,18 +1037,32 @@ export default { delaySubmissionTitle: 'Delay submissions', delaySubmissionDescription: 'Expenses are shared right away for better spend visibility. Set a slower cadence if needed.', submissionFrequency: 'Submission frequency', - weeklyFrequency: 'Weekly', - monthlyFrequency: 'Monthly', - twiceAMonthFrequency: 'Twice a month', - byTripFrequency: 'By trip', - manuallyFrequency: 'Manually', - dailyFrequency: 'Daily', + submissionFrequencyDateOfMonth: 'Date of month', addApprovalsTitle: 'Add approvals', approver: 'Approver', connectBankAccount: 'Connect bank account', addApprovalsDescription: 'Require additional approval before authorizing a payment.', makeOrTrackPaymentsTitle: 'Make or track payments', makeOrTrackPaymentsDescription: 'Add an authorized payer for payments made in Expensify, or simply track payments made elsewhere.', + editor: { + submissionFrequency: 'Choose how long Expensify should wait before sharing error-free spend.', + }, + frequencies: { + weekly: 'Weekly', + monthly: 'Monthly', + twiceAMonth: 'Twice a month', + byTrip: 'By trip', + manually: 'Manually', + daily: 'Daily', + lastDayOfMonth: 'Last day of the month', + lastBusinessDayOfMonth: 'Last business day of the month', + ordinals: { + one: 'st', + two: 'nd', + few: 'rd', + other: 'th', + }, + }, }, reportFraudPage: { title: 'Report virtual card fraud', @@ -1742,6 +1756,7 @@ export default { collect: 'Collect', }, categories: { + categoryName: 'Category name', requiresCategory: 'Members must categorize all spend', enableCategory: 'Enable category', subtitle: 'Get a better overview of where money is being spent. Use our default categories or add your own.', @@ -1749,6 +1764,7 @@ export default { title: "You haven't created any categories", subtitle: 'Add a category to organize your spend.', }, + genericFailureMessage: 'An error occurred while updating the category, please try again.', }, emptyWorkspace: { title: 'Create a workspace', diff --git a/src/languages/es.ts b/src/languages/es.ts index 2a9394740337..20f4cf8aeac8 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1033,18 +1033,32 @@ export default { delaySubmissionTitle: 'Retrasar envíos', delaySubmissionDescription: 'Los gastos se comparten de inmediato para una mejor visibilidad del gasto. Establece una cadencia más lenta si es necesario.', submissionFrequency: 'Frecuencia de envíos', - weeklyFrequency: 'Semanal', - monthlyFrequency: 'Mensual', - twiceAMonthFrequency: 'Dos veces al mes', - byTripFrequency: 'Por viaje', - manuallyFrequency: 'Manual', - dailyFrequency: 'Diaria', + submissionFrequencyDateOfMonth: 'Fecha del mes', addApprovalsTitle: 'Requerir aprobaciones', approver: 'Aprobador', connectBankAccount: 'Conectar cuenta bancaria', addApprovalsDescription: 'Requiere una aprobación adicional antes de autorizar un pago.', makeOrTrackPaymentsTitle: 'Realizar o seguir pagos', makeOrTrackPaymentsDescription: 'Añade un pagador autorizado para los pagos realizados en Expensify, o simplemente realiza un seguimiento de los pagos realizados en otro lugar.', + editor: { + submissionFrequency: 'Elige cuánto tiempo Expensify debe esperar antes de compartir los gastos sin errores.', + }, + frequencies: { + weekly: 'Semanal', + monthly: 'Mensual', + twiceAMonth: 'Dos veces al mes', + byTrip: 'Por viaje', + manually: 'Manualmente', + daily: 'Diaria', + lastDayOfMonth: 'Último día del mes', + lastBusinessDayOfMonth: 'Último día hábil del mes', + ordinals: { + one: '.º', + two: '.º', + few: '.º', + other: '.º', + }, + }, }, reportFraudPage: { title: 'Reportar fraude con la tarjeta virtual', @@ -1766,6 +1780,7 @@ export default { collect: 'Recolectar', }, categories: { + categoryName: 'Nombre de la categoría', requiresCategory: 'Los miembros deben categorizar todos los gastos', enableCategory: 'Activar categoría', subtitle: 'Obtén una visión general de dónde te gastas el dinero. Utiliza las categorías predeterminadas o añade las tuyas propias.', @@ -1773,6 +1788,7 @@ export default { title: 'No has creado ninguna categoría', subtitle: 'Añade una categoría para organizar tu gasto.', }, + genericFailureMessage: 'Se ha producido un error al intentar eliminar la categoría. Por favor, inténtalo más tarde.', }, emptyWorkspace: { title: 'Crea un espacio de trabajo', diff --git a/src/libs/API/parameters/SetWorkspaceAutoReportingFrequencyParams.ts b/src/libs/API/parameters/SetWorkspaceAutoReportingFrequencyParams.ts new file mode 100644 index 000000000000..81c6b47b8e09 --- /dev/null +++ b/src/libs/API/parameters/SetWorkspaceAutoReportingFrequencyParams.ts @@ -0,0 +1,9 @@ +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; + +type SetWorkspaceAutoReportingFrequencyParams = { + policyID: string; + frequency: ValueOf; +}; + +export default SetWorkspaceAutoReportingFrequencyParams; diff --git a/src/libs/API/parameters/SetWorkspaceAutoReportingMonthlyOffsetParams.ts b/src/libs/API/parameters/SetWorkspaceAutoReportingMonthlyOffsetParams.ts new file mode 100644 index 000000000000..d8c3d252dfc2 --- /dev/null +++ b/src/libs/API/parameters/SetWorkspaceAutoReportingMonthlyOffsetParams.ts @@ -0,0 +1,6 @@ +type SetWorkspaceAutoReportingMonthlyOffsetParams = { + policyID: string; + value: string; +}; + +export default SetWorkspaceAutoReportingMonthlyOffsetParams; diff --git a/src/libs/API/parameters/SetWorkspaceCategoriesEnabledParams.ts b/src/libs/API/parameters/SetWorkspaceCategoriesEnabledParams.ts new file mode 100644 index 000000000000..0851dc366819 --- /dev/null +++ b/src/libs/API/parameters/SetWorkspaceCategoriesEnabledParams.ts @@ -0,0 +1,10 @@ +type SetWorkspaceCategoriesEnabledParams = { + policyID: string; + /** + * Stringified JSON object with type of following structure: + * Array<{name: string; enabled: boolean}> + */ + categories: string; +}; + +export default SetWorkspaceCategoriesEnabledParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 051f132456bf..4fbc597b8186 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -146,7 +146,10 @@ export type {default as CancelPaymentParams} from './CancelPaymentParams'; export type {default as AcceptACHContractForBankAccount} from './AcceptACHContractForBankAccount'; export type {default as UpdateWorkspaceDescriptionParams} from './UpdateWorkspaceDescriptionParams'; export type {default as UpdateWorkspaceMembersRoleParams} from './UpdateWorkspaceMembersRoleParams'; +export type {default as SetWorkspaceCategoriesEnabledParams} from './SetWorkspaceCategoriesEnabledParams'; export type {default as SetWorkspaceRequiresCategoryParams} from './SetWorkspaceRequiresCategoryParams'; export type {default as SetWorkspaceAutoReportingParams} from './SetWorkspaceAutoReportingParams'; +export type {default as SetWorkspaceAutoReportingFrequencyParams} from './SetWorkspaceAutoReportingFrequencyParams'; +export type {default as SetWorkspaceAutoReportingMonthlyOffsetParams} from './SetWorkspaceAutoReportingMonthlyOffsetParams'; export type {default as SetWorkspaceApprovalModeParams} from './SetWorkspaceApprovalModeParams'; export type {default as SwitchToOldDotParams} from './SwitchToOldDotParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 87ae2a4d7b2c..ba49bc5fa27b 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -9,6 +9,8 @@ type ApiRequest = ValueOf; const WRITE_COMMANDS = { SET_WORKSPACE_AUTO_REPORTING: 'SetWorkspaceAutoReporting', + SET_WORKSPACE_AUTO_REPORTING_FREQUENCY: 'SetWorkspaceAutoReportingFrequency', + SET_WORKSPACE_AUTO_REPORTING_MONTHLY_OFFSET: 'UpdatePolicy', SET_WORKSPACE_APPROVAL_MODE: 'SetWorkspaceApprovalMode', DISMISS_REFERRAL_BANNER: 'DismissReferralBanner', UPDATE_PREFERRED_LOCALE: 'UpdatePreferredLocale', @@ -112,6 +114,7 @@ const WRITE_COMMANDS = { UPDATE_WORKSPACE_MEMBERS_ROLE: 'UpdateWorkspaceMembersRole', CREATE_WORKSPACE: 'CreateWorkspace', CREATE_WORKSPACE_FROM_IOU_PAYMENT: 'CreateWorkspaceFromIOUPayment', + SET_WORKSPACE_CATEGORIES_ENABLED: 'SetWorkspaceCategoriesEnabled', SET_WORKSPACE_REQUIRES_CATEGORY: 'SetWorkspaceRequiresCategory', CREATE_TASK: 'CreateTask', CANCEL_TASK: 'CancelTask', @@ -260,6 +263,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT_AND_RATE]: Parameters.UpdateWorkspaceCustomUnitAndRateParams; [WRITE_COMMANDS.CREATE_WORKSPACE]: Parameters.CreateWorkspaceParams; [WRITE_COMMANDS.CREATE_WORKSPACE_FROM_IOU_PAYMENT]: Parameters.CreateWorkspaceFromIOUPaymentParams; + [WRITE_COMMANDS.SET_WORKSPACE_CATEGORIES_ENABLED]: Parameters.SetWorkspaceCategoriesEnabledParams; [WRITE_COMMANDS.SET_WORKSPACE_REQUIRES_CATEGORY]: Parameters.SetWorkspaceRequiresCategoryParams; [WRITE_COMMANDS.CREATE_TASK]: Parameters.CreateTaskParams; [WRITE_COMMANDS.CANCEL_TASK]: Parameters.CancelTaskParams; @@ -302,6 +306,8 @@ type WriteCommandParameters = { [WRITE_COMMANDS.ACCEPT_ACH_CONTRACT_FOR_BANK_ACCOUNT]: Parameters.AcceptACHContractForBankAccount; [WRITE_COMMANDS.UPDATE_WORKSPACE_DESCRIPTION]: Parameters.UpdateWorkspaceDescriptionParams; [WRITE_COMMANDS.SET_WORKSPACE_AUTO_REPORTING]: Parameters.SetWorkspaceAutoReportingParams; + [WRITE_COMMANDS.SET_WORKSPACE_AUTO_REPORTING_FREQUENCY]: Parameters.SetWorkspaceAutoReportingFrequencyParams; + [WRITE_COMMANDS.SET_WORKSPACE_AUTO_REPORTING_MONTHLY_OFFSET]: Parameters.SetWorkspaceAutoReportingMonthlyOffsetParams; [WRITE_COMMANDS.SET_WORKSPACE_APPROVAL_MODE]: Parameters.SetWorkspaceApprovalModeParams; [WRITE_COMMANDS.SWITCH_TO_OLD_DOT]: Parameters.SwitchToOldDotParams; }; diff --git a/src/libs/HttpUtils.ts b/src/libs/HttpUtils.ts index 51418cffebe5..f52fefe02386 100644 --- a/src/libs/HttpUtils.ts +++ b/src/libs/HttpUtils.ts @@ -28,6 +28,9 @@ Onyx.connect({ // We use the AbortController API to terminate pending request in `cancelPendingRequests` let cancellationController = new AbortController(); +// Some existing old commands (6+ years) exempted from the auth writes count check +const exemptedCommandsWithAuthWrites: string[] = ['SetWorkspaceAutoReportingFrequency']; + /** * The API commands that require the skew calculation */ @@ -120,7 +123,8 @@ function processHTTPRequest(url: string, method: RequestType = 'get', body: Form title: CONST.ERROR_TITLE.SOCKET, }); } - if (response.jsonCode === CONST.JSON_CODE.MANY_WRITES_ERROR) { + + if (response.jsonCode === CONST.JSON_CODE.MANY_WRITES_ERROR && !exemptedCommandsWithAuthWrites.includes(response.data?.phpCommandName ?? '')) { if (response.data) { const {phpCommandName, authWriteCommands} = response.data; // eslint-disable-next-line max-len diff --git a/src/libs/LocaleDigitUtils.ts b/src/libs/LocaleDigitUtils.ts index 794c7611cb5c..156e58c59033 100644 --- a/src/libs/LocaleDigitUtils.ts +++ b/src/libs/LocaleDigitUtils.ts @@ -1,6 +1,8 @@ import _ from 'lodash'; import type {ValueOf} from 'type-fest'; import type CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import * as Localize from './Localize'; import * as NumberFormatUtils from './NumberFormatUtils'; type Locale = ValueOf; @@ -66,4 +68,30 @@ function fromLocaleDigit(locale: Locale, localeDigit: string): string { return STANDARD_DIGITS[index]; } -export {toLocaleDigit, fromLocaleDigit}; +/** + * Formats a number into its localized ordinal representation i.e 1st, 2nd etc + */ +function toLocaleOrdinal(locale: Locale, number: number): string { + // Defaults to "other" suffix or "th" in English + let suffixKey = 'workflowsPage.frequencies.ordinals.other'; + + // Calculate last digit of the number to determine basic ordinality + const lastDigit = number % 10; + + // Calculate last two digits to handle exceptions in the 11-13 range + const lastTwoDigits = number % 100; + + if (lastDigit === 1 && lastTwoDigits !== 11) { + suffixKey = 'workflowsPage.frequencies.ordinals.one'; + } else if (lastDigit === 2 && lastTwoDigits !== 12) { + suffixKey = 'workflowsPage.frequencies.ordinals.two'; + } else if (lastDigit === 3 && lastTwoDigits !== 13) { + suffixKey = 'workflowsPage.frequencies.ordinals.few'; + } + + const suffix = Localize.translate(locale, suffixKey as TranslationPaths); + + return `${number}${suffix}`; +} + +export {toLocaleDigit, toLocaleOrdinal, fromLocaleDigit}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index 3d0144d8cf77..527d93c2a3db 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -248,6 +248,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/workspace/WorkspaceProfileDescriptionPage').default as React.ComponentType, [SCREENS.WORKSPACE.SHARE]: () => require('../../../pages/workspace/WorkspaceProfileSharePage').default as React.ComponentType, [SCREENS.WORKSPACE.CURRENCY]: () => require('../../../pages/workspace/WorkspaceProfileCurrencyPage').default as React.ComponentType, + [SCREENS.WORKSPACE.CATEGORY_SETTINGS]: () => require('../../../pages/workspace/categories/CategorySettingsPage').default as React.ComponentType, [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: () => require('../../../pages/workspace/categories/WorkspaceCategoriesSettingsPage').default as React.ComponentType, [SCREENS.REIMBURSEMENT_ACCOUNT]: () => require('../../../pages/ReimbursementAccount/ReimbursementAccountPage').default as React.ComponentType, [SCREENS.GET_ASSISTANCE]: () => require('../../../pages/GetAssistancePage').default as React.ComponentType, @@ -257,6 +258,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/ExitSurvey/ExitSurveyReasonPage').default as React.ComponentType, [SCREENS.SETTINGS.EXIT_SURVEY.RESPONSE]: () => require('../../../pages/settings/ExitSurvey/ExitSurveyResponsePage').default as React.ComponentType, [SCREENS.SETTINGS.EXIT_SURVEY.CONFIRM]: () => 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, }); const EnablePaymentsStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 7a6211ebd283..7e0e6c028ff1 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -241,6 +241,12 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.DESCRIPTION]: { path: ROUTES.WORKSPACE_PROFILE_DESCRIPTION.route, }, + [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY]: { + path: ROUTES.WORKSPACE_WORKFLOWS_AUTOREPORTING_FREQUENCY.route, + }, + [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET]: { + path: ROUTES.WORKSPACE_WORKFLOWS_AUTOREPORTING_MONTHLY_OFFSET.route, + }, [SCREENS.WORKSPACE.SHARE]: { path: ROUTES.WORKSPACE_PROFILE_SHARE.route, }, @@ -259,6 +265,12 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.INVITE_MESSAGE]: { path: ROUTES.WORKSPACE_INVITE_MESSAGE.route, }, + [SCREENS.WORKSPACE.CATEGORY_SETTINGS]: { + path: ROUTES.WORKSPACE_CATEGORY_SETTINGS.route, + parse: { + categoryName: (categoryName: string) => decodeURI(categoryName), + }, + }, [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: { path: ROUTES.WORKSPACE_CATEGORIES_SETTINGS.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index e0bbbe95802f..6d680ac7e190 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -63,6 +63,12 @@ type CentralPaneNavigatorParamList = { [SCREENS.WORKSPACE.WORKFLOWS]: { policyID: string; }; + [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY]: { + policyID: string; + }; + [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET]: { + policyID: string; + }; [SCREENS.WORKSPACE.REIMBURSE]: { policyID: string; }; @@ -180,6 +186,10 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.INVITE_MESSAGE]: { policyID: string; }; + [SCREENS.WORKSPACE.CATEGORY_SETTINGS]: { + policyID: string; + categoryName: string; + }; [SCREENS.WORKSPACE.CATEGORIES_SETTINGS]: { policyID: string; }; diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index ce5e0e674c59..c9f386f5bd7a 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 canUseWorkflowsDelayedSubmission(betas: OnyxEntry): boolean { + return !!betas?.includes(CONST.BETAS.WORKFLOWS_DELAYED_SUBMISSION) || canUseAllBetas(betas); +} + /** * Link previews are temporarily disabled. */ @@ -40,4 +44,5 @@ export default { canUseLinkPreviews, canUseViolations, canUseReportFields, + canUseWorkflowsDelayedSubmission, }; diff --git a/src/libs/actions/Policy.ts b/src/libs/actions/Policy.ts index 219dd39b30ac..313c4deb9934 100644 --- a/src/libs/actions/Policy.ts +++ b/src/libs/actions/Policy.ts @@ -21,6 +21,8 @@ import type { OpenWorkspaceParams, OpenWorkspaceReimburseViewParams, SetWorkspaceApprovalModeParams, + SetWorkspaceAutoReportingFrequencyParams, + SetWorkspaceAutoReportingMonthlyOffsetParams, SetWorkspaceAutoReportingParams, UpdateWorkspaceAvatarParams, UpdateWorkspaceCustomUnitAndRateParams, @@ -44,6 +46,7 @@ import type { InvitedEmailsToAccountIDs, PersonalDetailsList, Policy, + PolicyCategories, PolicyMember, PolicyTagList, RecentlyUsedCategories, @@ -207,6 +210,13 @@ Onyx.connect({ callback: (val) => (allRecentlyUsedTags = val), }); +let allPolicyCategories: OnyxCollection = {}; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY_CATEGORIES, + waitForCollectionCallback: true, + callback: (val) => (allPolicyCategories = val), +}); + /** * Stores in Onyx the policy ID of the last workspace that was accessed by the user */ @@ -432,6 +442,80 @@ function setWorkspaceAutoReporting(policyID: string, enabled: boolean) { API.write(WRITE_COMMANDS.SET_WORKSPACE_AUTO_REPORTING, params, {optimisticData, failureData, successData}); } +function setWorkspaceAutoReportingFrequency(policyID: string, frequency: ValueOf) { + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReportingFrequency: frequency, + pendingFields: {autoReportingFrequency: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: {autoReportingFrequency: null}, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: {autoReportingFrequency: null}, + }, + }, + ]; + + const params: SetWorkspaceAutoReportingFrequencyParams = {policyID, frequency}; + API.write(WRITE_COMMANDS.SET_WORKSPACE_AUTO_REPORTING_FREQUENCY, params, {optimisticData, failureData, successData}); +} + +function setWorkspaceAutoReportingMonthlyOffset(policyID: string, autoReportingOffset: number | ValueOf) { + const value = JSON.stringify({autoReportingOffset: autoReportingOffset.toString()}); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + autoReportingOffset, + pendingFields: {autoReportingOffset: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: {autoReportingOffset: null}, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: {autoReportingOffset: null}, + }, + }, + ]; + + const params: SetWorkspaceAutoReportingMonthlyOffsetParams = {policyID, value}; + API.write(WRITE_COMMANDS.SET_WORKSPACE_AUTO_REPORTING_MONTHLY_OFFSET, params, {optimisticData, failureData, successData}); +} + function setWorkspaceApprovalMode(policyID: string, approver: string, approvalMode: ValueOf) { const isAutoApprovalEnabled = approvalMode === CONST.POLICY.APPROVAL_MODE.BASIC; @@ -2261,7 +2345,81 @@ function createWorkspaceFromIOUPayment(iouReport: Report | EmptyObject): string return policyID; } -const setWorkspaceRequiresCategory = (policyID: string, requiresCategory: boolean) => { +function setWorkspaceCategoryEnabled(policyID: string, categoriesToUpdate: Record) { + const policyCategories = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`] ?? {}; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + ...Object.keys(categoriesToUpdate).reduce((acc, key) => { + acc[key] = { + ...policyCategories[key], + ...categoriesToUpdate[key], + errors: null, + pendingFields: { + enabled: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, + }; + + return acc; + }, {}), + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + ...Object.keys(categoriesToUpdate).reduce((acc, key) => { + acc[key] = { + ...policyCategories[key], + ...categoriesToUpdate[key], + errors: null, + pendingFields: { + enabled: null, + }, + }; + + return acc; + }, {}), + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, + value: { + ...Object.keys(categoriesToUpdate).reduce((acc, key) => { + acc[key] = { + ...policyCategories[key], + ...categoriesToUpdate[key], + errors: ErrorUtils.getMicroSecondOnyxError('workspace.categories.genericFailureMessage'), + pendingFields: { + enabled: null, + }, + }; + + return acc; + }, {}), + }, + }, + ], + }; + + const parameters = { + policyID, + categories: JSON.stringify(Object.keys(categoriesToUpdate).map((key) => categoriesToUpdate[key])), + }; + + API.write('SetWorkspaceCategoriesEnabled', parameters, onyxData); +} + +function setWorkspaceRequiresCategory(policyID: string, requiresCategory: boolean) { const onyxData: OnyxData = { optimisticData: [ { @@ -2313,7 +2471,21 @@ const setWorkspaceRequiresCategory = (policyID: string, requiresCategory: boolea }; API.write('SetWorkspaceRequiresCategory', parameters, onyxData); -}; +} + +function clearCategoryErrors(policyID: string, categoryName: string) { + const category = allPolicyCategories?.[`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`]?.[categoryName]; + + if (!category) { + return; + } + + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, { + [category.name]: { + errors: null, + }, + }); +} export { removeMembers, @@ -2358,6 +2530,10 @@ export { setWorkspaceInviteMessageDraft, setWorkspaceAutoReporting, setWorkspaceApprovalMode, + setWorkspaceAutoReportingFrequency, + setWorkspaceAutoReportingMonthlyOffset, updateWorkspaceDescription, + setWorkspaceCategoryEnabled, setWorkspaceRequiresCategory, + clearCategoryErrors, }; diff --git a/src/pages/workspace/categories/CategorySettingsPage.tsx b/src/pages/workspace/categories/CategorySettingsPage.tsx new file mode 100644 index 000000000000..16f128e5ea1f --- /dev/null +++ b/src/pages/workspace/categories/CategorySettingsPage.tsx @@ -0,0 +1,90 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +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 {setWorkspaceCategoryEnabled} from '@libs/actions/Policy'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; +import AdminPolicyAccessOrNotFoundWrapper from '@pages/workspace/AdminPolicyAccessOrNotFoundWrapper'; +import PaidPolicyAccessOrNotFoundWrapper from '@pages/workspace/PaidPolicyAccessOrNotFoundWrapper'; +import * as Policy from '@userActions/Policy'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import type * as OnyxTypes from '@src/types/onyx'; + +type CategorySettingsPageOnyxProps = { + /** Collection of categories attached to a policy */ + policyCategories: OnyxEntry; +}; + +type CategorySettingsPageProps = CategorySettingsPageOnyxProps & StackScreenProps; + +function CategorySettingsPage({route, policyCategories}: CategorySettingsPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const policyCategory = policyCategories?.[route.params.categoryName]; + + if (!policyCategory) { + return ; + } + + const updateWorkspaceRequiresCategory = (value: boolean) => { + setWorkspaceCategoryEnabled(route.params.policyID, {[policyCategory.name]: {name: policyCategory.name, enabled: value}}); + }; + + return ( + + + + + + Policy.clearCategoryErrors(route.params.policyID, route.params.categoryName)} + > + + + {translate('workspace.categories.enableCategory')} + + + + + + + + + + ); +} + +CategorySettingsPage.displayName = 'CategorySettingsPage'; + +export default withOnyx({ + policyCategories: { + key: ({route}) => `${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${route.params.policyID}`, + }, +})(CategorySettingsPage); diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 721341073d72..d15011489bac 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -89,15 +89,19 @@ function WorkspaceCategoriesPage({policyCategories, route}: WorkspaceCategoriesP ); - const navigateToCategorySettings = () => { + const navigateToCategoriesSettings = () => { Navigation.navigate(ROUTES.WORKSPACE_CATEGORIES_SETTINGS.getRoute(route.params.policyID)); }; + const navigateToCategorySettings = (category: PolicyForList) => { + Navigation.navigate(ROUTES.WORKSPACE_CATEGORY_SETTINGS.getRoute(route.params.policyID, category.text)); + }; + const settingsButton = (