Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
570dcf6
Add vacation delegate handling to StatusPage with active delegations …
samranahm Dec 24, 2025
888da9b
Add delegatorFor field to VacationDelegate type
samranahm Dec 24, 2025
669b77d
add cannotSetVacationDelegate message in all locals
samranahm Dec 24, 2025
d5a8f71
fix: set interactive false for delegator MenuItem component in Status…
samranahm Dec 24, 2025
1dfe78c
add error page for users with active vacation delegations
samranahm Dec 24, 2025
56bd5bc
refactor: extract delegator list rendering into a separate function i…
samranahm Dec 26, 2025
1904c04
Merge remote-tracking branch 'upstream/main' into 78037/vacation-dele…
samranahm Jan 6, 2026
95095aa
Merge remote-tracking branch 'upstream/main' into 78037/vacation-dele…
samranahm Jan 12, 2026
10a4954
refactor: simplify hasActiveDelegations condition in StatusPage
samranahm Jan 13, 2026
f42223f
remove redundant check from renderDelegatorList
samranahm Jan 15, 2026
ccb3bfd
Merge remote-tracking branch 'upstream/main' into 78037/vacation-dele…
samranahm Jan 15, 2026
c995a24
prevent scroll to bottom in status page
samranahm Jan 15, 2026
6f25a87
Merge remote-tracking branch 'upstream/main' into 78037/vacation-dele…
samranahm Jan 19, 2026
1d6431a
feat: add cannot set vacation delegate view in VacationDelegatePage
samranahm Jan 19, 2026
9ab07fb
feat: add fixed footer with save button to StatusPage
samranahm Jan 22, 2026
abaf6c5
Merge branch 'Expensify:main' into 78037/vacation-delegate-in-status-…
samranahm Jan 22, 2026
c744977
feat: add usePersonalDetailsByLogin hook
samranahm Jan 23, 2026
d736c0f
fix: keyboard reopen on modal hide in VacationDelegatePage
samranahm Jan 23, 2026
1196473
Merge remote-tracking branch 'upstream/main' into 78037/vacation-dele…
samranahm Feb 2, 2026
68f91df
Merge remote-tracking branch 'upstream/main' into 78037/vacation-dele…
samranahm Feb 3, 2026
e18be67
Merge branch 'Expensify:main' into 78037/vacation-delegate-in-status-…
samranahm Feb 5, 2026
95aa9af
Merge remote-tracking branch 'upstream/main' into 78037/vacation-dele…
samranahm Feb 6, 2026
87e125a
replace Text import to use custom Text component
samranahm Feb 6, 2026
f5b53f4
Add form state handling and button loading indicator
samranahm Feb 6, 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
27 changes: 27 additions & 0 deletions src/hooks/usePersonalDetailsByLogin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {useMemo} from 'react';
import ONYXKEYS from '@src/ONYXKEYS';
import type {PersonalDetails} from '@src/types/onyx';
import useOnyx from './useOnyx';

/**
* Hook that returns personal details indexed by login email.
* Enables case-insensitive lookups.
*/
function usePersonalDetailsByLogin(): Record<string, PersonalDetails> {
const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: true});

return useMemo(() => {
if (!personalDetails) {
return {};
}

return Object.values(personalDetails).reduce((acc: Record<string, PersonalDetails>, detail) => {
if (detail?.login) {
acc[detail.login.toLowerCase()] = detail;
}
return acc;
}, {});
}, [personalDetails]);
}

