diff --git a/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md b/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md index eef91bd6fa0e..8d3097664a55 100644 --- a/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md +++ b/docs/articles/new-expensify/settings/Delegate-when-out-of-office.md @@ -2,14 +2,14 @@ title: Delegate When Out of Office description: Set a vacation delegate to approve reports on your behalf while you're away. keywords: [New Expensify, delegate approvals, out-of-office approver, vacation, out of office, approval delegation, temporary delegate, vacation delegate, copilot vs delegate, copilot, shared access, proxy, behalf of] -internalScope: Audience is all members. Covers assigning and removing your own vacation delegate from your profile status. Does not cover Copilot access or Expensify Classic delegates. +internalScope: Audience is all members. Covers assigning, scheduling the end of, and removing your own vacation delegate from your profile. Does not cover Copilot access or Expensify Classic delegates. --- # Delegate When Out of Office If you're out of the office or on vacation, you can assign a vacation delegate to handle your approvals while you're away. -A vacation delegate can approve reports on your behalf. Once assigned, any reports sent to you for approval will be automatically redirected to your delegate. When you're back, just remove the delegate to resume handling your own reports. +A vacation delegate can approve reports on your behalf. Once assigned, any reports sent to you for approval will be automatically redirected to your delegate. Pick a **Clear after** date and time and the delegate is removed automatically at that time, so reports come back to you when you return. All delegate actions are tracked in the report history for full visibility. @@ -17,19 +17,20 @@ All delegate actions are tracked in the report history for full visibility. ## How to assign a vacation delegate -1. Go to **Account > Profile > Status**. -2. Under **Vacation delegate**, enter the email address or phone number of your delegate. -3. Click **Save**. +1. Go to **Account > Profile > Vacation delegate**. +2. Select the member who should approve on your behalf. +3. Under **Clear after (recommended)**, pick the last day the delegate should cover (optional). +4. Under **Time**, pick when the delegate clears on that day (defaults to the end of the day). +5. Click **Save**. -Once set, any reports or chats that would normally come to you will be redirected to your delegate. You can view every action they take in the report's history and comments. +Once set, any reports or chats that would normally come to you will be redirected to your delegate. You can view every action they take in the report's history and comments. The delegate and the date and time it clears are shown under **Vacation delegate** on your profile. ## How to remove a vacation delegate -When you're ready to take back control of your reports: +If you set a **Clear after** date, the delegate is removed automatically. To remove it sooner: -1. Go to **Account > Profile > Status**. -2. Under **Vacation delegate**, clear the delegate's email or phone number. -3. Click **Save**. +1. Go to **Account > Profile > Vacation delegate**. +2. Click **Remove vacation delegate**. --- @@ -49,7 +50,7 @@ Your delegate can assign their own vacation delegate too. This ensures approvals ## If I clear my status, does that remove my vacation delegate? -No. Clearing your status won't affect your delegate. You'll need to manually remove them when you're back. +No. Clearing your status won't affect your delegate. Set a **Clear after** date on your vacation delegate, or remove them when you're back. ## What if my delegate isn’t in the same workspace? diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 875e26e7bb2e..a35bbd7dfe8d 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -10429,6 +10429,7 @@ const CONST = { DISPLAY_NAME: 'SettingsProfile-DisplayName', CONTACT_METHODS: 'SettingsProfile-ContactMethods', STATUS: 'SettingsProfile-Status', + VACATION_DELEGATE: 'SettingsProfile-VacationDelegate', PRONOUNS: 'SettingsProfile-Pronouns', TIMEZONE: 'SettingsProfile-Timezone', SHARE_CODE: 'SettingsProfile-ShareCode', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index ba953d651f53..9aceaf15c17e 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1251,6 +1251,8 @@ const ONYXKEYS = { SETTINGS_STATUS_SET_FORM_DRAFT: 'settingsStatusSetFormDraft', SETTINGS_STATUS_CLEAR_DATE_FORM: 'settingsStatusClearDateForm', SETTINGS_STATUS_CLEAR_DATE_FORM_DRAFT: 'settingsStatusClearDateFormDraft', + VACATION_DELEGATE_FORM: 'vacationDelegateForm', + VACATION_DELEGATE_FORM_DRAFT: 'vacationDelegateFormDraft', CHANGE_BILLING_CURRENCY_FORM: 'billingCurrencyForm', CHANGE_BILLING_CURRENCY_FORM_DRAFT: 'billingCurrencyFormDraft', PRIVATE_NOTES_FORM: 'privateNotesForm', @@ -1495,6 +1497,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.WAYPOINT_FORM]: FormTypes.WaypointForm; [ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: FormTypes.SettingsStatusSetForm; [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_DATE_FORM]: FormTypes.SettingsStatusClearDateForm; + [ONYXKEYS.FORMS.VACATION_DELEGATE_FORM]: FormTypes.VacationDelegateForm; [ONYXKEYS.FORMS.CHANGE_BILLING_CURRENCY_FORM]: FormTypes.ChangeBillingCurrencyForm; [ONYXKEYS.FORMS.PRIVATE_NOTES_FORM]: FormTypes.PrivateNotesForm; [ONYXKEYS.FORMS.I_KNOW_A_TEACHER_FORM]: FormTypes.IKnowTeacherForm; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 6d8f0867ef1d..9ff9ef1baa84 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2774,8 +2774,9 @@ const ROUTES = { SETTINGS_STATUS_CLEAR_AFTER: 'settings/profile/status/clear-after', SETTINGS_STATUS_CLEAR_AFTER_DATE: 'settings/profile/status/clear-after/date', SETTINGS_STATUS_CLEAR_AFTER_TIME: 'settings/profile/status/clear-after/time', - SETTINGS_VACATION_DELEGATE: 'settings/profile/status/vacation-delegate', - SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES: 'settings/profile/status/vacation-delegate/missing-workspaces', + SETTINGS_VACATION_DELEGATE: 'settings/profile/vacation-delegate', + SETTINGS_VACATION_DELEGATE_SELECT: 'settings/profile/vacation-delegate/select', + SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES: 'settings/profile/vacation-delegate/missing-workspaces', SETTINGS_TROUBLESHOOT: 'settings/troubleshoot', SETTINGS_TROUBLESHOOT_SERVER: 'settings/troubleshoot/server', SETTINGS_HELP: 'settings/help', @@ -5669,6 +5670,11 @@ const ROUTES = { getRoute: (domainAccountID: number, accountID: number) => `domain/${domainAccountID}/members/${accountID}/vacation-delegate` as const, }, + DOMAIN_VACATION_DELEGATE_SELECT: { + route: 'domain/:domainAccountID/members/:accountID/vacation-delegate/select', + getRoute: (domainAccountID: number, accountID: number) => `domain/${domainAccountID}/members/${accountID}/vacation-delegate/select` as const, + }, + MULTIFACTOR_AUTHENTICATION_AUTHORIZE_TRANSACTION: { route: 'multifactor-authentication/authorize-transaction/:transactionID', getRoute: (transactionID: string) => `multifactor-authentication/authorize-transaction/${transactionID}` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 9905d3ad4829..36d69609edff 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -117,7 +117,8 @@ const SCREENS = { STATUS_CLEAR_AFTER: 'Settings_Status_Clear_After', STATUS_CLEAR_AFTER_DATE: 'Settings_Status_Clear_After_Date', STATUS_CLEAR_AFTER_TIME: 'Settings_Status_Clear_After_Time', - VACATION_DELEGATE: 'Settings_Status_Vacation_Delegate', + VACATION_DELEGATE: 'Settings_Profile_Vacation_Delegate', + VACATION_DELEGATE_SELECT: 'Settings_Profile_Vacation_Delegate_Select', VACATION_DELEGATE_MISSING_WORKSPACES: 'Settings_Status_Vacation_Delegate_Missing_Workspaces', STATUS: 'Settings_Status', PRONOUNS: 'Settings_Pronouns', @@ -1229,6 +1230,7 @@ const SCREENS = { MEMBERS: 'Domain_Members', MEMBER_DETAILS: 'Member_Details', VACATION_DELEGATE: 'Vacation_Delegate', + VACATION_DELEGATE_SELECT: 'Vacation_Delegate_Select', RESET_DOMAIN: 'Domain_Reset', ADD_MEMBER: 'Domain_Add_Member', MEMBERS_SETTINGS: 'Members_Settings', diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index fd76763912b1..58d4d0f13fcd 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -5,6 +5,7 @@ import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -51,9 +52,14 @@ function DatePicker({ rightHandSideComponent, onPickerVisibilityChange, shouldHideCalendarIcon = false, + icon, }: DateInputWithPickerProps) { const icons = useMemoizedLazyExpensifyIcons(['Calendar']); const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + // The clear button takes the calendar icon's place once a date is picked, so it gets the same horizontal padding as the icon's + // container instead of its own margins. Otherwise the X sits in a different spot than the icon it replaces. + const {paddingHorizontal: iconPaddingHorizontal} = StyleUtils.getTextInputIconContainerStyles(true); const {windowHeight, windowWidth} = useWindowDimensions(); const {translate} = useLocalize(); @@ -225,7 +231,7 @@ function DatePicker({ ref={combinedTextInputRef} inputID={inputID} forceActiveLabel - icon={selectedDate || shouldHideCalendarIcon ? null : icons.Calendar} + icon={selectedDate || shouldHideCalendarIcon ? null : (icon ?? icons.Calendar)} iconContainerStyle={styles.pr0} label={label} accessibilityLabel={label} @@ -243,6 +249,7 @@ function DatePicker({ textInputContainerStyles={isModalVisible ? styles.borderColorFocus : {}} shouldHideClearButton={shouldHideClearButton} onClearInput={handleClear} + clearButtonStyle={[styles.mh0, {paddingHorizontal: iconPaddingHorizontal}]} forwardedFSClass={forwardedFSClass} autoComplete={autoComplete} disableKeyboard diff --git a/src/components/VacationDelegateForm.tsx b/src/components/VacationDelegateForm.tsx new file mode 100644 index 000000000000..177712b6ec22 --- /dev/null +++ b/src/components/VacationDelegateForm.tsx @@ -0,0 +1,208 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useInitialSelection from '@hooks/useInitialSelection'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import DateUtils from '@libs/DateUtils'; +import { + formatVacationDelegateClearDateTime, + getActiveVacationDelegate, + getVacationDelegateClearAfter, + getVacationDelegateClearDate, + getVacationDelegateClearDateTime, + getVacationDelegateLocalClearDateTime, + isVacationDelegateClearAfterTooSoon, + isVacationDelegateClearDatePassed, +} from '@libs/VacationDelegateUtils'; + +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/VacationDelegateForm'; +import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails'; +import type {BaseVacationDelegate} from '@src/types/onyx/VacationDelegate'; + +import React from 'react'; +import {View} from 'react-native'; + +import type {FormInputErrors, FormOnyxValues} from './Form/types'; + +import DatePicker from './DatePicker'; +import FormProvider from './Form/FormProvider'; +import InputWrapper from './Form/InputWrapper'; +import MenuItemAction from './MenuItem/presets/MenuItemAction'; +import OfflineWithFeedback from './OfflineWithFeedback'; +import Text from './Text'; +import TimeModalPicker from './TimeModalPicker'; +import VacationDelegateMenuItem from './VacationDelegateMenuItem'; + +type VacationDelegateFormProps = { + /** The saved vacation delegate */ + vacationDelegate?: BaseVacationDelegate; + + /** Text shown above the form */ + description?: string; + + /** + * Timezone the clear date and time are picked in. Defaults to the current user's Profile timezone. + * A domain admin passes the member's, so the delegate clears at the end of the vacationer's day, not the admin's. + */ + timezone?: SelectedTimezone; + + /** Opens the member selection screen. The picked member is stored in the form draft. */ + onChangeDelegate: () => void; + + /** + * Saves the picked delegate + * @param delegate - Login of the picked delegate + * @param clearAfter - UTC datetime (yyyy-MM-dd HH:mm:ss) when the delegate clears, or undefined when it never clears + */ + onSubmit: (delegate: string, clearAfter: string | undefined) => void; + + /** Removes the saved delegate */ + onRemove: () => void; + + /** Errors left by the last change to the saved delegate */ + errors?: Errors; + + /** Pending action of the last change to the saved delegate */ + pendingAction?: PendingAction; + + /** Dismisses `errors` */ + onCloseError?: () => void; + + /** Whether the save request is still running, which shows the spinner on the Save button */ + isLoading?: boolean; +}; + +function VacationDelegateForm({ + vacationDelegate, + description, + timezone: timezoneProp, + onChangeDelegate, + onSubmit, + onRemove, + errors, + pendingAction, + onCloseError, + isLoading = false, +}: VacationDelegateFormProps) { + const styles = useThemeStyles(); + const {translate, dateFnsLocale, getLocalDateFromDatetime} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['CalendarSolid', 'Trashcan']); + const {timezone: currentUserTimezone} = useCurrentUserPersonalDetails(); + const timezone = timezoneProp ?? currentUserTimezone?.selected; + const [draftValues] = useOnyx(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT); + + const savedDelegate = getActiveVacationDelegate(vacationDelegate); + // Remove reflects the delegate saved when the form opened. Otherwise the optimistic write from this form's own Save + // would make the button appear while the request runs and the RHP closes. + const savedDelegateOnOpen = useInitialSelection(savedDelegate); + const savedClearDateTime = savedDelegate ? getVacationDelegateClearDateTime(vacationDelegate?.clearAfter, timezone) : ''; + const savedClearDate = savedDelegate ? getVacationDelegateClearDate(vacationDelegate?.clearAfter, timezone) : ''; + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- an empty draft means no member was picked yet + const delegate = draftValues?.[INPUT_IDS.DELEGATE] || savedDelegate; + const clearDate = draftValues?.[INPUT_IDS.CLEAR_AFTER_DATE] ?? savedClearDate; + const formattedClearDateTime = formatVacationDelegateClearDateTime( + getVacationDelegateLocalClearDateTime(clearDate, draftValues?.[INPUT_IDS.CLEAR_AFTER_TIME] ?? savedClearDateTime), + dateFnsLocale, + ); + + const validate = (values: FormOnyxValues): FormInputErrors => { + const formErrors: FormInputErrors = {}; + if (!values[INPUT_IDS.CLEAR_AFTER_DATE]) { + return formErrors; + } + + // Both checks read the picked date and time in the form's timezone, the same one clearAfter is built from, not the device's + if (isVacationDelegateClearDatePassed(values[INPUT_IDS.CLEAR_AFTER_DATE], timezone)) { + formErrors[INPUT_IDS.CLEAR_AFTER_DATE] = translate('common.error.dateInvalid'); + } else if (isVacationDelegateClearAfterTooSoon(getVacationDelegateClearAfter(values[INPUT_IDS.CLEAR_AFTER_DATE], values[INPUT_IDS.CLEAR_AFTER_TIME], timezone))) { + formErrors[INPUT_IDS.CLEAR_AFTER_TIME] = translate('common.error.invalidTimeShouldBeFuture'); + } + return formErrors; + }; + + const submit = (values: FormOnyxValues) => { + if (!delegate) { + return; + } + + onSubmit(delegate, getVacationDelegateClearAfter(values[INPUT_IDS.CLEAR_AFTER_DATE], values[INPUT_IDS.CLEAR_AFTER_TIME], timezone)); + }; + + return ( + + {!!description && {description}} + + {/* The clear after date and time belong to the same pending save as the delegate, so they grey out with it */} + + + {/* Shows the calendar icon while empty and the clear button once a date is picked, so the delegate can go back to never clearing. + The earliest day is today in the form's timezone, so it matches the validation even when the device is in another timezone. */} + + + {/* The time only means something once a day is picked. Until it is changed, the delegate clears at the end of that day. */} + {!!clearDate && ( + + + + )} + {/* The bottom margin matches the date input's own, so the remove button below sits the same distance from whichever is last */} + {!!formattedClearDateTime && ( + {translate('statusPage.vacationDelegate.willClearOn', formattedClearDateTime)} + )} + + {!!savedDelegateOnOpen && !!savedDelegate && ( + + + + )} + + ); +} + +export default VacationDelegateForm; diff --git a/src/components/VacationDelegateMenuItem.tsx b/src/components/VacationDelegateMenuItem.tsx index ff3c58528440..f9a742fbf77e 100644 --- a/src/components/VacationDelegateMenuItem.tsx +++ b/src/components/VacationDelegateMenuItem.tsx @@ -1,25 +1,90 @@ +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import useVacationDelegatePersonalDetails from '@hooks/useVacationDelegatePersonalDetails'; import getVacationDelegateDisplayName from '@libs/getVacationDelegateDisplayName'; +import type {AvatarSource} from '@libs/UserAvatarUtils'; +import {formatVacationDelegateClearDateTime, getActiveVacationDelegate, getVacationDelegateClearDateTime} from '@libs/VacationDelegateUtils'; -import CONST from '@src/CONST'; +import {callFunctionIfActionIsAllowed} from '@userActions/Session'; + +import type CONST from '@src/CONST'; import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; +import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails'; import type {BaseVacationDelegate} from '@src/types/onyx/VacationDelegate'; +import type {ValueOf} from 'type-fest'; + import React from 'react'; -import UserAvatar from './Avatar/UserAvatar'; import MenuItem from './MenuItem'; import MenuItemField from './MenuItem/presets/MenuItemField'; -import MenuItemWithLabel from './MenuItem/presets/MenuItemWithLabel'; import OfflineWithFeedback from './OfflineWithFeedback'; +import UserPill from './UserPill'; + +type VacationDelegateMenuItemRowProps = { + /** Text above the delegate, naming what the row holds */ + label: string; + + /** Name shown in the delegate's pill */ + displayName: string; + + /** Avatar shown in the delegate's pill */ + avatar?: AvatarSource; + + /** Account ID of the delegate */ + accountID?: number; + + /** Login of the delegate */ + login?: string; + + /** Text under the pill saying when the delegate clears */ + untilText?: string; + + /** Indicator shown next to the chevron */ + brickRoadIndicator?: ValueOf; +}; + +/** + * The line of a set vacation delegate, without a `MenuItem.Root` of its own: the label, the delegate as a pill, and when it clears. + * The pill is not a text leaf, so the `Root` around it should get an `accessibilityLabel` that names the delegate. + */ +function VacationDelegateMenuItemRow({label, displayName, avatar, accountID, login, untilText, brickRoadIndicator}: VacationDelegateMenuItemRowProps) { + const styles = useThemeStyles(); + const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); + + return ( + + + {label} + + {!!untilText && {untilText}} + + + {!!brickRoadIndicator && } + + + + ); +} type VacationDelegateSectionProps = { vacationDelegate?: BaseVacationDelegate; + /** Text above the delegate. Defaults to "Vacation delegate" */ + label?: string; + + /** Timezone the clear date is shown in. Defaults to the current user's, so a domain admin can pass the member's instead */ + timezone?: SelectedTimezone; + /** Errors related to setting the vacation delegate */ errors?: Errors; @@ -29,7 +94,7 @@ type VacationDelegateSectionProps = { /** * Callback used to clear/reset errors related to the vacation delegate */ - onCloseError: () => void; + onCloseError?: () => void; /** * Callback triggered when the section is pressed. @@ -38,17 +103,21 @@ type VacationDelegateSectionProps = { onPress: () => void; }; -function VacationDelegateMenuItem({vacationDelegate, errors, pendingAction, onCloseError, onPress}: VacationDelegateSectionProps) { +function VacationDelegateMenuItemPreset({vacationDelegate, label, timezone, errors, pendingAction, onCloseError, onPress}: VacationDelegateSectionProps) { const styles = useThemeStyles(); - const {translate, formatPhoneNumber} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); + const {translate, formatPhoneNumber, dateFnsLocale} = useLocalize(); + const {timezone: currentUserTimezone} = useCurrentUserPersonalDetails(); - const hasVacationDelegate = !!vacationDelegate?.delegate; - const vacationDelegatePersonalDetails = useVacationDelegatePersonalDetails(vacationDelegate?.delegate); + const rowLabel = label ?? translate('common.vacationDelegate'); + const activeVacationDelegate = getActiveVacationDelegate(vacationDelegate); + const hasVacationDelegate = !!activeVacationDelegate; + // Only looks up a delegate the row shows, so an expired one doesn't trigger a personal details request + const vacationDelegatePersonalDetails = useVacationDelegatePersonalDetails(activeVacationDelegate); - const rawDelegateLogin = vacationDelegatePersonalDetails?.login ?? vacationDelegate?.delegate ?? ''; + const rawDelegateLogin = vacationDelegatePersonalDetails?.login ?? activeVacationDelegate ?? ''; const delegateDisplayName = getVacationDelegateDisplayName(rawDelegateLogin, vacationDelegatePersonalDetails?.displayName, formatPhoneNumber); - const delegateDescription = formatPhoneNumber(rawDelegateLogin); + const clearDate = formatVacationDelegateClearDateTime(getVacationDelegateClearDateTime(vacationDelegate?.clearAfter, timezone ?? currentUserTimezone?.selected), dateFnsLocale); + const untilText = clearDate ? translate('statusPage.vacationDelegate.until', clearDate) : ''; return ( {hasVacationDelegate ? ( - - - - - - - {delegateDisplayName} - {!!delegateDescription && {delegateDescription}} - - - - - - + + ) : ( )} @@ -90,4 +151,6 @@ function VacationDelegateMenuItem({vacationDelegate, errors, pendingAction, onCl ); } +const VacationDelegateMenuItem = Object.assign(VacationDelegateMenuItemPreset, {Row: VacationDelegateMenuItemRow}); + export default VacationDelegateMenuItem; diff --git a/src/languages/de.ts b/src/languages/de.ts index 68db0747d0a7..2d38e1697274 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3954,6 +3954,11 @@ ${amount} für ${merchant} – ${date}`, `${delegate} ist kein Mitglied der unten aufgeführten Arbeitsbereiche. Admins der Arbeitsbereiche, die Sie nicht verwalten, werden gebeten, die Person hinzuzufügen. Möchten Sie die Person jetzt zu den Arbeitsbereichen einladen, in denen Sie Admin sind?`, youAreAMemberOf: 'Sie sind Mitglied dieser Arbeitsbereiche:', youAreAnAdminOf: 'Sie sind Admin dieser Arbeitsbereiche:', + chooseDelegate: 'Vertretung auswählen', + clearAfterRecommended: 'Löschen nach (empfohlen)', + willClearOn: (date: string) => `Die Urlaubsvertretung wird am ${date} entfernt`, + until: (date: string) => `Bis ${date}`, + removeDelegate: 'Urlaubsvertretung entfernen', }, }, stepCounter: (step: number, total?: number, text?: string) => { diff --git a/src/languages/el.ts b/src/languages/el.ts index f87fb4f3514f..1840bc521100 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -4001,6 +4001,11 @@ ${amount} για ${merchant} - ${date}`, `Ο/Η ${delegate} δεν είναι μέλος των παρακάτω χώρων εργασίας. Θα ζητηθεί από τους διαχειριστές των χώρων εργασίας που δεν διαχειρίζεστε να τον/την προσθέσουν. Θέλετε να τον/την προσκαλέσετε τώρα σε αυτούς που διαχειρίζεστε ως διαχειριστής;`, youAreAMemberOf: 'Είστε μέλος αυτών των χώρων εργασίας:', youAreAnAdminOf: 'Είστε διαχειριστής αυτών των χώρων εργασίας:', + chooseDelegate: 'Επιλέξτε εκπρόσωπο', + clearAfterRecommended: 'Εκκαθάριση μετά από (συνιστάται)', + willClearOn: (date: string) => `Ο εκπρόσωπος διακοπών θα καταργηθεί στις ${date}`, + until: (date: string) => `Έως ${date}`, + removeDelegate: 'Κατάργηση εκπροσώπου διακοπών', }, }, stepCounter: (step, total, text) => { diff --git a/src/languages/en.ts b/src/languages/en.ts index 7a8346af8420..c474c0da131a 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4118,6 +4118,11 @@ const translations = { `${delegate} isn't a member of the workspaces below. Admins of the workspaces you don't manage will be asked to add them. Do you want to invite them to the ones you are an admin of now?`, youAreAMemberOf: 'You are a member of these workspaces:', youAreAnAdminOf: 'You are an admin of these workspaces:', + chooseDelegate: 'Choose delegate', + clearAfterRecommended: 'Clear after (recommended)', + willClearOn: (date: string) => `Vacation delegate will clear on ${date}`, + until: (date: string) => `Until ${date}`, + removeDelegate: 'Remove vacation delegate', }, }, stepCounter: (step: number, total?: number, text?: string) => { diff --git a/src/languages/es.ts b/src/languages/es.ts index c6557c7f461b..946fe286656f 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3866,6 +3866,11 @@ ${amount} para ${merchant} - ${date}`, `${delegate} no es miembro de los siguientes espacios de trabajo. Se pedirá a los administradores de los espacios de trabajo que no gestiona que lo agreguen. ¿Desea invitarlo ahora a los espacios de trabajo en los que es administrador?`, youAreAMemberOf: 'Usted es miembro de estos espacios de trabajo:', youAreAnAdminOf: 'Usted es administrador de estos espacios de trabajo:', + chooseDelegate: 'Elegir delegado', + clearAfterRecommended: 'Borrar después de (recomendado)', + willClearOn: (date: string) => `El delegado de vacaciones se eliminará el ${date}`, + until: (date: string) => `Hasta el ${date}`, + removeDelegate: 'Eliminar delegado de vacaciones', }, }, stepCounter: (step, total, text) => { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index f5a34db5f595..bd5f737a15ee 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3961,6 +3961,11 @@ ${amount} pour ${merchant} - ${date}`, `${delegate} n'est pas membre des espaces de travail ci-dessous. Les administrateurs des espaces de travail que vous ne gérez pas seront invités à l'ajouter. Voulez-vous l'inviter dès maintenant à ceux dont vous êtes administrateur ?`, youAreAMemberOf: 'Vous êtes membre de ces espaces de travail :', youAreAnAdminOf: 'Vous êtes administrateur de ces espaces de travail :', + chooseDelegate: 'Choisir un délégué', + clearAfterRecommended: 'Effacer après (recommandé)', + willClearOn: (date: string) => `Le délégué de vacances sera supprimé le ${date}`, + until: (date: string) => `Jusqu’au ${date}`, + removeDelegate: 'Supprimer le délégué de vacances', }, }, stepCounter: (step: number, total?: number, text?: string) => { diff --git a/src/languages/it.ts b/src/languages/it.ts index 69d51a069f95..831c17252a0b 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3934,6 +3934,11 @@ ${amount} per ${merchant} - ${date}`, `${delegate} non è membro degli spazi di lavoro elencati di seguito. Agli amministratori degli spazi di lavoro che non gestisci verrà chiesto di aggiungerlo. Vuoi invitarlo ora in quelli di cui sei amministratore?`, youAreAMemberOf: 'Sei membro di questi spazi di lavoro:', youAreAnAdminOf: 'Sei amministratore di questi spazi di lavoro:', + chooseDelegate: 'Scegli delegato', + clearAfterRecommended: 'Cancella dopo (consigliato)', + willClearOn: (date: string) => `La delega ferie verrà rimossa il ${date}`, + until: (date: string) => `Fino al ${date}`, + removeDelegate: 'Rimuovi delega ferie', }, }, stepCounter: (step: number, total?: number, text?: string) => { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 6b6c4d3f203f..61d5837a2a6f 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3903,6 +3903,11 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' `${delegate}は以下のワークスペースのメンバーではありません。あなたが管理していないワークスペースの管理者に追加を依頼します。あなたが管理者になっているワークスペースには今すぐ招待しますか?`, youAreAMemberOf: 'あなたはこれらのワークスペースのメンバーです:', youAreAnAdminOf: 'あなたはこれらのワークスペースの管理者です:', + chooseDelegate: '代理人を選択', + clearAfterRecommended: 'クリア日(推奨)', + willClearOn: (date: string) => `休暇代理人は${date}に解除されます`, + until: (date: string) => `${date}まで`, + removeDelegate: '休暇代理人を削除', }, }, stepCounter: (step: number, total?: number, text?: string) => { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 918d43d115b2..b841afb75339 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3936,6 +3936,11 @@ ${amount} voor ${merchant} - ${date}`, `${delegate} is geen lid van de onderstaande werkruimtes. Beheerders van de werkruimtes die je niet beheert, wordt gevraagd diegene toe te voegen. Wil je diegene nu uitnodigen voor de werkruimtes waar je beheerder van bent?`, youAreAMemberOf: 'Je bent lid van deze werkruimtes:', youAreAnAdminOf: 'Je bent beheerder van deze werkruimtes:', + chooseDelegate: 'Vertegenwoordiger kiezen', + clearAfterRecommended: 'Wissen na (aanbevolen)', + willClearOn: (date: string) => `De vertegenwoordiger tijdens vakantie wordt gewist op ${date}`, + until: (date: string) => `Tot ${date}`, + removeDelegate: 'Vertegenwoordiger tijdens vakantie verwijderen', }, }, stepCounter: (step: number, total?: number, text?: string) => { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index acc4849c851b..46bd462d370a 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3966,6 +3966,11 @@ ${amount} dla ${merchant} - ${date}`, `${delegate} nie jest członkiem poniższych przestrzeni roboczych. Administratorzy przestrzeni roboczych, którymi nie zarządzasz, zostaną poproszeni o dodanie tej osoby. Czy chcesz zaprosić tę osobę do przestrzeni, w których jesteś administratorem?`, youAreAMemberOf: 'Jesteś członkiem tych przestrzeni roboczych:', youAreAnAdminOf: 'Jesteś administratorem tych przestrzeni roboczych:', + chooseDelegate: 'Wybierz zastępcę', + clearAfterRecommended: 'Wyczyść po (zalecane)', + willClearOn: (date: string) => `Zastępca urlopowy zostanie usunięty ${date}`, + until: (date: string) => `Do ${date}`, + removeDelegate: 'Usuń zastępcę urlopowego', }, }, stepCounter: (step: number, total?: number, text?: string) => { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 74db296b9504..2bde7709c14b 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3923,6 +3923,11 @@ ${amount} para ${merchant} - ${date}`, `${delegate} não é membro dos workspaces abaixo. Os admins dos workspaces que você não gerencia serão solicitados a adicioná-lo(a). Deseja convidá-lo(a) agora para os workspaces em que você é admin?`, youAreAMemberOf: 'Você é membro destes workspaces:', youAreAnAdminOf: 'Você é admin destes workspaces:', + chooseDelegate: 'Escolher delegado', + clearAfterRecommended: 'Limpar após (recomendado)', + willClearOn: (date: string) => `O delegado de férias será removido em ${date}`, + until: (date: string) => `Até ${date}`, + removeDelegate: 'Remover delegado de férias', }, }, stepCounter: (step: number, total?: number, text?: string) => { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index cd85b735466e..221d208c9911 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3812,6 +3812,11 @@ ${amount},商户:${merchant} - 日期:${date}`, `${delegate} 不是以下工作区的成员。系统会请求你未管理的工作区的管理员将其添加进来。你要现在邀请他们加入你管理的工作区吗?`, youAreAMemberOf: '你是这些工作区的成员:', youAreAnAdminOf: '你是这些工作区的管理员:', + chooseDelegate: '选择代理人', + clearAfterRecommended: '清除日期(推荐)', + willClearOn: (date: string) => `休假代理将于 ${date} 清除`, + until: (date: string) => `直到 ${date}`, + removeDelegate: '移除休假代理', }, }, stepCounter: (step: number, total?: number, text?: string) => { diff --git a/src/libs/API/parameters/SetVacationDelegateParams.ts b/src/libs/API/parameters/SetVacationDelegateParams.ts index d149f370d5c5..eb5abb05c555 100644 --- a/src/libs/API/parameters/SetVacationDelegateParams.ts +++ b/src/libs/API/parameters/SetVacationDelegateParams.ts @@ -2,6 +2,9 @@ type SetVacationDelegateParams = { creator: string; vacationerEmail?: string; vacationDelegateEmail: string; + + /** UTC datetime (yyyy-MM-dd HH:mm:ss) when the backend should clear the vacation delegate */ + clearAfter?: string; overridePolicyDiffWarning: boolean; domainAccountID?: number; }; diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts index c4a9e03bbf6e..a76717e108dc 100644 --- a/src/libs/DateUtils.ts +++ b/src/libs/DateUtils.ts @@ -1095,7 +1095,7 @@ const formatInTimeZoneWithFallback: typeof formatInTimeZone = (date, timeZone, f * @param timeZone - Target timezone to display the date in * @returns Date string in yyyy-MM-dd format, or empty string if invalid */ -function formatUTCDateTimeToDateInTimezone(utcDateTime: string, timeZone: SelectedTimezone | undefined, formatStr = CONST.DATE.FNS_FORMAT_STRING): string { +function formatUTCDateTimeToDateInTimezone(utcDateTime: string, timeZone: SelectedTimezone | undefined, formatStr: string = CONST.DATE.FNS_FORMAT_STRING): string { if (!utcDateTime || !timeZone) { return ''; } @@ -1367,6 +1367,7 @@ const DateUtils = { formatViolationSnapshotStartedAtDate, normalizeDateToStartOfDay, normalizeDateToEndOfDay, + formatDBTimeWithoutMilliseconds, getMonthDateRange, getWeekDateRange, isDateStringInMonth, diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 488a2f3c4fbd..7cf477a13c21 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -546,7 +546,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Profile/CustomStatus/StatusClearAfterPage').default, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: () => require('../../../../pages/settings/Profile/CustomStatus/SetDatePage').default, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: () => require('../../../../pages/settings/Profile/CustomStatus/SetTimePage').default, - [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE]: () => require('../../../../pages/settings/Profile/CustomStatus/VacationDelegatePage').default, + [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE]: () => require('../../../../pages/settings/Profile/CustomStatus/VacationDelegateFormPage').default, + [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_SELECT]: () => require('../../../../pages/settings/Profile/CustomStatus/VacationDelegatePage').default, [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_MISSING_WORKSPACES]: () => require('../../../../pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage').default, [SCREENS.SETTINGS.SUBSCRIPTION.SIZE]: () => require('../../../../pages/settings/Subscription/SubscriptionSize').default, @@ -1317,7 +1318,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/domain/Admins/DomainAddAdminPage').default, [SCREENS.DOMAIN.MEMBER_DETAILS]: () => require('../../../../pages/domain/Members/DomainMemberDetailsPage').default, [SCREENS.DOMAIN.RESET_DOMAIN]: () => require('../../../../pages/domain/DomainResetPage').default, - [SCREENS.DOMAIN.VACATION_DELEGATE]: () => require('../../../../pages/domain/Members/DomainMemberVacationDelegatePage').default, + [SCREENS.DOMAIN.VACATION_DELEGATE]: () => require('../../../../pages/domain/Members/DomainMemberVacationDelegateFormPage').default, + [SCREENS.DOMAIN.VACATION_DELEGATE_SELECT]: () => require('../../../../pages/domain/Members/DomainMemberVacationDelegatePage').default, [SCREENS.DOMAIN.ADD_MEMBER]: () => require('../../../../pages/domain/Members/DomainAddMemberPage').default, [SCREENS.DOMAIN.MEMBERS_SETTINGS]: () => require('../../../../pages/domain/Members/DomainMembersSettingsPage').default, [SCREENS.DOMAIN.MEMBERS_SETTINGS_TWO_FACTOR_AUTH]: () => require('../../../../pages/domain/Members/DomainRequireTwoFactorAuthPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts index 572d7444556e..17f92ba79351 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/DOMAIN_TO_RHP.ts @@ -14,6 +14,7 @@ const DOMAIN_TO_RHP: Partial['config'] = { path: ROUTES.SETTINGS_VACATION_DELEGATE, exact: true, }, + [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_SELECT]: { + path: ROUTES.SETTINGS_VACATION_DELEGATE_SELECT, + exact: true, + }, [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_MISSING_WORKSPACES]: { path: ROUTES.SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES, exact: true, @@ -1620,6 +1624,9 @@ const config: LinkingOptions['config'] = { [SCREENS.DOMAIN.VACATION_DELEGATE]: { path: ROUTES.DOMAIN_VACATION_DELEGATE.route, }, + [SCREENS.DOMAIN.VACATION_DELEGATE_SELECT]: { + path: ROUTES.DOMAIN_VACATION_DELEGATE_SELECT.route, + }, [SCREENS.DOMAIN.ADD_MEMBER]: { path: ROUTES.DOMAIN_ADD_MEMBER.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index ae375caa37e1..819efc0a5e99 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -353,6 +353,7 @@ type SettingsNavigatorParamList = { [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: undefined; [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: undefined; [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE]: undefined; + [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_SELECT]: undefined; [SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE_MISSING_WORKSPACES]: undefined; [SCREENS.WORKSPACE.CURRENCY]: { isForcedToChangeCurrency?: boolean; @@ -2223,6 +2224,10 @@ type SettingsNavigatorParamList = { domainAccountID: number; accountID: number; }; + [SCREENS.DOMAIN.VACATION_DELEGATE_SELECT]: { + domainAccountID: number; + accountID: number; + }; [SCREENS.DOMAIN.MEMBER_FORCE_TWO_FACTOR_AUTH]: { domainAccountID: number; accountID: number; diff --git a/src/libs/VacationDelegateUtils.ts b/src/libs/VacationDelegateUtils.ts new file mode 100644 index 000000000000..1f7bf9b8ebc8 --- /dev/null +++ b/src/libs/VacationDelegateUtils.ts @@ -0,0 +1,122 @@ +import CONST from '@src/CONST'; +import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails'; +import type {BaseVacationDelegate} from '@src/types/onyx/VacationDelegate'; + +import type {Locale as DateFnsLocale} from 'date-fns'; + +import {addMinutes, format, isValid, parse} from 'date-fns'; +import {fromZonedTime} from 'date-fns-tz'; + +import DateUtils from './DateUtils'; + +/** Format of the local clear date and time the user picks, e.g. 2026-10-01 17:00:00 */ +const LOCAL_DATE_TIME_FORMAT = 'yyyy-MM-dd HH:mm:ss'; + +/** Time a clear date ends at when no time is picked, so the delegate stays active for the whole day */ +const END_OF_DAY_TIME = '23:59:59'; + +/** + * Combines the picked clear date (yyyy-MM-dd) and time into the local datetime (yyyy-MM-dd HH:mm:ss) the delegate clears at. + * The time can be a full datetime, as the time picker stores it; only its time part is used. Without a time, the delegate clears + * at the end of the picked day. + */ +function getVacationDelegateLocalClearDateTime(clearDate: string | undefined, clearTime: string | undefined): string { + if (!clearDate) { + return ''; + } + + return (clearTime ? DateUtils.combineDateAndTime(clearTime, clearDate) : '') || `${clearDate} ${END_OF_DAY_TIME}`; +} + +/** + * Converts the clear date (yyyy-MM-dd) and time picked for the vacation delegate into the UTC datetime the backend expects, + * reading them in the given timezone. + */ +function getVacationDelegateClearAfter(clearDate: string | undefined, clearTime: string | undefined, timezone: SelectedTimezone | undefined): string | undefined { + const localClearDateTime = getVacationDelegateLocalClearDateTime(clearDate, clearTime); + if (!localClearDateTime) { + return undefined; + } + + return DateUtils.formatDBTimeWithoutMilliseconds(fromZonedTime(localClearDateTime, timezone ?? CONST.DEFAULT_TIME_ZONE.selected).valueOf()); +} + +/** + * Converts the UTC datetime the vacation delegate clears after back into the local datetime (yyyy-MM-dd HH:mm:ss) it clears at in the given timezone. + */ +function getVacationDelegateClearDateTime(clearAfter: string | undefined, timezone: SelectedTimezone | undefined): string { + if (!clearAfter) { + return ''; + } + + return DateUtils.formatUTCDateTimeToDateInTimezone(clearAfter, timezone ?? CONST.DEFAULT_TIME_ZONE.selected, LOCAL_DATE_TIME_FORMAT); +} + +/** + * Converts the UTC datetime the vacation delegate clears after back into the day (yyyy-MM-dd) it clears on in the given timezone. + */ +function getVacationDelegateClearDate(clearAfter: string | undefined, timezone: SelectedTimezone | undefined): string { + return getVacationDelegateClearDateTime(clearAfter, timezone).slice(0, CONST.DATE.FNS_FORMAT_STRING.length); +} + +/** + * Formats a local clear datetime (yyyy-MM-dd HH:mm:ss) for display, e.g. "Oct 1, 2026 5:00 PM". + */ +function formatVacationDelegateClearDateTime(clearDateTime: string, dateFnsLocale: DateFnsLocale | undefined): string { + const date = parse(clearDateTime, LOCAL_DATE_TIME_FORMAT, new Date()); + if (!isValid(date)) { + return ''; + } + + return format(date, `${CONST.DATE.MONTH_DAY_YEAR_ABBR_FORMAT} ${CONST.DATE.LOCAL_TIME_FORMAT}`, {locale: dateFnsLocale}); +} + +/** + * Whether the vacation delegate's clearAfter datetime has passed. The backend clears the delegate at that time, so this only covers + * the gap before its update reaches the client. + */ +function isVacationDelegateExpired(clearAfter: string | undefined): boolean { + // Both values are UTC in the database format, so they compare as strings. + return !!clearAfter && clearAfter <= DateUtils.getDBTime(); +} + +/** + * The delegate still in effect, ignoring one whose clearAfter has passed. Every screen that shows or opens the delegate uses this, + * so they all apply the same expiry rule. + */ +function getActiveVacationDelegate(vacationDelegate: BaseVacationDelegate | undefined): string | undefined { + return vacationDelegate?.delegate && !isVacationDelegateExpired(vacationDelegate.clearAfter) ? vacationDelegate.delegate : undefined; +} + +/** + * Whether the picked clear date (yyyy-MM-dd) has already ended in the given timezone. The day is read in the user's Profile timezone, + * not the device's, so a device in another timezone doesn't reject a day that hasn't ended yet for the user, or accept one that has. + */ +function isVacationDelegateClearDatePassed(clearDate: string, timezone: SelectedTimezone | undefined): boolean { + if (!isValid(parse(clearDate, CONST.DATE.FNS_FORMAT_STRING, new Date()))) { + return true; + } + + return isVacationDelegateExpired(getVacationDelegateClearAfter(clearDate, undefined, timezone)); +} + +/** + * Whether the picked clearAfter datetime is less than one minute from now. Such a delegate could clear while the request is still in + * flight, so the form asks for a time at least one minute ahead. + */ +function isVacationDelegateClearAfterTooSoon(clearAfter: string | undefined): boolean { + // Both values are UTC in the database format, so they compare as strings. + return !!clearAfter && clearAfter < DateUtils.formatDBTimeWithoutMilliseconds(addMinutes(new Date(), 1).valueOf()); +} + +export { + getVacationDelegateClearAfter, + getVacationDelegateClearDate, + getVacationDelegateClearDateTime, + getVacationDelegateLocalClearDateTime, + formatVacationDelegateClearDateTime, + isVacationDelegateExpired, + getActiveVacationDelegate, + isVacationDelegateClearDatePassed, + isVacationDelegateClearAfterTooSoon, +}; diff --git a/src/libs/actions/Domain.ts b/src/libs/actions/Domain.ts index d950eb15f721..e4c83f87a9d1 100644 --- a/src/libs/actions/Domain.ts +++ b/src/libs/actions/Domain.ts @@ -1660,18 +1660,32 @@ function clearValidateDomainTwoFactorCodeError() { Onyx.set(ONYXKEYS.VALIDATE_DOMAIN_TWO_FACTOR_CODE, null); } -function setDomainVacationDelegate(domainAccountID: number, domainMemberAccountID: number, creator: string, vacationer: string, delegate: string, vacationDelegate?: BaseVacationDelegate) { +/** + * @param clearAfter - UTC datetime (yyyy-MM-dd HH:mm:ss) when the backend should clear the delegate. No value means it never clears. + */ +function setDomainVacationDelegate( + domainAccountID: number, + domainMemberAccountID: number, + creator: string, + vacationer: string, + delegate: string, + vacationDelegate?: BaseVacationDelegate, + clearAfter?: string, +) { + const optimisticVacationDelegate: NullishDeep = { + delegate, + creator, + clearAfter: clearAfter ?? null, + previousDelegate: vacationDelegate?.delegate, + previousClearAfter: vacationDelegate?.clearAfter ?? null, + }; const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, value: { - [`${CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${domainMemberAccountID}`]: { - delegate, - creator, - previousDelegate: vacationDelegate?.delegate, - }, - } as PrefixedRecord, + [`${CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${domainMemberAccountID}`]: optimisticVacationDelegate, + } as PrefixedRecord>, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -1704,6 +1718,7 @@ function setDomainVacationDelegate(domainAccountID: number, domainMemberAccountI value: { [`${CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${domainMemberAccountID}`]: { previousDelegate: null, + previousClearAfter: null, }, } as PrefixedRecord>, }, @@ -1760,6 +1775,7 @@ function setDomainVacationDelegate(domainAccountID: number, domainMemberAccountI creator, vacationerEmail: vacationer, vacationDelegateEmail: delegate, + clearAfter, overridePolicyDiffWarning: true, domainAccountID, }; @@ -1776,7 +1792,9 @@ function deleteDomainVacationDelegate(domainAccountID: number, domainMemberAccou [`${CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${domainMemberAccountID}`]: { creator: null, delegate: null, + clearAfter: null, previousDelegate: vacationDelegate?.delegate, + previousClearAfter: vacationDelegate?.clearAfter ?? null, }, } as PrefixedRecord>, }, @@ -1864,10 +1882,11 @@ function deleteDomainVacationDelegate(domainAccountID: number, domainMemberAccou API.write(WRITE_COMMANDS.DELETE_VACATION_DELEGATE, {vacationerEmail: vacationer, domainAccountID}, {optimisticData, successData, failureData}); } -function clearVacationDelegateError(domainAccountID: number, domainMemberAccountID: number, domainMemberEmail: string, previousDelegate?: string) { +function clearVacationDelegateError(domainAccountID: number, domainMemberAccountID: number, domainMemberEmail: string, previousDelegate?: string, previousClearAfter?: string) { Onyx.merge(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { [`${CONST.DOMAIN.PRIVATE_VACATION_DELEGATE_PREFIX}${domainMemberAccountID}`]: { delegate: previousDelegate ?? null, + clearAfter: previousClearAfter ?? null, }, } as PrefixedRecord>); diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts index 33c9e0975710..4f6926ef1db2 100644 --- a/src/libs/actions/RequestConflictUtils.ts +++ b/src/libs/actions/RequestConflictUtils.ts @@ -351,6 +351,36 @@ function resolveEnableFeatureConflicts( }; } +/** + * Removing the vacation delegate supersedes any change to it still waiting in the queue, e.g. a clear after date changed offline + * before the removal. Sending that change first would let its response bring the removed delegate back until the removal lands. + * Only the current user's own delegate is matched, so a domain admin's change for a member is left alone. + */ +function resolveVacationDelegateDeletionConflicts(persistedRequests: Array>): ConflictActionData { + const indicesToDelete = persistedRequests.reduce((indices, request, index) => { + if (request.command === WRITE_COMMANDS.SET_VACATION_DELEGATE && !request.data?.domainAccountID) { + indices.push(index); + } + return indices; + }, []); + + if (indicesToDelete.length === 0) { + return { + conflictAction: { + type: 'push', + }, + }; + } + + return { + conflictAction: { + type: 'delete', + indices: indicesToDelete, + pushNewRequest: true, + }, + }; +} + export { resolveDuplicationConflictAction, resolveOpenAppDuplicationConflictAction, @@ -365,6 +395,7 @@ export { createUpdateCommentMatcher, resolveEnableFeatureConflicts, enablePolicyFeatureCommand, + resolveVacationDelegateDeletionConflicts, }; export type {EnablePolicyFeatureCommand, AnyRequestMatcher}; diff --git a/src/libs/actions/VacationDelegate.ts b/src/libs/actions/VacationDelegate.ts index 3f814e73a5e9..c77e22e95b9f 100644 --- a/src/libs/actions/VacationDelegate.ts +++ b/src/libs/actions/VacationDelegate.ts @@ -24,27 +24,38 @@ import Onyx from 'react-native-onyx'; import type {CurrentUser} from './Policy/Policy'; import {addMembersToWorkspace} from './Policy/Member'; +import {resolveVacationDelegateDeletionConflicts} from './RequestConflictUtils'; type SetVacationDelegateOptions = { creator: string; delegate: string; + + /** UTC datetime (yyyy-MM-dd HH:mm:ss) when the backend should clear the delegate. No value means it never clears. */ + clearAfter?: string; currentDelegate?: string; + currentClearAfter?: string; shouldOverridePolicyDiffWarning?: boolean; }; -async function setVacationDelegate({creator, delegate, currentDelegate, shouldOverridePolicyDiffWarning = false}: SetVacationDelegateOptions) { +async function setVacationDelegate({creator, delegate, clearAfter, currentDelegate, currentClearAfter, shouldOverridePolicyDiffWarning = false}: SetVacationDelegateOptions) { + // Without the override, the response can still be a 305 that leads to the missing workspaces step, so the pick is only applied once the + // backend saved it. Applying it optimistically would flash the new delegate on Profile, take it away on the 305, and show it again after that step. + const pickedDelegate = {creator, delegate, clearAfter: clearAfter ?? null}; + const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, value: { - creator, - delegate, + ...(shouldOverridePolicyDiffWarning ? pickedDelegate : {}), errors: null, + // Set on both paths: the 305 ships an errors payload in its onyxData, and getVacationDelegateErrors only hides it while this is set pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, previousDelegate: currentDelegate ?? null, + previousClearAfter: currentClearAfter ?? null, policyDiff: null, pendingDelegate: null, + pendingClearAfter: null, }, }, ]; @@ -54,11 +65,14 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv onyxMethod: Onyx.METHOD.MERGE, key: ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, value: { + ...(shouldOverridePolicyDiffWarning ? {} : pickedDelegate), errors: null, pendingAction: null, previousDelegate: null, + previousClearAfter: null, policyDiff: null, pendingDelegate: null, + pendingClearAfter: null, }, }, ]; @@ -77,6 +91,7 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv const parameters: SetVacationDelegateParams = { creator, vacationDelegateEmail: delegate, + clearAfter, overridePolicyDiffWarning: shouldOverridePolicyDiffWarning, }; @@ -107,7 +122,9 @@ async function setVacationDelegate({creator, delegate, currentDelegate, shouldOv Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { errors: null, delegate: currentDelegate ?? null, + clearAfter: currentClearAfter ?? null, pendingDelegate: delegate, + pendingClearAfter: clearAfter ?? null, policyDiff: response.data.policyDiff, pendingAction: null, }); @@ -123,7 +140,7 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { return; } - const {creator, delegate} = vacationDelegate; + const {creator, delegate, clearAfter} = vacationDelegate; const optimisticData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -131,11 +148,14 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { value: { creator: null, delegate: null, + clearAfter: null, errors: null, previousDelegate: vacationDelegate?.delegate, + previousClearAfter: clearAfter ?? null, pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, policyDiff: null, pendingDelegate: null, + pendingClearAfter: null, }, }, ]; @@ -148,6 +168,7 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { errors: null, pendingAction: null, previousDelegate: null, + previousClearAfter: null, }, }, ]; @@ -159,23 +180,34 @@ function deleteVacationDelegate(vacationDelegate?: VacationDelegate) { value: { creator, delegate, + clearAfter, errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('statusPage.vacationDelegateError'), pendingAction: null, }, }, ]; - API.write(WRITE_COMMANDS.DELETE_VACATION_DELEGATE, null, {optimisticData, successData, failureData}); + API.write( + WRITE_COMMANDS.DELETE_VACATION_DELEGATE, + null, + {optimisticData, successData, failureData}, + { + checkAndFixConflictingRequest: (persistedRequests) => resolveVacationDelegateDeletionConflicts(persistedRequests), + }, + ); } -function clearVacationDelegateError(previousDelegate?: string) { +function clearVacationDelegateError(previousDelegate?: string, previousClearAfter?: string) { Onyx.merge(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, { errors: null, pendingAction: null, delegate: previousDelegate ?? null, + clearAfter: previousClearAfter ?? null, previousDelegate: null, + previousClearAfter: null, policyDiff: null, pendingDelegate: null, + pendingClearAfter: null, }); } diff --git a/src/pages/domain/Members/DomainMemberDetailsPage.tsx b/src/pages/domain/Members/DomainMemberDetailsPage.tsx index 345e09535742..b012ff301f7b 100644 --- a/src/pages/domain/Members/DomainMemberDetailsPage.tsx +++ b/src/pages/domain/Members/DomainMemberDetailsPage.tsx @@ -22,6 +22,7 @@ import { closeUserAccount, setTwoFactorAuthExemptEmailForDomain, } from '@libs/actions/Domain'; +import {clearDraftValues} from '@libs/actions/FormActions'; import {requestUnlockAccount} from '@libs/actions/User'; import {getLatestError} from '@libs/ErrorUtils'; @@ -165,10 +166,14 @@ function DomainMemberDetailsPage({route}: DomainMemberDetailsPageProps) { Navigation.navigate(ROUTES.DOMAIN_VACATION_DELEGATE.getRoute(domainAccountID, accountID))} + timezone={personalDetails?.timezone?.selected} + onPress={() => { + clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); + Navigation.navigate(ROUTES.DOMAIN_VACATION_DELEGATE.getRoute(domainAccountID, accountID)); + }} pendingAction={domainPendingActions?.member?.[memberLogin]?.vacationDelegate} errors={getLatestError(domainErrors?.memberErrors?.[memberLogin]?.vacationDelegateErrors)} - onCloseError={() => clearVacationDelegateError(domainAccountID, accountID, memberLogin, vacationDelegate?.previousDelegate)} + onCloseError={() => clearVacationDelegateError(domainAccountID, accountID, memberLogin, vacationDelegate?.previousDelegate, vacationDelegate?.previousClearAfter)} /> {!!domainSettings?.twoFactorAuthRequired && ( ; + +function DomainMemberVacationDelegateFormPage({route}: DomainMemberVacationDelegateFormPageProps) { + const {domainAccountID, accountID} = route.params; + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const {login: currentUserLogin} = useCurrentUserPersonalDetails(); + + const [vacationDelegate] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { + selector: vacationDelegateSelector(accountID), + }); + + const [personalDetails] = usePersonalDetail(accountID); + const memberLogin = personalDetails?.login; + + const goBackToMemberDetails = () => { + clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); + Navigation.goBack(ROUTES.DOMAIN_MEMBER_DETAILS.getRoute(domainAccountID, accountID)); + }; + + // Pops only this form, so a member picker opened straight from the member details page is shown again. + const goBack = () => { + clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); + Navigation.goBack(); + }; + + const onSubmit = (delegate: string, clearAfter: string | undefined) => { + if (!memberLogin) { + return; + } + + setDomainVacationDelegate(domainAccountID, accountID, currentUserLogin ?? '', memberLogin, delegate, vacationDelegate, clearAfter); + goBackToMemberDetails(); + }; + + const onRemove = () => { + if (!memberLogin || !vacationDelegate) { + return; + } + + deleteDomainVacationDelegate(domainAccountID, accountID, memberLogin, vacationDelegate); + goBackToMemberDetails(); + }; + + return ( + + + + {/* While the member is someone else's delegate, their own can't be changed, same as on the member picker */} + {vacationDelegate?.delegatorFor?.length ? ( + + + + ) : ( + Navigation.navigate(ROUTES.DOMAIN_VACATION_DELEGATE_SELECT.getRoute(domainAccountID, accountID))} + onSubmit={onSubmit} + onRemove={onRemove} + /> + )} + + + ); +} + +export default DomainMemberVacationDelegateFormPage; diff --git a/src/pages/domain/Members/DomainMemberVacationDelegatePage.tsx b/src/pages/domain/Members/DomainMemberVacationDelegatePage.tsx index f86ddcef8c47..90e2ec7c8b05 100644 --- a/src/pages/domain/Members/DomainMemberVacationDelegatePage.tsx +++ b/src/pages/domain/Members/DomainMemberVacationDelegatePage.tsx @@ -6,34 +6,42 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import {usePersonalDetail} from '@hooks/usePersonalDetails'; +import {setDraftValues} from '@libs/actions/FormActions'; import Navigation from '@libs/Navigation/Navigation'; +import {getActiveVacationDelegate} from '@libs/VacationDelegateUtils'; import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@navigation/types'; import DomainNotFoundPageWrapper from '@pages/domain/DomainNotFoundPageWrapper'; -import {deleteDomainVacationDelegate, setDomainVacationDelegate} from '@userActions/Domain'; - import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; +import SCREENS from '@src/SCREENS'; +import type {VacationDelegateForm} from '@src/types/form'; +import INPUT_IDS from '@src/types/form/VacationDelegateForm'; import type {Participant} from '@src/types/onyx/IOU'; +import type {OnyxEntry} from 'react-native-onyx'; + +import {useNavigationState} from '@react-navigation/native'; import {vacationDelegateSelector} from '@selectors/Domain'; import React from 'react'; -type DomainMemberVacationDelegatePageProps = PlatformStackScreenProps; +type DomainMemberVacationDelegatePageProps = PlatformStackScreenProps; + +const draftDelegateSelector = (draft: OnyxEntry) => draft?.[INPUT_IDS.DELEGATE]; function DomainMemberVacationDelegatePage({route}: DomainMemberVacationDelegatePageProps) { const {domainAccountID, accountID} = route.params; const {translate} = useLocalize(); - const {login: currentUserLogin} = useCurrentUserPersonalDetails(); const [vacationDelegate] = useOnyx(`${ONYXKEYS.COLLECTION.DOMAIN}${domainAccountID}`, { selector: vacationDelegateSelector(accountID), }); + const [draftDelegate] = useOnyx(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT, {selector: draftDelegateSelector}); + const isFormInStack = useNavigationState((state) => state.routes.some((stackRoute) => stackRoute.name === SCREENS.DOMAIN.VACATION_DELEGATE)); const [personalDetails] = usePersonalDetail(accountID); const memberLogin = personalDetails?.login; @@ -45,14 +53,16 @@ function DomainMemberVacationDelegatePage({route}: DomainMemberVacationDelegateP return; } - if (delegateLogin === vacationDelegate?.delegate) { - deleteDomainVacationDelegate(domainAccountID, accountID, memberLogin, vacationDelegate); - Navigation.goBack(ROUTES.DOMAIN_MEMBER_DETAILS.getRoute(domainAccountID, accountID)); + // The pick is only saved from the form, where the clear after date is chosen too. + setDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM, {[INPUT_IDS.DELEGATE]: delegateLogin}); + + // Opened from the form's delegate row, so go back to it. Opened straight from the member details page, the form comes + // next, so push it and keep this picker under it for the form's back button. + if (isFormInStack) { + Navigation.goBack(ROUTES.DOMAIN_VACATION_DELEGATE.getRoute(domainAccountID, accountID)); return; } - - setDomainVacationDelegate(domainAccountID, accountID, currentUserLogin ?? '', memberLogin, delegateLogin, vacationDelegate); - Navigation.goBack(ROUTES.DOMAIN_MEMBER_DETAILS.getRoute(domainAccountID, accountID)); + Navigation.navigate(ROUTES.DOMAIN_VACATION_DELEGATE.getRoute(domainAccountID, accountID)); }; return ( @@ -62,12 +72,16 @@ function DomainMemberVacationDelegatePage({route}: DomainMemberVacationDelegateP testID="DomainMemberVacationDelegate" > Navigation.goBack(ROUTES.DOMAIN_MEMBER_DETAILS.getRoute(domainAccountID, accountID))} + onBackButtonPress={() => Navigation.goBack()} cannotSetDelegateMessage={translate('domain.members.cannotSetVacationDelegateForMember', memberLogin ?? '')} additionalExcludeLogins={memberLogin ? {[memberLogin]: true} : undefined} + // The member can't be their own delegate. The current user is included by default, and that would override the + // member exclusion above when an admin opens their own member page. + includeCurrentUser={memberLogin !== currentUserLogin} /> diff --git a/src/pages/settings/Profile/CustomStatus/StatusPage.tsx b/src/pages/settings/Profile/CustomStatus/StatusPage.tsx index 3abae095f08a..789b228f4fa4 100644 --- a/src/pages/settings/Profile/CustomStatus/StatusPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/StatusPage.tsx @@ -1,5 +1,4 @@ import Button from '@components/Button'; -import DelegatorList from '@components/DelegatorList'; import EmojiPickerButtonDropdown from '@components/EmojiPicker/EmojiPickerButtonDropdown'; import FixedFooter from '@components/FixedFooter'; import FormProvider from '@components/Form/FormProvider'; @@ -11,7 +10,6 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; -import VacationDelegateMenuItem from '@components/VacationDelegateMenuItem'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -27,11 +25,9 @@ import {isMobileChrome} from '@libs/Browser'; import DateUtils from '@libs/DateUtils'; import focusAfterModalClose from '@libs/focusAfterModalClose'; import focusComposerWithDelay from '@libs/focusComposerWithDelay'; -import getVacationDelegateErrors from '@libs/getVacationDelegateErrors'; import Navigation from '@libs/Navigation/Navigation'; import {clearCustomStatus, clearDraftCustomStatus, updateCustomStatus, updateDraftCustomStatus} from '@userActions/User'; -import {clearVacationDelegateError} from '@userActions/VacationDelegate'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -66,8 +62,6 @@ function StatusPage() { const formRef = useRef(null); const [brickRoadIndicator, setBrickRoadIndicator] = useState>(); - const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); - const hasActiveDelegations = !!vacationDelegate?.delegatorFor?.length; const isFormLoading = !!formState?.isLoading; const currentUserEmojiCode = currentUserPersonalDetails?.status?.emojiCode ?? ''; @@ -243,27 +237,6 @@ function StatusPage() { /> )} - - {translate('common.vacationDelegate')} - {hasActiveDelegations ? ( - - ) : ( - <> - {translate('statusPage.setVacationDelegate')} - clearVacationDelegateError(vacationDelegate?.previousDelegate)} - onPress={() => Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE)} - /> - - )} - - {isInLandscapeMode && saveButton} diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx new file mode 100644 index 000000000000..c0ffa0484964 --- /dev/null +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateFormPage.tsx @@ -0,0 +1,159 @@ +import DelegatorList from '@components/DelegatorList'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import ScreenWrapper from '@components/ScreenWrapper'; +import VacationDelegateForm from '@components/VacationDelegateForm'; + +import useConfirmModal from '@hooks/useConfirmModal'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearDraftValues} from '@libs/actions/FormActions'; +import {clearVacationDelegateError, deleteVacationDelegate, setVacationDelegate} from '@libs/actions/VacationDelegate'; +import getVacationDelegateErrors from '@libs/getVacationDelegateErrors'; +import Navigation from '@libs/Navigation/Navigation'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import {useNavigation} from '@react-navigation/native'; +import React, {useRef, useState} from 'react'; +import {View} from 'react-native'; + +function goBackToProfile() { + clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); + Navigation.goBack(ROUTES.SETTINGS_PROFILE.route); +} + +// Pops only this form, so a member picker opened straight from Profile is shown again. +function goBack() { + clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); + Navigation.goBack(); +} + +function VacationDelegateFormPage() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); + const {showConfirmModal} = useConfirmModal(); + const navigation = useNavigation(); + + const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + const hasActiveDelegations = !!vacationDelegate?.delegatorFor?.length; + + const isSubmittingRef = useRef(false); + // Drives the Save spinner and stays on until the error modal is dismissed, so the modal is the only place a save error shows + const [isSaving, setIsSaving] = useState(false); + + const showErrorModal = async (delegateToRestore?: string, clearAfterToRestore?: string, message?: string) => { + await showConfirmModal({ + title: translate('statusPage.addVacationDelegate'), + prompt: message ?? translate('statusPage.vacationDelegateError'), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + }); + + clearVacationDelegateError(delegateToRestore, clearAfterToRestore); + }; + + const onSubmit = (delegate: string, clearAfter: string | undefined) => { + if (isSubmittingRef.current) { + return; + } + + const hasUnconfirmedChange = !!vacationDelegate?.pendingAction || !isEmptyObject(vacationDelegate?.errors) || !!vacationDelegate?.policyDiff; + const currentDelegate = hasUnconfirmedChange ? vacationDelegate?.previousDelegate : vacationDelegate?.delegate; + const currentClearAfter = hasUnconfirmedChange ? vacationDelegate?.previousClearAfter : vacationDelegate?.clearAfter; + + // Only the clear after date or time changed. The delegate was already accepted, so there's no policy diff to read, and a queued + // write keeps the change optimistic offline instead of failing a one-off request. + if (delegate === vacationDelegate?.delegate) { + setVacationDelegate({creator: currentUserLogin, delegate, clearAfter, currentDelegate, currentClearAfter, shouldOverridePolicyDiffWarning: true}); + goBackToProfile(); + return; + } + + isSubmittingRef.current = true; + setIsSaving(true); + setVacationDelegate({creator: currentUserLogin, delegate, clearAfter, currentDelegate, currentClearAfter}) + .then(async (response) => { + if (!navigation.isFocused()) { + if (response?.data?.policyDiff) { + clearVacationDelegateError(currentDelegate, currentClearAfter); + } + return; + } + + if (response?.data?.policyDiff) { + Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES); + return; + } + + // The action leaves the failure on the NVP for the profile page's red brick road, but the user is still on this screen, + // so report it where they are. Dismissing the modal restores the previous delegate, exactly as dismissing that error would. + if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { + // Awaited so the spinner and the hidden inline error last until the modal is dismissed + await showErrorModal(currentDelegate, currentClearAfter, response?.jsonCode === CONST.JSON_CODE.EXP_ERROR ? response.message : undefined); + return; + } + + goBackToProfile(); + }) + .catch(async () => { + if (!navigation.isFocused()) { + clearVacationDelegateError(currentDelegate, currentClearAfter); + return; + } + + await showErrorModal(currentDelegate, currentClearAfter); + }) + .finally(() => { + isSubmittingRef.current = false; + setIsSaving(false); + }); + }; + + const onRemove = () => { + deleteVacationDelegate(vacationDelegate); + goBackToProfile(); + }; + + return ( + + + {/* While the user is someone else's delegate, they can't change their own, same as on the member picker */} + {hasActiveDelegations ? ( + + + + ) : ( + Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE_SELECT)} + onSubmit={onSubmit} + onRemove={onRemove} + errors={isSaving ? undefined : getVacationDelegateErrors(vacationDelegate)} + pendingAction={vacationDelegate?.pendingAction} + isLoading={isSaving} + onCloseError={() => clearVacationDelegateError(vacationDelegate?.previousDelegate, vacationDelegate?.previousClearAfter)} + /> + )} + + ); +} + +export default VacationDelegateFormPage; diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx index 612854905c39..06968423e36e 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegateMissingWorkspacesPage/index.tsx @@ -14,9 +14,10 @@ import useOnyx from '@hooks/useOnyx'; import {useGetPersonalDetailsByLogin} from '@hooks/usePersonalDetailByLogin'; import useThemeStyles from '@hooks/useThemeStyles'; +import {clearDraftValues} from '@libs/actions/FormActions'; import {openWorkspaceMembersPage} from '@libs/actions/Policy/Member'; import {clearVacationDelegateError, inviteVacationDelegateToWorkspaces, setVacationDelegate} from '@libs/actions/VacationDelegate'; -import Navigation from '@libs/Navigation/Navigation'; +import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; @@ -29,7 +30,7 @@ import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import type {NavigationAction} from '@react-navigation/native'; -import {useNavigation, usePreventRemove} from '@react-navigation/native'; +import {usePreventRemove} from '@react-navigation/native'; import React, {useEffect, useRef, useState} from 'react'; import MissingWorkspacesFooter from './MissingWorkspacesFooter'; @@ -38,13 +39,13 @@ import WorkspaceSection from './WorkspaceSection'; type ScreenInput = { delegate: string; + clearAfter?: string; policyDiff: VacationDelegatePolicyDiff; }; function VacationDelegateMissingWorkspacesPage() { const styles = useThemeStyles(); const {translate, formatPhoneNumber} = useLocalize(); - const navigation = useNavigation(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const getPersonalDetailsByLogin = useGetPersonalDetailsByLogin(); @@ -54,7 +55,9 @@ function VacationDelegateMissingWorkspacesPage() { const creator = currentUserPersonalDetails.login ?? ''; const delegate = submittedInput?.delegate ?? vacationDelegate?.pendingDelegate ?? ''; + const clearAfter = submittedInput ? submittedInput.clearAfter : vacationDelegate?.pendingClearAfter; const previousDelegate = vacationDelegate?.previousDelegate; + const previousClearAfter = vacationDelegate?.previousClearAfter; const policyDiff = submittedInput?.policyDiff ?? vacationDelegate?.policyDiff; const adminPolicies = policyDiff?.adminPolicies ?? []; const nonAdminPolicies = policyDiff?.nonAdminPolicies ?? []; @@ -91,11 +94,13 @@ function VacationDelegateMissingWorkspacesPage() { usePreventRemove(!!policyDiff, ({data}: {data: {action: NavigationAction}}) => { if (!isSubmittingRef.current && policyDiff) { - setSubmittedInput({delegate, policyDiff}); - clearVacationDelegateError(previousDelegate); + setSubmittedInput({delegate, clearAfter, policyDiff}); + clearVacationDelegateError(previousDelegate, previousClearAfter); } - navigation.dispatch(data.action); + // Replayed through the container: this screen's own dispatch stamps its key as the action's source, and a stack + // above this one (e.g. the root when the whole RHP closes) can't find that key, so it would drop the action. + navigationRef.dispatch(data.action); }); if (!submittedInput && isLoadingOnyxValue(vacationDelegateMetadata)) { @@ -129,9 +134,10 @@ function VacationDelegateMissingWorkspacesPage() { }); } - setSubmittedInput({delegate, policyDiff}); - setVacationDelegate({creator, delegate, currentDelegate: previousDelegate, shouldOverridePolicyDiffWarning: true}); - Navigation.goBack(ROUTES.SETTINGS_STATUS); + setSubmittedInput({delegate, clearAfter, policyDiff}); + setVacationDelegate({creator, delegate, clearAfter, currentDelegate: previousDelegate, currentClearAfter: previousClearAfter, shouldOverridePolicyDiffWarning: true}); + clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); + Navigation.dismissModal(); }; return ( diff --git a/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx b/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx index d87256cec670..305c1e8d1f46 100644 --- a/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx +++ b/src/pages/settings/Profile/CustomStatus/VacationDelegatePage.tsx @@ -1,92 +1,49 @@ import BaseVacationDelegateSelectionComponent from '@components/BaseVacationDelegateSelectionComponent'; import ScreenWrapper from '@components/ScreenWrapper'; -import useConfirmModal from '@hooks/useConfirmModal'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import {clearVacationDelegateError, deleteVacationDelegate, setVacationDelegate} from '@libs/actions/VacationDelegate'; +import {setDraftValues} from '@libs/actions/FormActions'; import Navigation from '@libs/Navigation/Navigation'; +import {getActiveVacationDelegate} from '@libs/VacationDelegateUtils'; -import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import type {VacationDelegateForm} from '@src/types/form'; +import INPUT_IDS from '@src/types/form/VacationDelegateForm'; import type {Participant} from '@src/types/onyx/IOU'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import {useNavigation} from '@react-navigation/native'; -import React, {useRef} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; + +import {useNavigationState} from '@react-navigation/native'; +import React from 'react'; + +const draftDelegateSelector = (draft: OnyxEntry) => draft?.[INPUT_IDS.DELEGATE]; function VacationDelegatePage() { const {translate} = useLocalize(); - const {login: currentUserLogin = ''} = useCurrentUserPersonalDetails(); - const {showConfirmModal} = useConfirmModal(); - const navigation = useNavigation(); const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); - - const isSelectingRef = useRef(false); - - const showErrorModal = async (delegateToRestore?: string, message?: string) => { - await showConfirmModal({ - title: translate('statusPage.addVacationDelegate'), - prompt: message ?? translate('statusPage.vacationDelegateError'), - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - }); - - clearVacationDelegateError(delegateToRestore); - }; + const [draftDelegate] = useOnyx(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM_DRAFT, {selector: draftDelegateSelector}); + const isFormInStack = useNavigationState((state) => state.routes.some((route) => route.name === SCREENS.SETTINGS.PROFILE.VACATION_DELEGATE)); const onSelectRow = (option: Participant) => { - if (isSelectingRef.current) { + if (!option?.login) { return; } - if (option?.login === vacationDelegate?.delegate) { - deleteVacationDelegate(vacationDelegate); - Navigation.goBack(ROUTES.SETTINGS_STATUS); + // The pick is only saved from the form, where the clear after date is chosen too. + setDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM, {[INPUT_IDS.DELEGATE]: option.login}); + + // Opened from the form's delegate row, so go back to it. Opened straight from Profile, the form comes next, so push it + // and keep this picker under it for the form's back button. + if (isFormInStack) { + Navigation.goBack(ROUTES.SETTINGS_VACATION_DELEGATE); return; } - - isSelectingRef.current = true; - const hasUnconfirmedChange = !!vacationDelegate?.pendingAction || !isEmptyObject(vacationDelegate?.errors) || !!vacationDelegate?.policyDiff; - const currentDelegate = hasUnconfirmedChange ? vacationDelegate?.previousDelegate : vacationDelegate?.delegate; - setVacationDelegate({creator: currentUserLogin, delegate: option?.login ?? '', currentDelegate}) - .then((response) => { - if (!navigation.isFocused()) { - if (response?.data?.policyDiff) { - clearVacationDelegateError(currentDelegate); - } - return; - } - - if (response?.data?.policyDiff) { - Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE_MISSING_WORKSPACES); - return; - } - - // The action leaves the failure on the NVP for the profile page's red brick road, but the user is still on this screen, - // so report it where they are. Dismissing the modal restores the previous delegate, exactly as dismissing that error would. - if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { - showErrorModal(currentDelegate, response?.jsonCode === CONST.JSON_CODE.EXP_ERROR ? response.message : undefined); - return; - } - - Navigation.goBack(ROUTES.SETTINGS_STATUS); - }) - .catch(() => { - if (!navigation.isFocused()) { - clearVacationDelegateError(currentDelegate); - return; - } - - showErrorModal(currentDelegate); - }) - .finally(() => { - isSelectingRef.current = false; - }); + Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE); }; return ( @@ -96,10 +53,11 @@ function VacationDelegatePage() { shouldShowOfflineIndicator={false} > Navigation.goBack(ROUTES.SETTINGS_STATUS)} + headerTitle={translate('statusPage.vacationDelegate.chooseDelegate')} + onBackButtonPress={() => Navigation.goBack()} cannotSetDelegateMessage={translate('statusPage.cannotSetVacationDelegate')} includeCurrentUser={false} /> diff --git a/src/pages/settings/Profile/ProfilePage.tsx b/src/pages/settings/Profile/ProfilePage.tsx index fbe2ff443f38..8d3b57b34d93 100755 --- a/src/pages/settings/Profile/ProfilePage.tsx +++ b/src/pages/settings/Profile/ProfilePage.tsx @@ -15,6 +15,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Section from '@components/Section'; +import VacationDelegateMenuItem from '@components/VacationDelegateMenuItem'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDocumentTitle from '@hooks/useDocumentTitle'; @@ -29,7 +30,10 @@ import useScrollEnabled from '@hooks/useScrollEnabled'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import useVacationDelegatePersonalDetails from '@hooks/useVacationDelegatePersonalDetails'; +import {clearDraftValues} from '@libs/actions/FormActions'; +import getVacationDelegateDisplayName from '@libs/getVacationDelegateDisplayName'; import getVacationDelegateErrors from '@libs/getVacationDelegateErrors'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; @@ -37,6 +41,7 @@ import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigat import type {SettingsSplitNavigatorParamList} from '@libs/Navigation/types'; import {getFormattedAddress, temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {expensifyLoginsSelector, getContactMethodsOptions, getLoginListBrickRoadIndicator} from '@libs/UserUtils'; +import {formatVacationDelegateClearDateTime, getActiveVacationDelegate, getVacationDelegateClearDateTime} from '@libs/VacationDelegateUtils'; import useTimeSensitiveHomeAddress from '@pages/home/TimeSensitiveSection/hooks/useTimeSensitiveHomeAddress'; @@ -49,9 +54,10 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/PersonalDetailsForm'; +import type {PendingAction} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {ComponentRef} from 'react'; +import type {ComponentRef, ReactNode} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -67,7 +73,7 @@ function ProfilePage() { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const {translate, formatPhoneNumber} = useLocalize(); + const {translate, formatPhoneNumber, dateFnsLocale} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {safeAreaPaddingBottomStyle} = useSafeAreaPaddings(); const scrollEnabled = useScrollEnabled(); @@ -99,15 +105,36 @@ function ProfilePage() { const [commuterExclusionsWorkspaceName] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: homeAndOfficeCommuterExclusionPolicyNameSelector}); const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE); + const activeVacationDelegate = getActiveVacationDelegate(vacationDelegate); + const hasVacationDelegate = !!activeVacationDelegate; + const vacationDelegatePersonalDetails = useVacationDelegatePersonalDetails(activeVacationDelegate); + const vacationDelegateName = hasVacationDelegate + ? getVacationDelegateDisplayName(vacationDelegatePersonalDetails?.login ?? activeVacationDelegate, vacationDelegatePersonalDetails?.displayName, formatPhoneNumber) + : ''; + const vacationDelegateClearDate = hasVacationDelegate + ? formatVacationDelegateClearDateTime(getVacationDelegateClearDateTime(vacationDelegate?.clearAfter, currentUserPersonalDetails?.timezone?.selected), dateFnsLocale) + : ''; + const vacationDelegateUntilText = vacationDelegateClearDate ? translate('statusPage.vacationDelegate.until', vacationDelegateClearDate) : ''; + const vacationDelegateBrickRoadIndicator = isEmptyObject(getVacationDelegateErrors(vacationDelegate)) ? undefined : CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; const {isActingAsDelegate} = useDelegateNoAccessState(); const {showDelegateNoAccessModal} = useDelegateNoAccessActions(); const publicOptions: Array<{ description: string; title: string; pageRoute?: Route; + onPress?: () => void; brickRoadIndicator?: ValueOf; testID?: string; sentryLabel?: string; + + /** Replaces the default name and value line, for a value that is not plain text */ + content?: ReactNode; + + /** Accessibility label for a row whose `content` is not plain text */ + accessibilityLabel?: string; + + /** Greys the row out while a change to its value is waiting for the network */ + pendingAction?: PendingAction; }> = [ { description: translate('displayNamePage.headerTitle'), @@ -131,10 +158,34 @@ function ProfilePage() { description: translate('statusPage.status'), title: emojiCode ? `${emojiCode} ${currentUserPersonalDetails?.status?.text ?? ''}` : '', pageRoute: ROUTES.SETTINGS_STATUS, - brickRoadIndicator: isEmptyObject(getVacationDelegateErrors(vacationDelegate)) ? undefined : CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, testID: 'status-menu-item', sentryLabel: CONST.SENTRY_LABEL.SETTINGS_PROFILE.STATUS, }, + { + description: translate('common.vacationDelegate'), + title: vacationDelegateName, + content: hasVacationDelegate ? ( + + ) : undefined, + accessibilityLabel: hasVacationDelegate ? [translate('common.vacationDelegate'), vacationDelegateName, vacationDelegateUntilText].filter(Boolean).join(', ') : undefined, + pageRoute: ROUTES.SETTINGS_VACATION_DELEGATE, + onPress: () => { + clearDraftValues(ONYXKEYS.FORMS.VACATION_DELEGATE_FORM); + Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE); + }, + brickRoadIndicator: vacationDelegateBrickRoadIndicator, + pendingAction: vacationDelegate?.pendingAction ?? undefined, + testID: 'vacation-delegate-menu-item', + sentryLabel: CONST.SENTRY_LABEL.SETTINGS_PROFILE.VACATION_DELEGATE, + }, ...(isAgentAccount === false ? [ { @@ -277,24 +328,32 @@ function ProfilePage() { {publicOptions.map((detail) => { const {pageRoute} = detail; return ( - Navigation.navigate(pageRoute) : undefined} - testID={detail?.testID} - sentryLabel={detail.sentryLabel} + pendingAction={detail.pendingAction} + shouldHideOnDelete={false} > - Navigation.navigate(pageRoute) : undefined)} + testID={detail?.testID} + sentryLabel={detail.sentryLabel} + accessibilityLabel={detail.accessibilityLabel} > - {(!!detail.brickRoadIndicator || !!pageRoute) && ( - <> - {!!detail.brickRoadIndicator && } - {!!pageRoute && } - + {detail.content ?? ( + + {(!!detail.brickRoadIndicator || !!pageRoute) && ( + <> + {!!detail.brickRoadIndicator && } + {!!pageRoute && } + + )} + )} - - + + ); })}