diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 00ef647995498..ebb7ca55ee095 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -652,6 +652,9 @@ const ONYXKEYS = {
/** The transaction IDs to be highlighted when opening the Expenses search route page */
TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE: 'transactionIdsHighlightOnSearchRoute',
+ /** Virtual employees configured for a workspace, keyed by policyID */
+ VIRTUAL_EMPLOYEES: 'virtualEmployees',
+
/** Collection Keys */
COLLECTION: {
DOMAIN: 'domain_',
@@ -777,6 +780,8 @@ const ONYXKEYS = {
* Key format: passkey_${userId}
*/
PASSKEY_CREDENTIALS: 'passkeyCredentials_',
+ VIRTUAL_EMPLOYEES: 'virtualEmployees_',
+ VIRTUAL_EMPLOYEE_ACTIONS: 'virtualEmployeeActions_',
},
/** List of Form ids */
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 2f7de242fe33c..c61a3006c6d6d 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -2636,6 +2636,15 @@ const ROUTES = {
route: 'workspaces/:policyID/travel/missing-personal-details',
getRoute: (policyID: string) => `workspaces/${policyID}/travel/missing-personal-details` as const,
},
+ WORKSPACE_VIRTUAL_EMPLOYEES: {
+ route: 'workspace/:policyID/virtual-employees',
+ getRoute: (policyID: string) => `workspace/${policyID}/virtual-employees` as const,
+ },
+ WORKSPACE_VIRTUAL_EMPLOYEES_EDIT: {
+ route: 'workspace/:policyID/virtual-employees/:virtualEmployeeID',
+ getRoute: (policyID: string, virtualEmployeeID: string) =>
+ `workspace/${policyID}/virtual-employees/${virtualEmployeeID}` as const,
+ },
WORKSPACE_CREATE_DISTANCE_RATE: {
route: 'workspaces/:policyID/distance-rates/new',
getRoute: (policyID: string, transactionID?: string, reportID?: string) =>
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 986beb3ac0a4c..8459a18969720 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -807,6 +807,8 @@ const SCREENS = {
PER_DIEM_EDIT_CURRENCY: 'Per_Diem_Edit_Currency',
TIME_TRACKING: 'Time_Tracking',
TIME_TRACKING_DEFAULT_RATE: 'Time_Tracking_Default_Rate',
+ VIRTUAL_EMPLOYEES: 'Workspace_VirtualEmployees',
+ VIRTUAL_EMPLOYEES_EDIT: 'Workspace_VirtualEmployees_Edit',
},
EDIT_REQUEST: {
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 3c02f0097a8c3..58ae4067aaf23 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -5339,6 +5339,70 @@ const translations = {
defaultHourlyRate: 'Default hourly rate',
},
},
+ virtualEmployees: {
+ title: 'Virtual employees',
+ addNew: 'Add virtual employee',
+ createTitle: 'Create virtual employee',
+ editTitle: 'Edit virtual employee',
+ emptyStateTitle: 'No virtual employees yet',
+ emptyStateDescription: 'Create AI-powered virtual employees to automate expense management tasks in your workspace.',
+ displayNameLabel: 'Name',
+ displayNamePlaceholder: 'e.g. AP Specialist, Spend Analyst...',
+ systemPromptLabel: 'Instructions',
+ systemPromptPlaceholder:
+ 'Example: You are an AP specialist for Acme Corp. When a report is submitted, check that every Amazon expense has a business purpose in the comment. If not, message the submitter and ask them to add one.',
+ systemPromptHint: 'Be specific about what to check and when to act. The more detail, the better.',
+ unnamedEmployee: 'Unnamed employee',
+ virtualEmployeeLabel: 'Virtual Employee',
+ previewPlaceholder: 'Start typing instructions to preview how your virtual employee will behave\u2026',
+ capabilitiesSection: 'Capabilities',
+ capabilitiesSectionHint: 'Choose what actions this virtual employee is allowed to perform.',
+ eventsSection: 'Triggers',
+ eventsSectionHint: 'Choose which workspace events will activate this virtual employee.',
+ statusPaused: 'Paused',
+ noCapabilities: 'No capabilities',
+ capabilityCount: ({count}: {count: number}) => (count === 1 ? '1 capability' : `${count} capabilities`),
+ triggerCount: ({count}: {count: number}) => (count === 1 ? '1 trigger' : `${count} triggers`),
+ create: 'Create',
+ capabilities: {
+ readTransactions: 'Read transactions',
+ readTransactionsDescription: 'View expense details, amounts, merchants, and receipts.',
+ readReports: 'Read reports',
+ readReportsDescription: 'View expense report summaries and status.',
+ editTransactions: 'Edit transactions',
+ editTransactionsDescription: 'Update comments, categories, and tags on expenses.',
+ sendMessages: 'Send messages',
+ sendMessagesDescription: 'Post comments in report chats and open direct messages.',
+ approveReports: 'Approve reports',
+ approveReportsDescription: 'Approve expense reports when configured as an approver in the workspace workflow.',
+ rejectReports: 'Reject or hold reports',
+ rejectReportsDescription: 'Return reports to the submitter or place them on hold with a reason.',
+ dismissViolations: 'Dismiss violations',
+ dismissViolationsDescription: 'Dismiss policy violations on expenses.',
+ readPolicy: 'Read policy rules',
+ readPolicyDescription: 'View workspace spending limits, categories, and approval settings.',
+ },
+ events: {
+ transactionCreated: 'New expense created',
+ transactionCreatedDescription: 'Fires when any expense is added to the workspace.',
+ transactionModified: 'Expense modified',
+ transactionModifiedDescription: 'Fires when an expense field (amount, merchant, category, etc.) is changed.',
+ receiptScanned: 'Receipt scanned',
+ receiptScannedDescription: 'Fires when SmartScan finishes reading a receipt.',
+ reportSubmitted: 'Report submitted',
+ reportSubmittedDescription: 'Fires when a member submits an expense report for approval.',
+ reportApproved: 'Report approved',
+ reportApprovedDescription: 'Fires when an expense report is approved.',
+ chatMention: 'Mentioned in chat',
+ chatMentionDescription: 'Fires when this virtual employee is @mentioned in any workspace chat.',
+ chatMessage: 'Any chat message',
+ chatMessageDescription: 'Fires on every message in chats where this virtual employee participates. Use with care — high volume.',
+ },
+ errors: {
+ displayNameRequired: 'Display name is required.',
+ systemPromptMinLength: ({minLength}: {minLength: number}) => `System prompt must be at least ${minLength} characters.`,
+ },
+ },
reports: {
reportsCustomTitleExamples: 'Examples:',
customReportNamesSubtitle: `Customize report titles using our extensive formulas.`,
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 091cfe1e11a0a..e5642009a074e 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -5187,6 +5187,49 @@ ${amount} para ${merchant} - ${date}`,
defaultHourlyRate: 'Tarifa por hora predeterminada',
},
},
+ virtualEmployees: {
+ title: 'Empleados virtuales',
+ addNew: 'Agregar empleado virtual',
+ createTitle: 'Crear empleado virtual',
+ editTitle: 'Editar empleado virtual',
+ emptyStateTitle: 'No hay empleados virtuales',
+ emptyStateDescription: 'Crea empleados virtuales con IA para automatizar tareas de gestión de gastos en tu espacio de trabajo.',
+ displayNameLabel: 'Nombre para mostrar',
+ systemPromptLabel: 'Prompt del sistema',
+ systemPromptHint: 'Describe el rol y comportamiento de este empleado virtual. Mínimo 20 caracteres.',
+ unnamedEmployee: 'Empleado sin nombre',
+ virtualEmployeeLabel: 'Empleado Virtual',
+ previewPlaceholder: 'Escribe instrucciones para previsualizar el comportamiento de tu empleado virtual\u2026',
+ capabilitiesSection: 'Capacidades',
+ eventsSection: 'Suscripciones a eventos',
+ noCapabilities: 'Sin capacidades',
+ capabilityCount: ({count}: {count: number}) => (count === 1 ? '1 capacidad' : `${count} capacidades`),
+ triggerCount: ({count}: {count: number}) => (count === 1 ? '1 activador' : `${count} activadores`),
+ create: 'Crear',
+ capabilities: {
+ readTransactions: 'Leer transacciones',
+ editTransactions: 'Editar transacciones',
+ sendMessages: 'Enviar mensajes',
+ approveReports: 'Aprobar informes',
+ rejectReports: 'Rechazar informes',
+ dismissViolations: 'Descartar violaciones',
+ readPolicy: 'Leer política',
+ readReports: 'Leer informes',
+ },
+ events: {
+ transactionCreated: 'Transacción creada',
+ transactionModified: 'Transacción modificada',
+ receiptScanned: 'Recibo escaneado',
+ reportSubmitted: 'Informe enviado',
+ reportApproved: 'Informe aprobado',
+ chatMention: 'Mención en chat',
+ chatMessage: 'Mensaje de chat',
+ },
+ errors: {
+ displayNameRequired: 'El nombre para mostrar es obligatorio.',
+ systemPromptMinLength: ({minLength}: {minLength: number}) => `El prompt del sistema debe tener al menos ${minLength} caracteres.`,
+ },
+ },
reports: {
reportsCustomTitleExamples: 'Ejemplos:',
customReportNamesSubtitle: `Personaliza los títulos de los informes usando nuestras amplias fórmulas.`,
diff --git a/src/libs/API/parameters/CreateVirtualEmployeeParams.ts b/src/libs/API/parameters/CreateVirtualEmployeeParams.ts
new file mode 100644
index 0000000000000..190140f05fb90
--- /dev/null
+++ b/src/libs/API/parameters/CreateVirtualEmployeeParams.ts
@@ -0,0 +1,9 @@
+type CreateVirtualEmployeeParams = {
+ policyID: string;
+ displayName: string;
+ systemPrompt: string;
+ capabilities: string;
+ eventSubs: string;
+};
+
+export default CreateVirtualEmployeeParams;
diff --git a/src/libs/API/parameters/DeleteVirtualEmployeeParams.ts b/src/libs/API/parameters/DeleteVirtualEmployeeParams.ts
new file mode 100644
index 0000000000000..c83735cfdc44a
--- /dev/null
+++ b/src/libs/API/parameters/DeleteVirtualEmployeeParams.ts
@@ -0,0 +1,6 @@
+type DeleteVirtualEmployeeParams = {
+ policyID: string;
+ vaAccountID: number;
+};
+
+export default DeleteVirtualEmployeeParams;
diff --git a/src/libs/API/parameters/OpenWorkspaceVirtualEmployeesPageParams.ts b/src/libs/API/parameters/OpenWorkspaceVirtualEmployeesPageParams.ts
new file mode 100644
index 0000000000000..6b4b75d30bfc1
--- /dev/null
+++ b/src/libs/API/parameters/OpenWorkspaceVirtualEmployeesPageParams.ts
@@ -0,0 +1,5 @@
+type OpenWorkspaceVirtualEmployeesPageParams = {
+ policyID: string;
+};
+
+export default OpenWorkspaceVirtualEmployeesPageParams;
diff --git a/src/libs/API/parameters/UpdateVirtualEmployeeParams.ts b/src/libs/API/parameters/UpdateVirtualEmployeeParams.ts
new file mode 100644
index 0000000000000..814746b64c0e4
--- /dev/null
+++ b/src/libs/API/parameters/UpdateVirtualEmployeeParams.ts
@@ -0,0 +1,10 @@
+type UpdateVirtualEmployeeParams = {
+ policyID: string;
+ vaAccountID: number;
+ displayName: string;
+ systemPrompt: string;
+ capabilities: string;
+ eventSubs: string;
+};
+
+export default UpdateVirtualEmployeeParams;
diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts
index 6f66f2af6a53b..f0765d992ea7e 100644
--- a/src/libs/API/parameters/index.ts
+++ b/src/libs/API/parameters/index.ts
@@ -489,3 +489,7 @@ export type {default as ToggleTwoFactorAuthRequiredForDomainParams} from './Togg
export type {default as DeleteVacationDelegateParams} from './DeleteVacationDelegateParams';
export type {default as SetTwoFactorAuthExemptEmailForDomainParams} from './SetTwoFactorAuthExemptEmailForDomainParams';
export type {default as ResetDomainMemberTwoFactorAuthParams} from './ResetDomainMemberTwoFactorAuthParams';
+export type {default as CreateVirtualEmployeeParams} from './CreateVirtualEmployeeParams';
+export type {default as UpdateVirtualEmployeeParams} from './UpdateVirtualEmployeeParams';
+export type {default as DeleteVirtualEmployeeParams} from './DeleteVirtualEmployeeParams';
+export type {default as OpenWorkspaceVirtualEmployeesPageParams} from './OpenWorkspaceVirtualEmployeesPageParams';
diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts
index f3f50e9481ebf..f4af6d2ffe9fd 100644
--- a/src/libs/API/types.ts
+++ b/src/libs/API/types.ts
@@ -561,6 +561,9 @@ const WRITE_COMMANDS = {
TOGGLE_TWO_FACTOR_AUTH_REQUIRED_FOR_DOMAIN: 'ToggleTwoFactorAuthRequiredForDomain',
SET_TWO_FACTOR_AUTH_EXEMPT_EMAIL_FOR_DOMAIN: 'SetTwoFactorAuthExemptEmailForDomain',
RESET_DOMAIN_MEMBER_TWO_FACTOR_AUTH: 'ResetDomainMemberTwoFactorAuth',
+ CREATE_VIRTUAL_EMPLOYEE: 'CreateVirtualEmployee',
+ UPDATE_VIRTUAL_EMPLOYEE: 'UpdateVirtualEmployee',
+ DELETE_VIRTUAL_EMPLOYEE: 'DeleteVirtualEmployee',
} as const;
type WriteCommand = ValueOf;
@@ -1139,6 +1142,9 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.TOGGLE_TWO_FACTOR_AUTH_REQUIRED_FOR_DOMAIN]: Parameters.ToggleTwoFactorAuthRequiredForDomainParams;
[WRITE_COMMANDS.SET_TWO_FACTOR_AUTH_EXEMPT_EMAIL_FOR_DOMAIN]: Parameters.SetTwoFactorAuthExemptEmailForDomainParams;
[WRITE_COMMANDS.RESET_DOMAIN_MEMBER_TWO_FACTOR_AUTH]: Parameters.ResetDomainMemberTwoFactorAuthParams;
+ [WRITE_COMMANDS.CREATE_VIRTUAL_EMPLOYEE]: Parameters.CreateVirtualEmployeeParams;
+ [WRITE_COMMANDS.UPDATE_VIRTUAL_EMPLOYEE]: Parameters.UpdateVirtualEmployeeParams;
+ [WRITE_COMMANDS.DELETE_VIRTUAL_EMPLOYEE]: Parameters.DeleteVirtualEmployeeParams;
};
const READ_COMMANDS = {
@@ -1225,6 +1231,7 @@ const READ_COMMANDS = {
GET_SAML_SETTINGS: 'GetSAMLSettings',
GET_DUPLICATE_TRANSACTION_DETAILS: 'GetDuplicateTransactionDetails',
GET_TRANSACTIONS_MATCHING_CODING_RULE: 'GetTransactionsMatchingCodingRule',
+ OPEN_WORKSPACE_VIRTUAL_EMPLOYEES_PAGE: 'OpenWorkspaceVirtualEmployeesPage',
} as const;
type ReadCommand = ValueOf;
@@ -1313,6 +1320,7 @@ type ReadCommandParameters = {
[READ_COMMANDS.OPEN_DOMAIN_INITIAL_PAGE]: Parameters.DomainParams;
[READ_COMMANDS.GET_DUPLICATE_TRANSACTION_DETAILS]: Parameters.GetDuplicateTransactionDetailsParams;
[READ_COMMANDS.GET_TRANSACTIONS_MATCHING_CODING_RULE]: Parameters.GetTransactionsMatchingCodingRuleParams;
+ [READ_COMMANDS.OPEN_WORKSPACE_VIRTUAL_EMPLOYEES_PAGE]: Parameters.OpenWorkspaceVirtualEmployeesPageParams;
};
const SIDE_EFFECT_REQUEST_COMMANDS = {
diff --git a/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx
index 2345747303a14..c6beac1ef048a 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx
+++ b/src/libs/Navigation/AppNavigator/Navigators/WorkspaceSplitNavigator.tsx
@@ -35,6 +35,8 @@ const CENTRAL_PANE_WORKSPACE_SCREENS = {
[SCREENS.WORKSPACE.TRAVEL]: () => require('../../../../pages/workspace/travel/PolicyTravelPage').default,
[SCREENS.WORKSPACE.RULES]: () => require('../../../../pages/workspace/rules/PolicyRulesPage').default,
[SCREENS.WORKSPACE.TIME_TRACKING]: () => require('../../../../pages/workspace/timeTracking/WorkspaceTimeTrackingPage').default,
+ [SCREENS.WORKSPACE.VIRTUAL_EMPLOYEES]: () => require('../../../../pages/workspace/virtualEmployees/WorkspaceVirtualEmployeesPage').default,
+ [SCREENS.WORKSPACE.VIRTUAL_EMPLOYEES_EDIT]: () => require('../../../../pages/workspace/virtualEmployees/WorkspaceVirtualEmployeePage').default,
} satisfies Screens;
const Split = createSplitNavigator();
diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts
index d38863497639a..c5b94634e3a09 100755
--- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts
+++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts
@@ -317,6 +317,7 @@ const WORKSPACE_TO_RHP: Partial['config'] = {
[SCREENS.WORKSPACE.TIME_TRACKING]: {
path: ROUTES.WORKSPACE_TIME_TRACKING.route,
},
+ [SCREENS.WORKSPACE.VIRTUAL_EMPLOYEES]: {
+ path: ROUTES.WORKSPACE_VIRTUAL_EMPLOYEES.route,
+ },
+ [SCREENS.WORKSPACE.VIRTUAL_EMPLOYEES_EDIT]: {
+ path: ROUTES.WORKSPACE_VIRTUAL_EMPLOYEES_EDIT.route,
+ },
},
},
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index 1ea09d1fc6ac3..8d7e14f485a83 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -2721,6 +2721,13 @@ type WorkspaceSplitNavigatorParamList = {
[SCREENS.WORKSPACE.TIME_TRACKING]: {
policyID: string;
};
+ [SCREENS.WORKSPACE.VIRTUAL_EMPLOYEES]: {
+ policyID: string;
+ };
+ [SCREENS.WORKSPACE.VIRTUAL_EMPLOYEES_EDIT]: {
+ policyID: string;
+ virtualEmployeeID: string;
+ };
};
type DomainSplitNavigatorParamList = {
diff --git a/src/libs/actions/VirtualEmployee.ts b/src/libs/actions/VirtualEmployee.ts
new file mode 100644
index 0000000000000..ab3dc8dd8ca30
--- /dev/null
+++ b/src/libs/actions/VirtualEmployee.ts
@@ -0,0 +1,130 @@
+import Onyx from 'react-native-onyx';
+import * as API from '@libs/API';
+import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {VirtualEmployee, VirtualEmployeeCapability, VirtualEmployeeEventSubscription} from '@src/types/onyx/VirtualEmployee';
+
+function createVirtualEmployee(policyID: string, displayName: string, systemPrompt: string, capabilities: VirtualEmployeeCapability[], eventSubs: VirtualEmployeeEventSubscription[]): void {
+ const optimisticID = `pending_${Date.now()}`;
+ const optimisticEmployee: VirtualEmployee = {
+ id: optimisticID,
+ policyID,
+ accountID: 0,
+ email: '',
+ displayName,
+ systemPrompt,
+ capabilities,
+ eventSubs,
+ status: 'active',
+ createdBy: 0,
+ pendingAction: 'add',
+ };
+
+ const optimisticData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.VIRTUAL_EMPLOYEES}${policyID}`,
+ value: {[optimisticID]: optimisticEmployee},
+ },
+ ];
+
+ const successData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.VIRTUAL_EMPLOYEES}${policyID}`,
+ value: {[optimisticID]: null},
+ },
+ ];
+
+ const failureData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.VIRTUAL_EMPLOYEES}${policyID}`,
+ value: {[optimisticID]: {pendingAction: null, errors: {create: 'Failed to create virtual employee'}}},
+ },
+ ];
+
+ API.write(
+ WRITE_COMMANDS.CREATE_VIRTUAL_EMPLOYEE,
+ {policyID, displayName, systemPrompt, capabilities: JSON.stringify(capabilities), eventSubs: JSON.stringify(eventSubs)},
+ {optimisticData, successData, failureData},
+ );
+}
+
+function updateVirtualEmployee(
+ policyID: string,
+ virtualEmployeeID: string,
+ vaAccountID: number,
+ updates: Partial>,
+): void {
+ const optimisticData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.VIRTUAL_EMPLOYEES}${policyID}`,
+ value: {[virtualEmployeeID]: {...updates, pendingAction: 'update'}},
+ },
+ ];
+
+ const successData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.VIRTUAL_EMPLOYEES}${policyID}`,
+ value: {[virtualEmployeeID]: {pendingAction: null}},
+ },
+ ];
+
+ const failureData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.VIRTUAL_EMPLOYEES}${policyID}`,
+ value: {[virtualEmployeeID]: {pendingAction: null, errors: {update: 'Failed to update'}}},
+ },
+ ];
+
+ API.write(
+ WRITE_COMMANDS.UPDATE_VIRTUAL_EMPLOYEE,
+ {
+ policyID,
+ vaAccountID,
+ displayName: updates.displayName ?? '',
+ systemPrompt: updates.systemPrompt ?? '',
+ capabilities: JSON.stringify(updates.capabilities),
+ eventSubs: JSON.stringify(updates.eventSubs),
+ },
+ {optimisticData, successData, failureData},
+ );
+}
+
+function deleteVirtualEmployee(policyID: string, virtualEmployeeID: string, vaAccountID: number): void {
+ const optimisticData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.VIRTUAL_EMPLOYEES}${policyID}`,
+ value: {[virtualEmployeeID]: {pendingAction: 'delete'}},
+ },
+ ];
+
+ const successData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.VIRTUAL_EMPLOYEES}${policyID}`,
+ value: {[virtualEmployeeID]: null},
+ },
+ ];
+
+ const failureData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.VIRTUAL_EMPLOYEES}${policyID}`,
+ value: {[virtualEmployeeID]: {pendingAction: null, errors: {delete: 'Failed to delete'}}},
+ },
+ ];
+
+ API.write(WRITE_COMMANDS.DELETE_VIRTUAL_EMPLOYEE, {policyID, vaAccountID}, {optimisticData, successData, failureData});
+}
+
+function openWorkspaceVirtualEmployeesPage(policyID: string): void {
+ API.read(READ_COMMANDS.OPEN_WORKSPACE_VIRTUAL_EMPLOYEES_PAGE, {policyID});
+}
+
+export {createVirtualEmployee, updateVirtualEmployee, deleteVirtualEmployee, openWorkspaceVirtualEmployeesPage};
diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx
index 511900116112b..391eafe35156f 100644
--- a/src/pages/workspace/WorkspaceInitialPage.tsx
+++ b/src/pages/workspace/WorkspaceInitialPage.tsx
@@ -111,6 +111,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac
'Workflows',
'LuggageWithLines',
'Clock',
+ 'Sparkles',
] as const);
const policy = policyDraft?.id ? policyDraft : policyProp;
@@ -384,6 +385,13 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac
});
}
+ protectedMenuItems.push({
+ translationKey: 'workspace.virtualEmployees.title',
+ icon: expensifyIcons.Sparkles,
+ action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_VIRTUAL_EMPLOYEES.getRoute(policyID)))),
+ screenName: SCREENS.WORKSPACE.VIRTUAL_EMPLOYEES,
+ });
+
protectedMenuItems.push({
translationKey: 'workspace.common.moreFeatures',
icon: expensifyIcons.Gear,
@@ -432,6 +440,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac
expensifyIcons.CalendarSolid,
expensifyIcons.InvoiceGeneric,
expensifyIcons.Clock,
+ expensifyIcons.Sparkles,
singleExecution,
waitForNavigate,
featureStates,
diff --git a/src/pages/workspace/virtualEmployees/WorkspaceVirtualEmployeePage.tsx b/src/pages/workspace/virtualEmployees/WorkspaceVirtualEmployeePage.tsx
new file mode 100644
index 0000000000000..501c735657718
--- /dev/null
+++ b/src/pages/workspace/virtualEmployees/WorkspaceVirtualEmployeePage.tsx
@@ -0,0 +1,465 @@
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import Icon from '@components/Icon';
+import ScreenWrapper from '@components/ScreenWrapper';
+import ScrollView from '@components/ScrollView';
+import Text from '@components/Text';
+import TextInput from '@components/TextInput';
+import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import useOnyx from '@hooks/useOnyx';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {createVirtualEmployee, updateVirtualEmployee} from '@libs/actions/VirtualEmployee';
+import Navigation from '@libs/Navigation/Navigation';
+import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
+import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types';
+import type {TranslationPaths} from '@src/languages/types';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type SCREENS from '@src/SCREENS';
+import type {VirtualEmployee, VirtualEmployeeCapability, VirtualEmployeeEventSubscription} from '@src/types/onyx/VirtualEmployee';
+import ToggleSettingOptionRow from '../workflows/ToggleSettingsOptionRow';
+
+type WorkspaceVirtualEmployeePageProps = PlatformStackScreenProps;
+
+type CapabilityConfig = {
+ key: VirtualEmployeeCapability;
+ illustrationKey:
+ | 'MagnifyingGlassMoney'
+ | 'Binoculars'
+ | 'Pencil'
+ | 'CommentBubbles'
+ | 'Approval'
+ | 'Alert'
+ | 'CheckmarkCircle'
+ | 'Gears';
+ labelKey: TranslationPaths;
+ descriptionKey: TranslationPaths;
+};
+
+type EventConfig = {
+ key: VirtualEmployeeEventSubscription;
+ illustrationKey: 'FastMoney' | 'Pencil' | 'EnvelopeReceipt' | 'Hourglass' | 'ThumbsUpStars' | 'ConciergeBubble' | 'ChatBubbles';
+ labelKey: TranslationPaths;
+ descriptionKey: TranslationPaths;
+};
+
+const ALL_CAPABILITIES: CapabilityConfig[] = [
+ {
+ key: 'can_read_transactions',
+ illustrationKey: 'MagnifyingGlassMoney',
+ labelKey: 'workspace.virtualEmployees.capabilities.readTransactions',
+ descriptionKey: 'workspace.virtualEmployees.capabilities.readTransactionsDescription',
+ },
+ {
+ key: 'can_read_reports',
+ illustrationKey: 'Binoculars',
+ labelKey: 'workspace.virtualEmployees.capabilities.readReports',
+ descriptionKey: 'workspace.virtualEmployees.capabilities.readReportsDescription',
+ },
+ {
+ key: 'can_edit_transactions',
+ illustrationKey: 'Pencil',
+ labelKey: 'workspace.virtualEmployees.capabilities.editTransactions',
+ descriptionKey: 'workspace.virtualEmployees.capabilities.editTransactionsDescription',
+ },
+ {
+ key: 'can_send_messages',
+ illustrationKey: 'CommentBubbles',
+ labelKey: 'workspace.virtualEmployees.capabilities.sendMessages',
+ descriptionKey: 'workspace.virtualEmployees.capabilities.sendMessagesDescription',
+ },
+ {
+ key: 'can_approve_reports',
+ illustrationKey: 'Approval',
+ labelKey: 'workspace.virtualEmployees.capabilities.approveReports',
+ descriptionKey: 'workspace.virtualEmployees.capabilities.approveReportsDescription',
+ },
+ {
+ key: 'can_reject_reports',
+ illustrationKey: 'Alert',
+ labelKey: 'workspace.virtualEmployees.capabilities.rejectReports',
+ descriptionKey: 'workspace.virtualEmployees.capabilities.rejectReportsDescription',
+ },
+ {
+ key: 'can_dismiss_violations',
+ illustrationKey: 'CheckmarkCircle',
+ labelKey: 'workspace.virtualEmployees.capabilities.dismissViolations',
+ descriptionKey: 'workspace.virtualEmployees.capabilities.dismissViolationsDescription',
+ },
+ {
+ key: 'can_read_policy',
+ illustrationKey: 'Gears',
+ labelKey: 'workspace.virtualEmployees.capabilities.readPolicy',
+ descriptionKey: 'workspace.virtualEmployees.capabilities.readPolicyDescription',
+ },
+];
+
+const ALL_EVENTS: EventConfig[] = [
+ {
+ key: 'transaction.created',
+ illustrationKey: 'FastMoney',
+ labelKey: 'workspace.virtualEmployees.events.transactionCreated',
+ descriptionKey: 'workspace.virtualEmployees.events.transactionCreatedDescription',
+ },
+ {
+ key: 'transaction.modified',
+ illustrationKey: 'Pencil',
+ labelKey: 'workspace.virtualEmployees.events.transactionModified',
+ descriptionKey: 'workspace.virtualEmployees.events.transactionModifiedDescription',
+ },
+ {
+ key: 'transaction.receipt_scanned',
+ illustrationKey: 'EnvelopeReceipt',
+ labelKey: 'workspace.virtualEmployees.events.receiptScanned',
+ descriptionKey: 'workspace.virtualEmployees.events.receiptScannedDescription',
+ },
+ {
+ key: 'report.submitted',
+ illustrationKey: 'Hourglass',
+ labelKey: 'workspace.virtualEmployees.events.reportSubmitted',
+ descriptionKey: 'workspace.virtualEmployees.events.reportSubmittedDescription',
+ },
+ {
+ key: 'report.approved',
+ illustrationKey: 'ThumbsUpStars',
+ labelKey: 'workspace.virtualEmployees.events.reportApproved',
+ descriptionKey: 'workspace.virtualEmployees.events.reportApprovedDescription',
+ },
+ {
+ key: 'chat.mention',
+ illustrationKey: 'ConciergeBubble',
+ labelKey: 'workspace.virtualEmployees.events.chatMention',
+ descriptionKey: 'workspace.virtualEmployees.events.chatMentionDescription',
+ },
+ {
+ key: 'chat.message',
+ illustrationKey: 'ChatBubbles',
+ labelKey: 'workspace.virtualEmployees.events.chatMessage',
+ descriptionKey: 'workspace.virtualEmployees.events.chatMessageDescription',
+ },
+];
+
+const MIN_SYSTEM_PROMPT_LENGTH = 20;
+
+function WorkspaceVirtualEmployeePage({route}: WorkspaceVirtualEmployeePageProps) {
+ const {policyID, virtualEmployeeID} = route.params;
+ const isNew = virtualEmployeeID === 'new';
+ const styles = useThemeStyles();
+ const theme = useTheme();
+ const {translate} = useLocalize();
+ const {isOffline} = useNetwork();
+ const {shouldUseNarrowLayout} = useResponsiveLayout();
+
+ const illustrations = useMemoizedLazyIllustrations([
+ 'ConciergeBot',
+ 'MagnifyingGlassMoney',
+ 'Binoculars',
+ 'Pencil',
+ 'CommentBubbles',
+ 'Approval',
+ 'Alert',
+ 'CheckmarkCircle',
+ 'Gears',
+ 'FastMoney',
+ 'EnvelopeReceipt',
+ 'Hourglass',
+ 'ThumbsUpStars',
+ 'ConciergeBubble',
+ 'ChatBubbles',
+ ] as const);
+
+ const [virtualEmployeesCollection] = useOnyx(`${ONYXKEYS.COLLECTION.VIRTUAL_EMPLOYEES}${policyID}`);
+
+ const existingVE: VirtualEmployee | undefined = useMemo(() => {
+ if (isNew || !virtualEmployeesCollection) {
+ return undefined;
+ }
+ return Object.values(virtualEmployeesCollection).find((ve): ve is VirtualEmployee => !!ve && ve.id === virtualEmployeeID);
+ }, [isNew, virtualEmployeesCollection, virtualEmployeeID]);
+
+ const [displayName, setDisplayName] = useState(existingVE?.displayName ?? '');
+ const [systemPrompt, setSystemPrompt] = useState(existingVE?.systemPrompt ?? '');
+ const [capabilities, setCapabilities] = useState(existingVE?.capabilities ?? []);
+ const [eventSubs, setEventSubs] = useState(existingVE?.eventSubs ?? []);
+ const [displayNameError, setDisplayNameError] = useState('');
+ const [systemPromptError, setSystemPromptError] = useState('');
+
+ const hasHydratedFromOnyx = useRef(!isNew && !existingVE);
+ useEffect(() => {
+ if (!hasHydratedFromOnyx.current || !existingVE) {
+ return;
+ }
+ hasHydratedFromOnyx.current = false;
+ setDisplayName(existingVE.displayName);
+ setSystemPrompt(existingVE.systemPrompt);
+ setCapabilities(existingVE.capabilities);
+ setEventSubs(existingVE.eventSubs);
+ }, [existingVE]);
+
+ const toggleCapability = useCallback((cap: VirtualEmployeeCapability) => {
+ setCapabilities((prev) => (prev.includes(cap) ? prev.filter((c) => c !== cap) : [...prev, cap]));
+ }, []);
+
+ const toggleEvent = useCallback((event: VirtualEmployeeEventSubscription) => {
+ setEventSubs((prev) => (prev.includes(event) ? prev.filter((e) => e !== event) : [...prev, event]));
+ }, []);
+
+ const validate = useCallback((): boolean => {
+ let isValid = true;
+ if (!displayName.trim()) {
+ setDisplayNameError(translate('workspace.virtualEmployees.errors.displayNameRequired'));
+ isValid = false;
+ } else {
+ setDisplayNameError('');
+ }
+ if (systemPrompt.trim().length < MIN_SYSTEM_PROMPT_LENGTH) {
+ setSystemPromptError(translate('workspace.virtualEmployees.errors.systemPromptMinLength', {minLength: MIN_SYSTEM_PROMPT_LENGTH}));
+ isValid = false;
+ } else {
+ setSystemPromptError('');
+ }
+ return isValid;
+ }, [displayName, systemPrompt, translate]);
+
+ const handleSave = useCallback(() => {
+ if (!validate()) {
+ return;
+ }
+ if (isNew) {
+ createVirtualEmployee(policyID, displayName.trim(), systemPrompt.trim(), capabilities, eventSubs);
+ } else if (existingVE) {
+ updateVirtualEmployee(policyID, virtualEmployeeID, existingVE.accountID, {
+ displayName: displayName.trim(),
+ systemPrompt: systemPrompt.trim(),
+ capabilities,
+ eventSubs,
+ });
+ }
+ Navigation.goBack();
+ }, [validate, isNew, policyID, displayName, systemPrompt, capabilities, eventSubs, virtualEmployeeID, existingVE]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {translate('workspace.virtualEmployees.systemPromptLabel').toUpperCase()}
+
+
+
+ {!!systemPromptError && (
+
+ {systemPromptError}
+
+ )}
+
+ {translate('workspace.virtualEmployees.systemPromptHint')}
+
+
+
+ {!shouldUseNarrowLayout && (
+
+
+
+
+
+
+ {displayName || translate('workspace.virtualEmployees.unnamedEmployee')}
+
+
+ {translate('workspace.virtualEmployees.virtualEmployeeLabel')}
+
+
+
+
+ {systemPrompt
+ ? `\u201C${systemPrompt.substring(0, 200)}${systemPrompt.length > 200 ? '\u2026' : ''}\u201D`
+ : translate('workspace.virtualEmployees.previewPlaceholder')}
+
+
+
+
+
+ 0 ? theme.success : theme.icon,
+ marginRight: 6,
+ }}
+ />
+
+ {translate('workspace.virtualEmployees.capabilityCount', {count: capabilities.length})}
+
+
+
+ 0 ? theme.success : theme.icon,
+ marginRight: 6,
+ }}
+ />
+
+ {translate('workspace.virtualEmployees.triggerCount', {count: eventSubs.length})}
+
+
+
+
+
+ )}
+
+
+
+
+
+
+ {translate('workspace.virtualEmployees.capabilitiesSection')}
+
+
+ {translate('workspace.virtualEmployees.capabilitiesSectionHint')}
+
+
+ {ALL_CAPABILITIES.map(({key, illustrationKey, labelKey, descriptionKey}) => (
+ toggleCapability(key)}
+ wrapperStyle={[styles.ph5, styles.pv3]}
+ shouldPlaceSubtitleBelowSwitch
+ />
+ ))}
+
+
+
+
+
+ {translate('workspace.virtualEmployees.eventsSection')}
+
+
+ {translate('workspace.virtualEmployees.eventsSectionHint')}
+
+
+ {ALL_EVENTS.map(({key, illustrationKey, labelKey, descriptionKey}) => (
+ toggleEvent(key)}
+ wrapperStyle={[styles.ph5, styles.pv3]}
+ shouldPlaceSubtitleBelowSwitch
+ />
+ ))}
+
+
+
+
+
+
+
+
+ );
+}
+
+WorkspaceVirtualEmployeePage.displayName = 'WorkspaceVirtualEmployeePage';
+
+export default WorkspaceVirtualEmployeePage;
diff --git a/src/pages/workspace/virtualEmployees/WorkspaceVirtualEmployeesPage.tsx b/src/pages/workspace/virtualEmployees/WorkspaceVirtualEmployeesPage.tsx
new file mode 100644
index 0000000000000..6c7b34e2a939b
--- /dev/null
+++ b/src/pages/workspace/virtualEmployees/WorkspaceVirtualEmployeesPage.tsx
@@ -0,0 +1,166 @@
+import {useFocusEffect} from '@react-navigation/native';
+import React, {useCallback, useMemo} from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
+import EmptyStateComponent from '@components/EmptyStateComponent';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import MenuItem from '@components/MenuItem';
+import OfflineWithFeedback from '@components/OfflineWithFeedback';
+import ScreenWrapper from '@components/ScreenWrapper';
+import ScrollView from '@components/ScrollView';
+import Text from '@components/Text';
+import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import useOnyx from '@hooks/useOnyx';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {openWorkspaceVirtualEmployeesPage} from '@libs/actions/VirtualEmployee';
+import Navigation from '@libs/Navigation/Navigation';
+import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
+import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types';
+import {isPolicyAdmin as isPolicyAdminUtils} from '@libs/PolicyUtils';
+import {getDefaultAvatarURL} from '@libs/UserAvatarUtils';
+import colors from '@styles/theme/colors';
+import variables from '@styles/variables';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import type SCREENS from '@src/SCREENS';
+import type {VirtualEmployee} from '@src/types/onyx/VirtualEmployee';
+
+type WorkspaceVirtualEmployeesPageProps = PlatformStackScreenProps;
+
+function WorkspaceVirtualEmployeesPage({route}: WorkspaceVirtualEmployeesPageProps) {
+ const policyID = route.params.policyID;
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+ const {translate} = useLocalize();
+ const {isOffline} = useNetwork();
+
+ const illustrations = useMemoizedLazyIllustrations(['ConciergeBot'] as const);
+
+ const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`);
+ const [virtualEmployeesCollection] = useOnyx(`${ONYXKEYS.COLLECTION.VIRTUAL_EMPLOYEES}${policyID}`);
+
+ const isPolicyAdmin = isPolicyAdminUtils(policy);
+
+ useFocusEffect(
+ useCallback(() => {
+ openWorkspaceVirtualEmployeesPage(policyID);
+ }, [policyID]),
+ );
+
+ const virtualEmployees = useMemo(() => {
+ if (!virtualEmployeesCollection) {
+ return [];
+ }
+ return Object.values(virtualEmployeesCollection).filter((ve): ve is VirtualEmployee => !!ve && ve.status !== 'deleted');
+ }, [virtualEmployeesCollection]);
+
+ const navigateToCreate = useCallback(() => {
+ Navigation.navigate(ROUTES.WORKSPACE_VIRTUAL_EMPLOYEES_EDIT.getRoute(policyID, 'new'));
+ }, [policyID]);
+
+ const navigateToEdit = useCallback(
+ (virtualEmployeeID: string) => {
+ Navigation.navigate(ROUTES.WORKSPACE_VIRTUAL_EMPLOYEES_EDIT.getRoute(policyID, virtualEmployeeID));
+ },
+ [policyID],
+ );
+
+ const getCapabilitySummary = useCallback(
+ (ve: VirtualEmployee) => {
+ const count = ve.capabilities?.length ?? 0;
+ if (count === 0) {
+ return translate('workspace.virtualEmployees.noCapabilities');
+ }
+ return translate('workspace.virtualEmployees.capabilityCount', {count});
+ },
+ [translate],
+ );
+
+ if (!isPolicyAdmin) {
+ return (
+
+
+
+ {translate('workspace.common.notAuthorized')}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {virtualEmployees.length === 0 ? (
+
+ ) : (
+
+
+ {virtualEmployees.map((ve) => (
+
+
+ ))}
+
+
+ )}
+
+ );
+}
+
+WorkspaceVirtualEmployeesPage.displayName = 'WorkspaceVirtualEmployeesPage';
+
+export default WorkspaceVirtualEmployeesPage;
diff --git a/src/types/onyx/VirtualEmployee.ts b/src/types/onyx/VirtualEmployee.ts
new file mode 100644
index 0000000000000..ab3bee5f701b4
--- /dev/null
+++ b/src/types/onyx/VirtualEmployee.ts
@@ -0,0 +1,35 @@
+type VirtualEmployeeCapability =
+ | 'can_read_transactions'
+ | 'can_edit_transactions'
+ | 'can_send_messages'
+ | 'can_approve_reports'
+ | 'can_reject_reports'
+ | 'can_dismiss_violations'
+ | 'can_read_policy'
+ | 'can_read_reports';
+
+type VirtualEmployeeEventSubscription =
+ | 'transaction.created'
+ | 'transaction.modified'
+ | 'transaction.receipt_scanned'
+ | 'report.submitted'
+ | 'report.approved'
+ | 'chat.mention'
+ | 'chat.message';
+
+type VirtualEmployee = {
+ id: string;
+ policyID: string;
+ accountID: number;
+ email: string;
+ displayName: string;
+ systemPrompt: string;
+ capabilities: VirtualEmployeeCapability[];
+ eventSubs: VirtualEmployeeEventSubscription[];
+ status: 'active' | 'paused' | 'deleted';
+ createdBy: number;
+ pendingAction?: 'add' | 'update' | 'delete';
+ errors?: Record;
+};
+
+export type {VirtualEmployee, VirtualEmployeeCapability, VirtualEmployeeEventSubscription};
diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts
index f86ee284b3679..d86ead9184d51 100644
--- a/src/types/onyx/index.ts
+++ b/src/types/onyx/index.ts
@@ -165,6 +165,7 @@ import type VacationDelegate from './VacationDelegate';
import type ValidateDomainTwoFactorCode from './ValidateDomainTwoFactorCode';
import type ValidateMagicCodeAction from './ValidateMagicCodeAction';
import type ValidateUserAndGetAccessiblePolicies from './ValidateUserAndGetAccessiblePolicies';
+import type {VirtualEmployee, VirtualEmployeeCapability, VirtualEmployeeEventSubscription} from './VirtualEmployee';
import type WalletAdditionalDetails from './WalletAdditionalDetails';
import type {WalletAdditionalQuestionDetails} from './WalletAdditionalDetails';
import type WalletOnfido from './WalletOnfido';
@@ -367,4 +368,7 @@ export type {
CodingRuleMatchingTransaction,
UserSecurityGroupData,
DeviceBiometrics,
+ VirtualEmployee,
+ VirtualEmployeeCapability,
+ VirtualEmployeeEventSubscription,
};