diff --git a/src/CONST.ts b/src/CONST.ts index 8d4eaac44a38..7fa3d158e12e 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..b3c3d9a52a16 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, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index ac75968e68b9..771007e63113 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -216,6 +216,8 @@ 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', 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/languages/en.ts b/src/languages/en.ts index 995279033a3e..a7f65a93aff7 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', diff --git a/src/languages/es.ts b/src/languages/es.ts index f4b4e6a2f21e..2c045d826c7d 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', 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/index.ts b/src/libs/API/parameters/index.ts index fc24b97ff1f3..a0fb81936ba3 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -147,5 +147,7 @@ export type {default as AcceptACHContractForBankAccount} from './AcceptACHContra export type {default as UpdateWorkspaceDescriptionParams} from './UpdateWorkspaceDescriptionParams'; 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 c6fd1154fbf1..0172732c826f 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', @@ -300,6 +302,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..e603e785040d 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -257,6 +257,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..dc8f21c2ba75 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, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index e0bbbe95802f..6ab961778d8e 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; }; 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 ce222940c7ca..4996f7e91033 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, @@ -425,6 +427,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; @@ -2290,6 +2366,8 @@ export { setWorkspaceInviteMessageDraft, setWorkspaceAutoReporting, setWorkspaceApprovalMode, + setWorkspaceAutoReportingFrequency, + setWorkspaceAutoReportingMonthlyOffset, updateWorkspaceDescription, setWorkspaceRequiresCategory, }; diff --git a/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx b/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx new file mode 100644 index 000000000000..cf66af726a72 --- /dev/null +++ b/src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx @@ -0,0 +1,136 @@ +import React, {useState} from 'react'; +import {FlatList} from 'react-native-gesture-handler'; +import type {ValueOf} from 'type-fest'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItem from '@components/MenuItem'; +import OfflineWithFeedback from '@components/OfflineWithFeedback'; +import ScreenWrapper from '@components/ScreenWrapper'; +import RadioListItem from '@components/SelectionList/RadioListItem'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as Localize from '@libs/Localize'; +import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import withPolicy from '@pages/workspace/withPolicy'; +import type {WithPolicyOnyxProps} from '@pages/workspace/withPolicy'; +import * as Policy from '@userActions/Policy'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type AutoReportingFrequencyKey = Exclude, 'instant'>; +type Locale = ValueOf; + +type WorkspaceAutoReportingFrequencyPageProps = WithPolicyOnyxProps; + +type WorkspaceAutoReportingFrequencyPageItem = { + text: string; + keyForList: string; + isSelected: boolean; +}; + +type AutoReportingFrequencyDisplayNames = Record; + +const getAutoReportingFrequencyDisplayNames = (locale: Locale): AutoReportingFrequencyDisplayNames => ({ + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY]: Localize.translate(locale, 'workflowsPage.frequencies.monthly'), + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.IMMEDIATE]: Localize.translate(locale, 'workflowsPage.frequencies.daily'), + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.WEEKLY]: Localize.translate(locale, 'workflowsPage.frequencies.weekly'), + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.SEMI_MONTHLY]: Localize.translate(locale, 'workflowsPage.frequencies.twiceAMonth'), + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.TRIP]: Localize.translate(locale, 'workflowsPage.frequencies.byTrip'), + [CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MANUAL]: Localize.translate(locale, 'workflowsPage.frequencies.manually'), +}); + +function WorkspaceAutoReportingFrequencyPage({policy}: WorkspaceAutoReportingFrequencyPageProps) { + const {translate, preferredLocale, toLocaleOrdinal} = useLocalize(); + const styles = useThemeStyles(); + const [isMonthlyFrequency, setIsMonthlyFrequency] = useState(policy?.autoReportingFrequency === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY); + + const autoReportingFrequencyItems: WorkspaceAutoReportingFrequencyPageItem[] = Object.keys(getAutoReportingFrequencyDisplayNames(preferredLocale)).map((frequencyKey) => { + const isSelected = policy?.autoReportingFrequency === frequencyKey; + + return { + text: getAutoReportingFrequencyDisplayNames(preferredLocale)[frequencyKey as AutoReportingFrequencyKey] || '', + keyForList: frequencyKey, + isSelected, + }; + }); + + const onSelectAutoReportingFrequency = (item: WorkspaceAutoReportingFrequencyPageItem) => { + Policy.setWorkspaceAutoReportingFrequency(policy?.id ?? '', item.keyForList as AutoReportingFrequencyKey); + + if (item.keyForList === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY) { + setIsMonthlyFrequency(true); + return; + } + + setIsMonthlyFrequency(false); + Navigation.goBack(); + }; + + const getDescriptionText = () => { + if (policy?.autoReportingOffset === undefined) { + return toLocaleOrdinal(1); + } + if (typeof policy?.autoReportingOffset === 'number') { + return toLocaleOrdinal(policy.autoReportingOffset); + } + + return translate(`workflowsPage.frequencies.${policy?.autoReportingOffset}`); + }; + + const monthlyFrequencyDetails = () => ( + + Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_AUTOREPORTING_MONTHLY_OFFSET.getRoute(policy?.id ?? ''))} + shouldShowRightIcon + /> + + ); + + const renderItem = ({item}: {item: WorkspaceAutoReportingFrequencyPageItem}) => ( + <> + onSelectAutoReportingFrequency(item)} + showTooltip={false} + /> + {isMonthlyFrequency && item.keyForList === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.MONTHLY ? monthlyFrequencyDetails() : null} + + ); + + return ( + + + + + item.text} + /> + + + ); +} + +WorkspaceAutoReportingFrequencyPage.displayName = 'WorkspaceAutoReportingFrequencyPage'; +export type {AutoReportingFrequencyDisplayNames, AutoReportingFrequencyKey}; +export {getAutoReportingFrequencyDisplayNames}; +export default withPolicy(WorkspaceAutoReportingFrequencyPage); diff --git a/src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx b/src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx new file mode 100644 index 000000000000..84d70e799c42 --- /dev/null +++ b/src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx @@ -0,0 +1,102 @@ +import React, {useState} from 'react'; +import type {ValueOf} from 'type-fest'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; +import useLocalize from '@hooks/useLocalize'; +import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; +import withPolicy from '@pages/workspace/withPolicy'; +import type {WithPolicyOnyxProps} from '@pages/workspace/withPolicy'; +import * as Policy from '@userActions/Policy'; +import CONST from '@src/CONST'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +const DAYS_OF_MONTH = 28; + +type WorkspaceAutoReportingMonthlyOffsetProps = WithPolicyOnyxProps; + +type AutoReportingOffsetKeys = ValueOf; + +type WorkspaceAutoReportingMonthlyOffsetPageItem = { + text: string; + keyForList: string; + isSelected: boolean; + isNumber?: boolean; +}; + +function WorkspaceAutoReportingMonthlyOffsetPage({policy}: WorkspaceAutoReportingMonthlyOffsetProps) { + const {translate, toLocaleOrdinal} = useLocalize(); + const offset = policy?.autoReportingOffset ?? 0; + const [searchText, setSearchText] = useState(''); + const trimmedText = searchText.trim().toLowerCase(); + + const daysOfMonth: WorkspaceAutoReportingMonthlyOffsetPageItem[] = Array.from({length: DAYS_OF_MONTH}, (value, index) => { + const day = index + 1; + + return { + text: toLocaleOrdinal(day), + keyForList: day.toString(), // we have to cast it as string for to work + isSelected: day === offset, + isNumber: true, + }; + }).concat([ + { + keyForList: 'lastDayOfMonth', + text: translate('workflowsPage.frequencies.lastDayOfMonth'), + isSelected: offset === CONST.POLICY.AUTO_REPORTING_OFFSET.LAST_DAY_OF_MONTH, + isNumber: false, + }, + { + keyForList: 'lastBusinessDayOfMonth', + text: translate('workflowsPage.frequencies.lastBusinessDayOfMonth'), + isSelected: offset === CONST.POLICY.AUTO_REPORTING_OFFSET.LAST_BUSINESS_DAY_OF_MONTH, + isNumber: false, + }, + ]); + + const filteredDaysOfMonth = daysOfMonth.filter((dayItem) => dayItem.text.toLowerCase().includes(trimmedText)); + + const headerMessage = searchText.trim() && !filteredDaysOfMonth.length ? translate('common.noResultsFound') : ''; + + const onSelectDayOfMonth = (item: WorkspaceAutoReportingMonthlyOffsetPageItem) => { + Policy.setWorkspaceAutoReportingMonthlyOffset(policy?.id ?? '', item.isNumber ? parseInt(item.keyForList, 10) : (item.keyForList as AutoReportingOffsetKeys)); + Navigation.goBack(); + }; + + return ( + + + + + + + + ); +} + +WorkspaceAutoReportingMonthlyOffsetPage.displayName = 'WorkspaceAutoReportingMonthlyOffsetPage'; +export default withPolicy(WorkspaceAutoReportingMonthlyOffsetPage); diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index fc1ed1d19560..d9974ed193be 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -1,6 +1,8 @@ import type {StackScreenProps} from '@react-navigation/stack'; -import React, {useMemo} from 'react'; +import React, {useCallback, useMemo} from 'react'; import {FlatList, View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; import * as Illustrations from '@components/Icon/Illustrations'; import MenuItem from '@components/MenuItem'; import Section from '@components/Section'; @@ -10,23 +12,34 @@ import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import Navigation from '@libs/Navigation/Navigation'; import * as OptionsListUtils from '@libs/OptionsListUtils'; +import Permissions from '@libs/Permissions'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import type {CentralPaneNavigatorParamList} from '@navigation/types'; -import withPolicy from '@pages/workspace/withPolicy'; import type {WithPolicyProps} from '@pages/workspace/withPolicy'; +import withPolicy from '@pages/workspace/withPolicy'; import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections'; 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'; +import type {Beta} from '@src/types/onyx'; import ToggleSettingOptionRow from './ToggleSettingsOptionRow'; import type {ToggleSettingOptionRowProps} from './ToggleSettingsOptionRow'; +import {getAutoReportingFrequencyDisplayNames} from './WorkspaceAutoReportingFrequencyPage'; +import type {AutoReportingFrequencyKey} from './WorkspaceAutoReportingFrequencyPage'; -type WorkspaceWorkflowsPageProps = WithPolicyProps & StackScreenProps; +type WorkspaceWorkflowsPageOnyxProps = { + /** Beta features list */ + betas: OnyxEntry; +}; +type WorkspaceWorkflowsPageProps = WithPolicyProps & WorkspaceWorkflowsPageOnyxProps & StackScreenProps; -function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { - const {translate} = useLocalize(); +function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPageProps) { + const {translate, preferredLocale} = useLocalize(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {isSmallScreenWidth} = useWindowDimensions(); @@ -35,32 +48,42 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const ownerPersonalDetails = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs([policy?.ownerAccountID ?? 0], CONST.EMPTY_OBJECT), false); const policyOwnerDisplayName = ownerPersonalDetails[0]?.displayName; const containerStyle = useMemo(() => [styles.ph8, styles.mhn8, styles.ml11, styles.pv3, styles.pr0, styles.pl4, styles.mr0, styles.widthAuto, styles.mt4], [styles]); + const canUseDelayedSubmission = Permissions.canUseWorkflowsDelayedSubmission(betas); + + const onPressAutoReportingFrequency = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_AUTOREPORTING_FREQUENCY.getRoute(policy?.id ?? '')), [policy?.id]); const items: ToggleSettingOptionRowProps[] = useMemo( () => [ - { - icon: Illustrations.ReceiptEnvelope, - title: translate('workflowsPage.delaySubmissionTitle'), - subtitle: translate('workflowsPage.delaySubmissionDescription'), - onToggle: (isEnabled: boolean) => { - Policy.setWorkspaceAutoReporting(route.params.policyID, isEnabled); - }, - subMenuItems: ( - Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_AUTOREPORTING_FREQUENCY).getRoute(route.params.policyID))} - // TODO will be done in https://github.com/Expensify/Expensify/issues/368332 - description={translate('workflowsPage.weeklyFrequency')} - shouldShowRightIcon - wrapperStyle={containerStyle} - hoverAndPressStyle={[styles.mr0, styles.br2]} - /> - ), - isActive: policy?.harvesting?.enabled ?? false, - pendingAction: policy?.pendingFields?.isAutoApprovalEnabled, - }, + ...(canUseDelayedSubmission + ? [ + { + icon: Illustrations.ReceiptEnvelope, + title: translate('workflowsPage.delaySubmissionTitle'), + subtitle: translate('workflowsPage.delaySubmissionDescription'), + onToggle: (isEnabled: boolean) => { + Policy.setWorkspaceAutoReporting(route.params.policyID, isEnabled); + }, + subMenuItems: ( + + ), + isActive: policy?.harvesting?.enabled ?? false, + pendingAction: policy?.pendingFields?.isAutoApprovalEnabled, + }, + ] + : []), { icon: Illustrations.Approval, title: translate('workflowsPage.addApprovalsTitle'), @@ -108,7 +131,19 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { isActive: false, // TODO will be done in https://github.com/Expensify/Expensify/issues/368335 }, ], - [policy, route.params.policyID, styles, translate, policyOwnerDisplayName, containerStyle, isOffline, StyleUtils], + [ + policy, + route.params.policyID, + styles, + translate, + policyOwnerDisplayName, + containerStyle, + isOffline, + StyleUtils, + onPressAutoReportingFrequency, + preferredLocale, + canUseDelayedSubmission, + ], ); const renderItem = ({item}: {item: ToggleSettingOptionRowProps}) => ( @@ -159,4 +194,10 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { WorkspaceWorkflowsPage.displayName = 'WorkspaceWorkflowsPage'; -export default withPolicy(WorkspaceWorkflowsPage); +export default withPolicy( + withOnyx({ + betas: { + key: ONYXKEYS.BETAS, + }, + })(WorkspaceWorkflowsPage), +);