Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3c95faa
Add a clear after date to vacation delegates and move them to the Pro…
MelvinBot Sep 28, 2026
5cffbfc
Match the vacation delegate mockups: show the delegate as a pill, add…
MelvinBot Sep 29, 2026
4ecae14
Fix vacation delegate navigation and match the date input to the mockups
MelvinBot Sep 30, 2026
b8e31b1
Merge main into claude-vacationDelegateClearAfter
MelvinBot Oct 1, 2026
f778819
Let users pick a time as well as a date for the vacation delegate to …
MelvinBot Oct 1, 2026
36812b0
Merge remote-tracking branch 'origin/main' into claude-vacationDelega…
MelvinBot Oct 2, 2026
593f42f
Use the composed Header and a memoized useOnyx selector on the vacati…
MelvinBot Oct 2, 2026
006d892
Address review comments on the vacation delegate form
MelvinBot Oct 7, 2026
fcf9bdf
Validate the vacation delegate clear date in the Profile timezone
MelvinBot Oct 8, 2026
693aafe
Merge remote-tracking branch 'origin/main' into claude-vacationDelega…
MelvinBot Oct 9, 2026
91e069c
Address C+ review on vacation delegates: domain guard, member timezon…
MelvinBot Oct 9, 2026
b4c2607
Address review on vacation delegates: page-driven Save spinner, queue…
MelvinBot Oct 9, 2026
4c176fc
Apply a picked vacation delegate only after the backend saves it unle…
MelvinBot Oct 9, 2026
bcb9f88
Always open the vacation delegate form from the row, show a clear but…
MelvinBot Oct 12, 2026
74e4599
Merge remote-tracking branch 'origin/main' into claude-vacationDelega…
MelvinBot Oct 12, 2026
17d4691
Add tests for expired delegates in the picker and the member details …
MelvinBot Oct 12, 2026
7786b7a
Merge remote-tracking branch 'origin/main' into claude-vacationDelega…
MelvinBot Oct 13, 2026
7eb737e
Merge remote-tracking branch 'origin/main' into claude-vacationDelega…
MelvinBot Oct 13, 2026
581b256
Merge remote-tracking branch 'origin/main' into claude-vacationDelega…
MelvinBot Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,35 @@
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.

---

## 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**.

---

Expand All @@ -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?

Expand Down
1 change: 1 addition & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 8 additions & 2 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
9 changes: 8 additions & 1 deletion src/components/DatePicker/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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}
Expand All @@ -243,6 +249,7 @@ function DatePicker({
textInputContainerStyles={isModalVisible ? styles.borderColorFocus : {}}
shouldHideClearButton={shouldHideClearButton}
onClearInput={handleClear}
clearButtonStyle={[styles.mh0, {paddingHorizontal: iconPaddingHorizontal}]}
forwardedFSClass={forwardedFSClass}
autoComplete={autoComplete}
disableKeyboard
Expand Down
208 changes: 208 additions & 0 deletions src/components/VacationDelegateForm.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof ONYXKEYS.FORMS.VACATION_DELEGATE_FORM>): FormInputErrors<typeof ONYXKEYS.FORMS.VACATION_DELEGATE_FORM> => {
const formErrors: FormInputErrors<typeof ONYXKEYS.FORMS.VACATION_DELEGATE_FORM> = {};
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<typeof ONYXKEYS.FORMS.VACATION_DELEGATE_FORM>) => {
if (!delegate) {
return;
}

onSubmit(delegate, getVacationDelegateClearAfter(values[INPUT_IDS.CLEAR_AFTER_DATE], values[INPUT_IDS.CLEAR_AFTER_TIME], timezone));
};

return (
<FormProvider
formID={ONYXKEYS.FORMS.VACATION_DELEGATE_FORM}
style={styles.flexGrow1}
onSubmit={submit}
validate={validate}
submitButtonText={translate('common.save')}
submitButtonStyles={styles.ph5}
isSubmitDisabled={!delegate}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 After a failed save, the Save spinner never stops once Got it is clicked, and Save stops responding. FormProvider's press spinner (shouldShowLoadingImmediatelyOnPress, on by default) only resets when the screen regains focus. The confirm modal never takes focus away, so submit() keeps returning early on isLoading.

Can we let the page drive the spinner instead ?

/** Whether the save request is still running, which shows the spinner on the Save button */
isLoading?: boolean;
...
isSubmitDisabled={!delegate}
isLoading={isLoading}
// The press spinner only resets when the screen regains focus, which the error modal never takes away, so it would stay on
// after a failed save. The page drives the spinner through isLoading instead.
shouldShowLoadingImmediatelyOnPress={false}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in b4c2607. The form takes an isLoading prop and sets shouldShowLoadingImmediatelyOnPress={false}, so the page drives the spinner. The domain form saves with a queued write and closes right away, so it doesn't pass isLoading.

isLoading={isLoading}
// The press spinner only resets when the screen regains focus, which the error modal never takes away, so it would stay on
// after a failed save. The page drives the spinner through isLoading instead.
shouldShowLoadingImmediatelyOnPress={false}
enabledWhenOffline
shouldHideFixErrorsAlert
>
{!!description && <Text style={[styles.mh5, styles.mb4]}>{description}</Text>}
<VacationDelegateMenuItem
vacationDelegate={{delegate}}
label={translate('statusPage.vacationDelegate.chooseDelegate')}
errors={errors}
pendingAction={pendingAction}
onCloseError={onCloseError}
onPress={onChangeDelegate}
/>
{/* The clear after date and time belong to the same pending save as the delegate, so they grey out with it */}
<OfflineWithFeedback
pendingAction={pendingAction}
shouldHideOnDelete={false}
>
<View style={styles.ph5}>
{/* 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. */}
<InputWrapper
InputComponent={DatePicker}
inputID={INPUT_IDS.CLEAR_AFTER_DATE}
label={translate('statusPage.vacationDelegate.clearAfterRecommended')}
defaultValue={savedClearDate}
minDate={getLocalDateFromDatetime(undefined, timezone)}
icon={icons.CalendarSolid}
shouldSaveDraft
/>
</View>
{/* The time only means something once a day is picked. Until it is changed, the delegate clears at the end of that day. */}
{!!clearDate && (
<View style={styles.mt2}>
<InputWrapper
InputComponent={TimeModalPicker}
inputID={INPUT_IDS.CLEAR_AFTER_TIME}
label={translate('statusPage.time')}
defaultValue={savedClearDateTime || DateUtils.getEndOfToday()}
shouldSaveDraft
/>
</View>
)}
{/* The bottom margin matches the date input's own, so the remove button below sits the same distance from whichever is last */}
{!!formattedClearDateTime && (
<Text style={[styles.mh5, styles.textLabelSupporting, styles.mt2, styles.mb2]}>{translate('statusPage.vacationDelegate.willClearOn', formattedClearDateTime)}</Text>
)}
</OfflineWithFeedback>
{!!savedDelegateOnOpen && !!savedDelegate && (
<View style={styles.mt4}>
<MenuItemAction
title={translate('statusPage.vacationDelegate.removeDelegate')}
icon={icons.Trashcan}
onPress={onRemove}
/>
</View>
)}
</FormProvider>
);
}

export default VacationDelegateForm;
Loading
Loading