export default usePersonalDetailsByLogin;
1 change: 1 addition & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3139,6 +3139,7 @@ ${
whenClearStatus: 'Wann sollen wir deinen Status zurücksetzen?',
vacationDelegate: 'Urlaubsvertretung',
setVacationDelegate: `Lege eine Vertretung für den Urlaub fest, die Berichte in deiner Abwesenheit in deinem Namen genehmigt.`,
cannotSetVacationDelegate: `Du kannst keinen Urlaubsvertreter festlegen, da du derzeit der Vertreter für die folgenden Mitglieder bist:`,
vacationDelegateError: 'Beim Aktualisieren Ihrer Vertretung im Urlaub ist ein Fehler aufgetreten.',
asVacationDelegate: ({nameOrEmail}: VacationDelegateParams) => `als Urlaubsvertretung von ${nameOrEmail}`,
toAsVacationDelegate: ({submittedToName, vacationDelegateName}: SubmittedToVacationDelegateParams) => `an ${submittedToName} als Urlaubsvertretung für ${vacationDelegateName}`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3165,6 +3165,7 @@ const translations = {
whenClearStatus: 'When should we clear your status?',
vacationDelegate: 'Vacation delegate',
setVacationDelegate: `Set a vacation delegate to approve reports on your behalf while you're out of office.`,
cannotSetVacationDelegate: `You can't set a vacation delegate because you're currently the delegate for the following members:`,
vacationDelegateError: 'There was an error updating your vacation delegate.',
asVacationDelegate: ({nameOrEmail}: VacationDelegateParams) => `as ${nameOrEmail}'s vacation delegate`,
toAsVacationDelegate: ({submittedToName, vacationDelegateName}: SubmittedToVacationDelegateParams) => `to ${submittedToName} as vacation delegate for ${vacationDelegateName}`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2904,6 +2904,7 @@ ${amount} para ${merchant} - ${date}`,
whenClearStatus: '¿Cuándo deberíamos borrar tu estado?',
vacationDelegate: 'Delegado de vacaciones',
setVacationDelegate: 'Configura un delegado de vacaciones para aprobar informes en tu nombre mientras estás fuera de la oficina.',
cannotSetVacationDelegate: `No puedes establecer un delegado de vacaciones porque actualmente eres el delegado de los siguientes miembros:`,
vacationDelegateError: 'Hubo un error al actualizar tu delegado de vacaciones.',
asVacationDelegate: ({nameOrEmail: managerName}) => `como delegado de vacaciones de ${managerName}`,
toAsVacationDelegate: ({submittedToName, vacationDelegateName}) => `a ${submittedToName} como delegado de vacaciones de ${vacationDelegateName}`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3145,6 +3145,7 @@ ${
whenClearStatus: 'Quand devons-nous effacer votre statut ?',
vacationDelegate: 'Délégué de vacances',
setVacationDelegate: `Définissez un délégué de vacances pour approuver les notes de frais en votre nom pendant votre absence du bureau.`,
cannotSetVacationDelegate: `Vous ne pouvez pas définir un délégué de vacances car vous êtes actuellement le délégué des membres suivants :`,
vacationDelegateError: 'Une erreur s’est produite lors de la mise à jour de votre remplaçant de congés.',
asVacationDelegate: ({nameOrEmail}: VacationDelegateParams) => `en tant que délégué de vacances de ${nameOrEmail}`,
toAsVacationDelegate: ({submittedToName, vacationDelegateName}: SubmittedToVacationDelegateParams) =>
Expand Down
1 change: 1 addition & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3130,6 +3130,7 @@ ${
whenClearStatus: 'Quando dovremmo cancellare il tuo stato?',
vacationDelegate: 'Delegato ferie',
setVacationDelegate: `Imposta un delegato per le ferie per approvare i report per tuo conto mentre sei fuori ufficio.`,
cannotSetVacationDelegate: `Non puoi impostare un delegato per le ferie perché al momento sei il delegato per i seguenti membri:`,
vacationDelegateError: 'Si è verificato un errore durante l’aggiornamento del tuo delegato per le ferie.',
asVacationDelegate: ({nameOrEmail}: VacationDelegateParams) => `come delegato per le ferie di ${nameOrEmail}`,
toAsVacationDelegate: ({submittedToName, vacationDelegateName}: SubmittedToVacationDelegateParams) => `a ${submittedToName} come delegato ferie per ${vacationDelegateName}`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3116,6 +3116,7 @@ ${
whenClearStatus: 'ステータスをいつクリアしますか?',
vacationDelegate: '休暇代理人',
setVacationDelegate: `休暇中に不在の間、あなたに代わってレポートを承認する代理人を設定しましょう。`,
cannotSetVacationDelegate: `現在、次のメンバーの代理人になっているため、休暇代理人を設定できません:`,
vacationDelegateError: '休暇の代理人を更新中にエラーが発生しました。',
asVacationDelegate: ({nameOrEmail}: VacationDelegateParams) => `${nameOrEmail} さんの休暇代理として`,
toAsVacationDelegate: ({submittedToName, vacationDelegateName}: SubmittedToVacationDelegateParams) => `${vacationDelegateName} の休暇代理人として ${submittedToName} に`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3130,6 +3130,7 @@ ${
whenClearStatus: 'Wanneer moeten we je status wissen?',
vacationDelegate: 'Vertegenwoordiger tijdens vakantie',
setVacationDelegate: `Stel een vervangende fiatteur in om rapporten namens jou goed te keuren terwijl je afwezig bent.`,
cannotSetVacationDelegate: `Je kunt geen vakantiedelegaat instellen omdat je momenteel de delegaat bent voor de volgende leden:`,
vacationDelegateError: 'Er is een fout opgetreden bij het bijwerken van je vervanger tijdens vakantie.',
asVacationDelegate: ({nameOrEmail}: VacationDelegateParams) => `als vakantiewaarnemer van ${nameOrEmail}`,
toAsVacationDelegate: ({submittedToName, vacationDelegateName}: SubmittedToVacationDelegateParams) =>
Expand Down
1 change: 1 addition & 0 deletions src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3121,6 +3121,7 @@ ${
whenClearStatus: 'Kiedy powinniśmy wyczyścić Twój status?',
vacationDelegate: 'Zastępca urlopowy',
setVacationDelegate: `Ustaw zastępcę na czas urlopu, aby zatwierdzał raporty w Twoim imieniu, gdy jesteś poza biurem.`,
cannotSetVacationDelegate: `Nie możesz ustawić delegata urlopowego, ponieważ obecnie jesteś delegatem dla następujących członków:`,
vacationDelegateError: 'Wystąpił błąd podczas aktualizowania Twojego zastępcy urlopowego.',
asVacationDelegate: ({nameOrEmail}: VacationDelegateParams) => `jako osoba zastępująca ${nameOrEmail} podczas urlopu`,
toAsVacationDelegate: ({submittedToName, vacationDelegateName}: SubmittedToVacationDelegateParams) => `do ${submittedToName} jako zastępca urlopowy dla ${vacationDelegateName}`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3123,6 +3123,7 @@ ${
whenClearStatus: 'Quando devemos limpar seu status?',
vacationDelegate: 'Delegado de férias',
setVacationDelegate: `Defina um delegado de férias para aprovar relatórios em seu nome enquanto você estiver fora do escritório.`,
cannotSetVacationDelegate: `Você não pode definir um delegado de férias porque atualmente é o delegado dos seguintes membros:`,
vacationDelegateError: 'Ocorreu um erro ao atualizar seu delegado de férias.',
asVacationDelegate: ({nameOrEmail}: VacationDelegateParams) => `como delegado de férias de ${nameOrEmail}`,
toAsVacationDelegate: ({submittedToName, vacationDelegateName}: SubmittedToVacationDelegateParams) => `para ${submittedToName} como delegado(a) de férias de ${vacationDelegateName}`,
Expand Down
1 change: 1 addition & 0 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3078,6 +3078,7 @@ ${
whenClearStatus: '我们应在何时清除你的状态?',
vacationDelegate: '休假代理',
setVacationDelegate: `设置一个休假代理人在你不在办公室时代你审批报销报告。`,
cannotSetVacationDelegate: `由于你目前是以下成员的代理人,因此无法设置休假代理人:`,
vacationDelegateError: '更新你的休假代理时出错。',
asVacationDelegate: ({nameOrEmail}: VacationDelegateParams) => `作为 ${nameOrEmail} 的休假代理`,
toAsVacationDelegate: ({submittedToName, vacationDelegateName}: SubmittedToVacationDelegateParams) => `作为 ${vacationDelegateName} 的休假代理人提交给 ${submittedToName}`,
Expand Down
117 changes: 85 additions & 32 deletions src/pages/settings/Profile/CustomStatus/StatusPage.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {InteractionManager, View} from 'react-native';
import type {ValueOf} from 'type-fest';
import Button from '@components/Button';
import EmojiPickerButtonDropdown from '@components/EmojiPicker/EmojiPickerButtonDropdown';
import FixedFooter from '@components/FixedFooter';
import FormProvider from '@components/Form/FormProvider';
import InputWrapper from '@components/Form/InputWrapper';
import type {FormInputErrors, FormOnyxValues, FormRef} from '@components/Form/types';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import * as Expensicons from '@components/Icon/Expensicons';

Check warning on line 11 in src/pages/settings/Profile/CustomStatus/StatusPage.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

'@components/Icon/Expensicons' import is restricted from being used by a pattern. Direct imports from Icon/Expensicons are deprecated. Please use lazy loading hooks instead. Use `useMemoizedLazyExpensifyIcons` from @hooks/useLazyAsset. See docs/LAZY_ICONS_AND_ILLUSTRATIONS.md for details

Check warning on line 11 in src/pages/settings/Profile/CustomStatus/StatusPage.tsx

View workflow job for this annotation

GitHub Actions / Changed files ESLint check

'@components/Icon/Expensicons' import is restricted from being used. Direct imports from @components/Icon/Expensicons are deprecated. Please use lazy loading hooks instead. Use `useMemoizedLazyExpensifyIcons` from @hooks/useLazyAsset. See docs/LAZY_ICONS_AND_ILLUSTRATIONS.md for details
import MenuItem from '@components/MenuItem';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
Expand All @@ -18,6 +20,7 @@
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import usePersonalDetailsByLogin from '@hooks/usePersonalDetailsByLogin';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
Expand All @@ -28,7 +31,6 @@
import focusComposerWithDelay from '@libs/focusComposerWithDelay';
import {formatPhoneNumber} from '@libs/LocalePhoneNumber';
import Navigation from '@libs/Navigation/Navigation';
import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils';
import {clearCustomStatus, clearDraftCustomStatus, updateCustomStatus, updateDraftCustomStatus} from '@userActions/User';
import {clearVacationDelegateError} from '@userActions/VacationDelegate';
import CONST from '@src/CONST';
Expand All @@ -54,14 +56,18 @@
const {isSmallScreenWidth} = useResponsiveLayout();

const [draftStatus] = useOnyx(ONYXKEYS.CUSTOM_STATUS_DRAFT, {canBeMissing: true});
const [formState] = useOnyx(ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM, {canBeMissing: true});
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
const personalDetailsByLogin = usePersonalDetailsByLogin();
const formRef = useRef<FormRef>(null);
const [brickRoadIndicator, setBrickRoadIndicator] = useState<ValueOf<typeof CONST.BRICK_ROAD_INDICATOR_STATUS>>();

const [vacationDelegate] = useOnyx(ONYXKEYS.NVP_PRIVATE_VACATION_DELEGATE, {canBeMissing: true});
const hasVacationDelegate = !!vacationDelegate?.delegate;
const vacationDelegatePersonalDetails = getPersonalDetailByEmail(vacationDelegate?.delegate ?? '');
const hasActiveDelegations = !!vacationDelegate?.delegatorFor?.length;
const vacationDelegatePersonalDetails = personalDetailsByLogin[vacationDelegate?.delegate?.toLowerCase() ?? ''];
const formattedDelegateLogin = formatPhoneNumber(vacationDelegatePersonalDetails?.login ?? '');
const isFormLoading = !!formState?.isLoading;

const currentUserEmojiCode = currentUserPersonalDetails?.status?.emojiCode ?? '';
const currentUserStatusText = currentUserPersonalDetails?.status?.text ?? '';
Expand Down Expand Up @@ -189,6 +195,28 @@
const {inputCallbackRef, inputRef} = useAutoFocusInput();
const fallbackVacationDelegateLogin = formattedDelegateLogin === '' ? vacationDelegate?.delegate : formattedDelegateLogin;

const renderDelegatorList = () => {
return vacationDelegate?.delegatorFor?.map((delegatorEmail) => {
const delegatorDetails = personalDetailsByLogin[delegatorEmail.toLowerCase()];
const formattedLogin = formatPhoneNumber(delegatorDetails?.login ?? '');
const displayLogin = formattedLogin || delegatorEmail;

return (
<MenuItem
key={delegatorEmail}
title={delegatorDetails?.displayName ?? displayLogin}
description={displayLogin}
avatarID={delegatorDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID}
icon={delegatorDetails?.avatar ?? icons.FallbackAvatar}
iconType={CONST.ICON_TYPE_AVATAR}
numberOfLinesDescription={1}
containerStyle={[styles.pr2, styles.mt1]}
interactive={false}
/>
);
});
};

return (
<ScreenWrapper
style={[StyleUtils.getBackgroundColorStyle(theme.PAGE_THEMES[SCREENS.SETTINGS.PROFILE.STATUS].backgroundColor)]}
Expand All @@ -206,11 +234,10 @@
style={[styles.flexGrow1, styles.flex1]}
ref={formRef}
submitButtonText={translate('statusPage.save')}
submitButtonStyles={[styles.mh5, styles.flexGrow1]}
onSubmit={updateStatus}
validate={validateForm}
isSubmitButtonVisible={false}
enabledWhenOffline
shouldScrollToEnd
>
<View style={[styles.mh5, styles.mv1]}>
<Text style={[styles.textNormal, styles.mt2]}>{translate('statusPage.statusExplanation')}</Text>
Expand Down Expand Up @@ -263,39 +290,65 @@
)}
</View>
<View style={[styles.mb2, styles.mt6]}>
<Text style={[styles.mh5]}>{translate('statusPage.setVacationDelegate')}</Text>
{hasVacationDelegate && <Text style={[styles.mh5, styles.mt6, styles.mutedTextLabel]}>{translate('statusPage.vacationDelegate')}</Text>}
{hasVacationDelegate ? (
<OfflineWithFeedback
pendingAction={vacationDelegate?.pendingAction}
errors={vacationDelegate?.errors}
errorRowStyles={styles.mh5}
onClose={() => clearVacationDelegateError(vacationDelegate?.previousDelegate)}
>
<MenuItem
title={vacationDelegatePersonalDetails?.displayName ?? fallbackVacationDelegateLogin}
description={fallbackVacationDelegateLogin}
avatarID={vacationDelegatePersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID}
icon={vacationDelegatePersonalDetails?.avatar ?? icons.FallbackAvatar}
iconType={CONST.ICON_TYPE_AVATAR}
numberOfLinesDescription={1}
shouldShowRightIcon
onPress={() => Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE)}
containerStyle={styles.pr2}
/>
</OfflineWithFeedback>
<Text style={[styles.headerText, styles.mh5, styles.mb2]}>{translate('statusPage.vacationDelegate')}</Text>
{hasActiveDelegations ? (
<View>
<Text style={[styles.mh5, styles.mb4]}>{translate('statusPage.cannotSetVacationDelegate')}</Text>
{renderDelegatorList()}
</View>
) : (
<View style={[styles.mt1]}>
<MenuItem
description={translate('statusPage.vacationDelegate')}
shouldShowRightIcon
onPress={() => Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE)}
containerStyle={styles.pr2}
/>
<View>
<Text style={[styles.mh5]}>{translate('statusPage.setVacationDelegate')}</Text>

{hasVacationDelegate ? (
<>
<Text style={[styles.mh5, styles.mt6, styles.mutedTextLabel]}>{translate('statusPage.vacationDelegate')}</Text>
<OfflineWithFeedback
pendingAction={vacationDelegate?.pendingAction}
errors={vacationDelegate?.errors}
errorRowStyles={styles.mh5}
onClose={() => clearVacationDelegateError(vacationDelegate?.previousDelegate)}
>
<MenuItem
title={vacationDelegatePersonalDetails?.displayName ?? fallbackVacationDelegateLogin}
description={fallbackVacationDelegateLogin}
avatarID={vacationDelegatePersonalDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID}
icon={vacationDelegatePersonalDetails?.avatar ?? icons.FallbackAvatar}
iconType={CONST.ICON_TYPE_AVATAR}
numberOfLinesDescription={1}
shouldShowRightIcon
onPress={() => Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE)}
containerStyle={styles.pr2}
/>
</OfflineWithFeedback>
</>
) : (
<View style={[styles.mt1]}>
<MenuItem
description={translate('statusPage.vacationDelegate')}
shouldShowRightIcon
onPress={() => Navigation.navigate(ROUTES.SETTINGS_VACATION_DELEGATE)}
containerStyle={styles.pr2}
/>
</View>
)}
</View>
)}
</View>
</FormProvider>

<FixedFooter style={[styles.mtAuto]}>
<Button
success
large
style={styles.w100}
text={translate('statusPage.save')}
onPress={() => formRef.current?.submit()}
pressOnEnter
enterKeyEventListenerPriority={1}
isLoading={isFormLoading}
/>
</FixedFooter>
</ScreenWrapper>
);
}
Expand Down
Loading
Loading