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 + /> + ))} + + + + +