diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 1a06b60f8760..28f63beae126 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1676,6 +1676,9 @@ const CONST = { ADD_EMPLOYEE: 'POLICYCHANGELOG_ADD_EMPLOYEE', ADD_CARD_FEED: 'POLICYCHANGELOG_ADD_CARD_FEED', ADD_EXPENSIFY_CARD_RULE: 'POLICYCHANGELOG_ADD_EXPENSIFY_CARD_RULE', + ADD_AGENT_RULE: 'POLICYCHANGELOG_ADD_AGENT_RULE', + UPDATE_AGENT_RULE: 'POLICYCHANGELOG_UPDATE_AGENT_RULE', + DELETE_AGENT_RULE: 'POLICYCHANGELOG_DELETE_AGENT_RULE', ADD_INTEGRATION: 'POLICYCHANGELOG_ADD_INTEGRATION', ADD_REPORT_FIELD: 'POLICYCHANGELOG_ADD_REPORT_FIELD', ADD_TAG: 'POLICYCHANGELOG_ADD_TAG', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 8c8938f25e61..c5a42a9d0f36 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -863,6 +863,8 @@ const ONYXKEYS = { // object should mirror the data as it's stored in the database. POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED: 'policyHasConnectionsDataBeenFetched_', POLICY_CONNECTION_SYNC_PROGRESS: 'policyConnectionSyncProgress_', + /** Number of submitted but not yet approved expenses on a policy, used to offer applying a new/edited agent rule to them */ + POLICY_UNAPPROVED_EXPENSE_COUNT: 'policyUnapprovedExpenseCount_', POLICY_MERGE_HR_INITIAL_SYNC_MODAL_SHOWN: 'policyMergeHRInitialSyncModalShown_', WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_', WORKSPACE_INVITE_MESSAGE_DRAFT: 'workspaceInviteMessageDraft_', @@ -1448,6 +1450,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_CATEGORIES]: OnyxTypes.RecentlyUsedCategories; [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_DESTINATIONS]: OnyxTypes.RecentlyUsedCategories; [ONYXKEYS.COLLECTION.POLICY_HAS_CONNECTIONS_DATA_BEEN_FETCHED]: boolean; + [ONYXKEYS.COLLECTION.POLICY_UNAPPROVED_EXPENSE_COUNT]: number; [ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST]: OnyxTypes.PolicyEmployeeList; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: OnyxTypes.InvitedEmailsToAccountIDs; [ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MESSAGE_DRAFT]: string; diff --git a/src/hooks/useAgentRuleApplyConfirmation.tsx b/src/hooks/useAgentRuleApplyConfirmation.tsx new file mode 100644 index 000000000000..e12432672934 --- /dev/null +++ b/src/hooks/useAgentRuleApplyConfirmation.tsx @@ -0,0 +1,98 @@ +import CheckboxWithLabel from '@components/CheckboxWithLabel'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import Text from '@components/Text'; + +import {getPolicyUnapprovedExpenseCount} from '@userActions/Policy/Rules'; + +import ONYXKEYS from '@src/ONYXKEYS'; + +import React, {useEffect, useState} from 'react'; +import {View} from 'react-native'; + +import useConfirmModal from './useConfirmModal'; +import useLocalize from './useLocalize'; +import useNetwork from './useNetwork'; +import useOnyx from './useOnyx'; +import useThemeStyles from './useThemeStyles'; + +type UseAgentRuleApplyConfirmationResult = { + /** + * Runs the save confirmation flow for an agent rule. When the policy has expenses awaiting + * approval, a confirmation modal offers to apply the rule to them; otherwise `onConfirm` is called + * right away. `onConfirm` receives whether the rule should be applied to those existing expenses, + * and is not called when the modal is cancelled. + */ + confirmAgentRuleSave: (onConfirm: (applyToExistingExpenses: boolean) => void) => void; +}; + +function ConfirmationPrompt({count, checkboxRef}: {count: number; checkboxRef: React.RefObject}) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const [isChecked, setIsChecked] = useState(false); + + return ( + + {translate('workspace.rules.agentRules.saveConfirmation.prompt')} + { + const checked = !!value; + setIsChecked(checked); + // eslint-disable-next-line no-param-reassign + checkboxRef.current = checked; + }} + /> + + ); +} + +/** + * Confirmation flow shown before saving a new or edited agent rule, offering to apply the rule to the + * policy's submitted but not yet approved expenses. Fetches the unapproved expense count when the + * page mounts. + */ +export default function useAgentRuleApplyConfirmation(policyID: string): UseAgentRuleApplyConfirmationResult { + const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + const {isOffline} = useNetwork(); + const [unapprovedExpenseCount] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_UNAPPROVED_EXPENSE_COUNT}${policyID}`); + + useEffect(() => { + if (isOffline) { + return; + } + getPolicyUnapprovedExpenseCount(policyID); + }, [policyID, isOffline]); + + const confirmAgentRuleSave = (onConfirm: (applyToExistingExpenses: boolean) => void) => { + if (!unapprovedExpenseCount) { + onConfirm(false); + return; + } + + const checkboxRef = {current: false}; + + showConfirmModal({ + title: translate('workspace.rules.agentRules.saveConfirmation.title'), + confirmText: translate('common.save'), + cancelText: translate('common.cancel'), + prompt: ( + + ), + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + onConfirm(checkboxRef.current); + }); + }; + + return { + confirmAgentRuleSave, + }; +} diff --git a/src/languages/de.ts b/src/languages/de.ts index cab92563ad8b..372c682d921f 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7881,6 +7881,11 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc describeRuleForConcierge: 'Beschreiben Sie Ihre Regel und Concierge erstellt sie', gotIt: 'Verstanden', createRule: 'Regel erstellen', + saveConfirmation: { + title: 'Regel speichern', + prompt: 'RuleBot wendet diese Regel auf neue Ausgaben an.', + applyToUnapprovedExpenses: ({count}: {count: number}) => `Diese Regel auf ${count} nicht genehmigte ${count === 1 ? 'Ausgabe' : 'Ausgaben'} anwenden`, + }, }, tabs: { general: 'Allgemein', @@ -8680,6 +8685,12 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc updatedMccGroupCategory: ({mccGroupName, oldCategory, newCategory}: {mccGroupName: string; oldCategory: string; newCategory: string}) => `hat die Standardausgabenkategorie für „${mccGroupName}“ in „${newCategory}“ geändert (zuvor „${oldCategory}“)`, updatedRequireCompanyCards: ({enabled}: {enabled: boolean}) => `${enabled ? 'aktiviert' : 'deaktiviert'} die Anforderung für Firmenkartenkäufe`, + agentRule: { + added: ({title, prompt}: {title: string; prompt: string}) => (title ? `hat die Agentenregel „${title}“ hinzugefügt: ${prompt}` : `hat eine Agentenregel hinzugefügt: ${prompt}`), + updated: ({title, prompt}: {title: string; prompt: string}) => + title ? `hat die Agentenregel „${title}“ aktualisiert zu: ${prompt}` : `eine Agentenregel aktualisiert zu: ${prompt}`, + deleted: ({title}: {title: string}) => (title ? `hat die Agentenregel „${title}“ entfernt` : 'hat eine Agentenregel entfernt'), + }, expensifyCardRule: { actionVerb: {block: 'blockiert', allow: 'erlaubt'}, amountOperator: {over: 'über', under: 'unter'}, diff --git a/src/languages/en.ts b/src/languages/en.ts index eecee050d7b8..ec749374c9b0 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8202,6 +8202,11 @@ const translations = { emptySuggestionsTitle: 'No suggestions available', emptySuggestionsSubtitle: 'Try writing your own rule.', gotIt: 'Got it', + saveConfirmation: { + title: 'Save rule', + prompt: 'RuleBot will apply this rule to new expenses.', + applyToUnapprovedExpenses: ({count}: {count: number}) => `Apply this rule to ${count} unapproved expense${count === 1 ? '' : 's'}`, + }, }, }, planTypePage: { @@ -8467,6 +8472,11 @@ const translations = { updatedCardFeedLiability: (feedName: string, enabled: boolean) => `${enabled ? 'enabled' : 'disabled'} cardholders to delete card transactions for card feed "${feedName}"`, updatedCardFeedStatementPeriod: (feedName: string, newValue?: string, previousValue?: string) => `changed card feed "${feedName}" statement period end day${newValue ? ` to "${newValue}"` : ''}${previousValue ? ` (previously "${previousValue}")` : ''}`, + agentRule: { + added: ({title, prompt}: {title: string; prompt: string}) => (title ? `added the agent rule "${title}": ${prompt}` : `added an agent rule: ${prompt}`), + updated: ({title, prompt}: {title: string; prompt: string}) => (title ? `updated the agent rule "${title}" to: ${prompt}` : `updated an agent rule to: ${prompt}`), + deleted: ({title}: {title: string}) => (title ? `removed the agent rule "${title}"` : 'removed an agent rule'), + }, expensifyCardRule: { actionVerb: { block: 'blocked', diff --git a/src/languages/es.ts b/src/languages/es.ts index 5992572af3c6..84deb8879820 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7784,6 +7784,11 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, findSuggestion: 'Buscar una regla', emptySuggestionsTitle: 'No hay sugerencias disponibles', emptySuggestionsSubtitle: 'Prueba a escribir tu propia regla.', + saveConfirmation: { + title: 'Guardar regla', + prompt: 'RuleBot aplicará esta regla a los nuevos gastos.', + applyToUnapprovedExpenses: ({count}: {count: number}) => `Aplicar esta regla a ${count} ${count === 1 ? 'gasto sin aprobar' : 'gastos sin aprobar'}`, + }, revampSubtitle: 'Describe reglas flexibles que se ejecutan cuando las necesitas.', describeRuleForConcierge: 'Describe tu regla y Concierge la creará', gotIt: 'Entendido', @@ -8160,6 +8165,11 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, `${enabled ? 'habilitó' : 'deshabilitó'} que los titulares de tarjetas eliminen transacciones de la fuente de tarjetas "${feedName}"`, updatedCardFeedStatementPeriod: (feedName: string, newValue?: string, previousValue?: string) => `cambió el día de cierre del período de estado de cuenta de la fuente de tarjetas "${feedName}"${newValue ? ` a "${newValue}"` : ''}${previousValue ? ` (previamente "${previousValue}")` : ''}`, + agentRule: { + added: ({title, prompt}: {title: string; prompt: string}) => (title ? `añadió la regla de agente «${title}»: ${prompt}` : `añadió una regla de agente: ${prompt}`), + updated: ({title, prompt}: {title: string; prompt: string}) => (title ? `actualizó la regla del agente «${title}» a: ${prompt}` : `actualizó una regla de agente a: ${prompt}`), + deleted: ({title}: {title: string}) => (title ? `eliminó la regla de agente «${title}»` : 'eliminó una regla de agente'), + }, expensifyCardRule: { actionVerb: {block: 'bloqueado', allow: 'permitido'}, amountOperator: {over: 'más de', under: 'debajo'}, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 1755efe6fea1..36f912e15c9f 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7905,6 +7905,11 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e findSuggestion: 'Trouver une règle', emptySuggestionsTitle: 'Aucune suggestion disponible', emptySuggestionsSubtitle: 'Essayez d’écrire votre propre règle.', + saveConfirmation: { + title: 'Enregistrer la règle', + prompt: 'RuleBot appliquera cette règle aux nouvelles dépenses.', + applyToUnapprovedExpenses: ({count}: {count: number}) => `Appliquer cette règle à ${count} ${count === 1 ? 'dépense non approuvée' : 'dépenses non approuvées'}`, + }, revampSubtitle: 'Décrivez des règles flexibles qui s’exécutent quand vous en avez besoin.', describeRuleForConcierge: 'Décrivez votre règle et Concierge la créera', gotIt: 'Compris', @@ -8714,6 +8719,12 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e updatedMccGroupCategory: ({mccGroupName, oldCategory, newCategory}: {mccGroupName: string; oldCategory: string; newCategory: string}) => `a modifié la catégorie de dépense par défaut pour « ${mccGroupName} » en « ${newCategory} » (auparavant « ${oldCategory} »)`, updatedRequireCompanyCards: ({enabled}: {enabled: boolean}) => `${enabled ? 'activé' : 'désactivé'} l’exigence d’achats par carte d’entreprise`, + agentRule: { + added: ({title, prompt}: {title: string; prompt: string}) => (title ? `a ajouté la règle d’agent « ${title} » : ${prompt}` : `a ajouté une règle d’agent : ${prompt}`), + updated: ({title, prompt}: {title: string; prompt: string}) => + title ? `a mis à jour la règle d’agent « ${title} » en : ${prompt}` : `a mis à jour une règle d’agent en : ${prompt}`, + deleted: ({title}: {title: string}) => (title ? `a supprimé la règle d’agent « ${title} »` : 'a supprimé une règle d’agent'), + }, expensifyCardRule: { actionVerb: {block: 'bloqué', allow: 'autorisé'}, amountOperator: {over: 'terminé', under: 'sous'}, diff --git a/src/languages/it.ts b/src/languages/it.ts index 3652b83eecbd..4bcfa5853ddb 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7850,6 +7850,11 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, findSuggestion: 'Trova una regola', emptySuggestionsTitle: 'Nessun suggerimento disponibile', emptySuggestionsSubtitle: 'Prova a scrivere la tua regola.', + saveConfirmation: { + title: 'Salva regola', + prompt: 'RuleBot applicherà questa regola alle nuove spese.', + applyToUnapprovedExpenses: ({count}: {count: number}) => `Applica questa regola a ${count} ${count === 1 ? 'spesa non approvata' : 'spese non approvate'}`, + }, revampSubtitle: 'Descrivi regole flessibili che vengono eseguite quando ne hai bisogno.', describeRuleForConcierge: 'Descrivi la tua regola e Concierge la creerà', gotIt: 'Capito', @@ -8653,6 +8658,12 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, updatedMccGroupCategory: ({mccGroupName, oldCategory, newCategory}: {mccGroupName: string; oldCategory: string; newCategory: string}) => `ha modificato la categoria di spesa predefinita per "${mccGroupName}" in "${newCategory}" (precedentemente "${oldCategory}")`, updatedRequireCompanyCards: ({enabled}: {enabled: boolean}) => `${enabled ? 'abilitato' : 'disabilitato'} il requisito per gli acquisti con carta aziendale`, + agentRule: { + added: ({title, prompt}: {title: string; prompt: string}) => (title ? `ha aggiunto la regola dell’agente "${title}": ${prompt}` : `ha aggiunto una regola agente: ${prompt}`), + updated: ({title, prompt}: {title: string; prompt: string}) => + title ? `ha aggiornato la regola dell’agente "${title}" in: ${prompt}` : `ha aggiornato una regola dell’agente in: ${prompt}`, + deleted: ({title}: {title: string}) => (title ? `ha rimosso la regola agente "${title}"` : 'ha rimosso una regola agente'), + }, expensifyCardRule: { actionVerb: {block: 'bloccato', allow: 'consentito'}, amountOperator: {over: 'terminato', under: 'sotto'}, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index a63f25df15b8..a8e0f333a204 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7754,6 +7754,11 @@ ${reportName}`, findSuggestion: 'ルールを探す', emptySuggestionsTitle: '利用可能な提案はありません', emptySuggestionsSubtitle: '独自のルールを作成してみてください。', + saveConfirmation: { + title: 'ルールを保存', + prompt: 'RuleBotは新しい経費にこのルールを適用します。', + applyToUnapprovedExpenses: ({count}: {count: number}) => `このルールを${count}件の未承認の経費に適用する`, + }, revampSubtitle: '必要なときに実行できる柔軟なルールを設定します。', describeRuleForConcierge: 'ルールの内容を入力すると、Concierge が自動作成します', gotIt: '了解しました', @@ -8542,6 +8547,12 @@ ${reportName}`, updatedMccGroupCategory: ({mccGroupName, oldCategory, newCategory}: {mccGroupName: string; oldCategory: string; newCategory: string}) => `「${mccGroupName}」のデフォルト支出カテゴリーを「${newCategory}」に変更しました(以前は「${oldCategory}」)`, updatedRequireCompanyCards: ({enabled}: {enabled: boolean}) => `${enabled ? '有効' : '無効'} の法人カード購入要件`, + agentRule: { + added: ({title, prompt}: {title: string; prompt: string}) => (title ? `エージェントルール「${title}」を追加しました:${prompt}` : `エージェントルールを追加しました:${prompt}`), + updated: ({title, prompt}: {title: string; prompt: string}) => + title ? `エージェントルール「${title}」を次の内容に更新しました:${prompt}` : `エージェントルールを次の内容に更新しました:${prompt}`, + deleted: ({title}: {title: string}) => (title ? `エージェントルール「${title}」を削除しました` : 'エージェントルールを削除しました'), + }, expensifyCardRule: { actionVerb: {block: 'ブロック済み', allow: '許可済み'}, amountOperator: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index cb9feaa8d504..498c4fa3eedf 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7831,6 +7831,11 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, findSuggestion: 'Zoek een regel', emptySuggestionsTitle: 'Geen suggesties beschikbaar', emptySuggestionsSubtitle: 'Probeer je eigen regel te schrijven.', + saveConfirmation: { + title: 'Regel opslaan', + prompt: 'RuleBot past deze regel toe op nieuwe uitgaven.', + applyToUnapprovedExpenses: ({count}: {count: number}) => `Pas deze regel toe op ${count} niet-goedgekeurde ${count === 1 ? 'uitgave' : 'uitgaven'}`, + }, revampSubtitle: 'Beschrijf flexibele regels die worden uitgevoerd wanneer jij dat nodig hebt.', describeRuleForConcierge: 'Beschrijf je regel en Concierge bouwt hem voor je', gotIt: 'Begrepen', @@ -8627,6 +8632,12 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, updatedMccGroupCategory: ({mccGroupName, oldCategory, newCategory}: {mccGroupName: string; oldCategory: string; newCategory: string}) => `heeft de standaarduitgavencategorie voor ‘${mccGroupName}’ gewijzigd in ‘${newCategory}’ (voorheen ‘${oldCategory}’)`, updatedRequireCompanyCards: ({enabled}: {enabled: boolean}) => `vereiste ${enabled ? 'ingeschakeld' : 'uitgeschakeld'} voor bedrijfskaarttransacties`, + agentRule: { + added: ({title, prompt}: {title: string; prompt: string}) => (title ? `heeft de agentregel "${title}" toegevoegd: ${prompt}` : `heeft een agentregel toegevoegd: ${prompt}`), + updated: ({title, prompt}: {title: string; prompt: string}) => + title ? `heeft de agentregel „${title}” bijgewerkt naar: ${prompt}` : `heeft een agentregel bijgewerkt naar: ${prompt}`, + deleted: ({title}: {title: string}) => (title ? `heeft de agentregel ‘${title}’ verwijderd` : 'heeft een agentregel verwijderd'), + }, expensifyCardRule: { actionVerb: {block: 'geblokkeerd', allow: 'toegestaan'}, amountOperator: {over: 'over', under: 'onder'}, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 0b8c4b143156..27e599fcb5cd 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7811,6 +7811,11 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, findSuggestion: 'Znajdź regułę', emptySuggestionsTitle: 'Brak dostępnych propozycji', emptySuggestionsSubtitle: 'Spróbuj napisać własną regułę.', + saveConfirmation: { + title: 'Zapisz regułę', + prompt: 'RuleBot zastosuje tę regułę do nowych wydatków.', + applyToUnapprovedExpenses: ({count}: {count: number}) => `Zastosuj tę regułę do ${count} ${count === 1 ? 'niezatwierdzonego wydatku' : 'niezatwierdzonych wydatków'}`, + }, revampSubtitle: 'Opisuj elastyczne reguły, które uruchamiają się wtedy, kiedy tego potrzebujesz.', describeRuleForConcierge: 'Opisz swoją regułę, a Concierge ją utworzy', gotIt: 'Jasne', @@ -8607,6 +8612,11 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, updatedMccGroupCategory: ({mccGroupName, oldCategory, newCategory}: {mccGroupName: string; oldCategory: string; newCategory: string}) => `zmieniono domyślną kategorię wydatków dla „${mccGroupName}” na „${newCategory}” (poprzednio „${oldCategory}”)`, updatedRequireCompanyCards: ({enabled}: {enabled: boolean}) => `${enabled ? 'włączone' : 'wyłączone'} wymóg dotyczący zakupów kartą służbową`, + agentRule: { + added: ({title, prompt}: {title: string; prompt: string}) => (title ? `dodał(a) regułę agenta „${title}”: ${prompt}` : `dodano regułę agenta: ${prompt}`), + updated: ({title, prompt}: {title: string; prompt: string}) => (title ? `zaktualizowano regułę agenta „${title}” na: ${prompt}` : `zaktualizowano regułę agenta na: ${prompt}`), + deleted: ({title}: {title: string}) => (title ? `usunął(-ę) regułę agenta „${title}”` : 'usunięto regułę agenta'), + }, expensifyCardRule: { actionVerb: {block: 'zablokowane', allow: 'dozwolone'}, amountOperator: {over: 'ponad', under: 'pod'}, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c7384897dabe..9cef7a9308a3 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7828,6 +7828,11 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, findSuggestion: 'Encontrar uma regra', emptySuggestionsTitle: 'Nenhuma sugestão disponível', emptySuggestionsSubtitle: 'Tente escrever sua própria regra.', + saveConfirmation: { + title: 'Salvar regra', + prompt: 'O RuleBot aplicará esta regra a novas despesas.', + applyToUnapprovedExpenses: ({count}: {count: number}) => `Aplicar esta regra a ${count} ${count === 1 ? 'despesa não aprovada' : 'despesas não aprovadas'}`, + }, revampSubtitle: 'Descreva regras flexíveis que são executadas quando você precisar.', describeRuleForConcierge: 'Descreva sua regra e o Concierge vai criá-la', gotIt: 'Entendi', @@ -8619,6 +8624,12 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, updatedMccGroupCategory: ({mccGroupName, oldCategory, newCategory}: {mccGroupName: string; oldCategory: string; newCategory: string}) => `alterou a categoria de gasto padrão de "${mccGroupName}" para "${newCategory}" (antes "${oldCategory}")`, updatedRequireCompanyCards: ({enabled}: {enabled: boolean}) => `${enabled ? 'ativado' : 'desativado'} o requisito de compras com cartão corporativo`, + agentRule: { + added: ({title, prompt}: {title: string; prompt: string}) => (title ? `adicionou a regra de agente "${title}": ${prompt}` : `adicionou uma regra de agente: ${prompt}`), + updated: ({title, prompt}: {title: string; prompt: string}) => + title ? `atualizou a regra do agente "${title}" para: ${prompt}` : `atualizou uma regra de agente para: ${prompt}`, + deleted: ({title}: {title: string}) => (title ? `removeu a regra de agente "${title}"` : 'removeu uma regra de agente'), + }, expensifyCardRule: { actionVerb: {block: 'bloqueado', allow: 'permitido'}, amountOperator: {over: 'acima', under: 'abaixo'}, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index a5c454f1cf11..e76dcaba1246 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -7579,6 +7579,11 @@ ${reportName}`, findSuggestion: '查找规则', emptySuggestionsTitle: '暂无可用建议', emptySuggestionsSubtitle: '尝试编写您自己的规则。', + saveConfirmation: { + title: '保存规则', + prompt: 'RuleBot 将把此规则应用于新报销。', + applyToUnapprovedExpenses: ({count}: {count: number}) => `将此规则应用于 ${count} 笔未批准的报销`, + }, revampSubtitle: '按需运行的灵活规则描述', describeRuleForConcierge: '描述你的规则,Concierge 会帮你创建', gotIt: '知道了', @@ -8337,6 +8342,11 @@ ${reportName}`, `已将“${mccGroupName}”的默认支出类别更改为“${newCategory}”(此前为“${oldCategory}”)`, addedReportField: (fieldType: string, fieldName?: string, defaultValue?: string) => `已添加 ${fieldType} 报告字段“${fieldName}”${defaultValue ? ` 默认值为“${defaultValue}”` : ''}`, updatedRequireCompanyCards: ({enabled}: {enabled: boolean}) => `${enabled ? '已启用' : '已禁用'} 公司商务卡消费要求`, + agentRule: { + added: ({title, prompt}: {title: string; prompt: string}) => (title ? `已添加代理规则“${title}”:${prompt}` : `已添加代理规则:${prompt}`), + updated: ({title, prompt}: {title: string; prompt: string}) => (title ? `已将代理规则“${title}”更新为:${prompt}` : `已将代理规则更新为:${prompt}`), + deleted: ({title}: {title: string}) => (title ? `已移除代理规则“${title}”` : '已移除代理规则'), + }, expensifyCardRule: { actionVerb: {block: '已阻止', allow: '允许'}, amountOperator: {over: '结束', under: '在……之下'}, diff --git a/src/libs/API/parameters/AddPolicyAgentRuleParams.ts b/src/libs/API/parameters/AddPolicyAgentRuleParams.ts index bf5a70ca4193..1d07c6366f43 100644 --- a/src/libs/API/parameters/AddPolicyAgentRuleParams.ts +++ b/src/libs/API/parameters/AddPolicyAgentRuleParams.ts @@ -2,6 +2,7 @@ type AddPolicyAgentRuleParams = { policyID: string; agentRuleID: string; prompt: string; + applyToExistingExpenses?: boolean; }; export default AddPolicyAgentRuleParams; diff --git a/src/libs/API/parameters/GetPolicyUnapprovedExpenseCountParams.ts b/src/libs/API/parameters/GetPolicyUnapprovedExpenseCountParams.ts new file mode 100644 index 000000000000..7b70bbf13d17 --- /dev/null +++ b/src/libs/API/parameters/GetPolicyUnapprovedExpenseCountParams.ts @@ -0,0 +1,5 @@ +type GetPolicyUnapprovedExpenseCountParams = { + policyID: string; +}; + +export default GetPolicyUnapprovedExpenseCountParams; diff --git a/src/libs/API/parameters/UpdatePolicyAgentRuleParams.ts b/src/libs/API/parameters/UpdatePolicyAgentRuleParams.ts index 09739b24ccc3..b45ec563bd3e 100644 --- a/src/libs/API/parameters/UpdatePolicyAgentRuleParams.ts +++ b/src/libs/API/parameters/UpdatePolicyAgentRuleParams.ts @@ -2,6 +2,7 @@ type UpdatePolicyAgentRuleParams = { policyID: string; agentRuleID: string; prompt: string; + applyToExistingExpenses?: boolean; }; export default UpdatePolicyAgentRuleParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index dda7b55b18e2..48bd9a617117 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -73,6 +73,7 @@ export type {default as GetNewerActionsParams} from './GetNewerActionsParams'; export type {default as GetOlderActionsParams} from './GetOlderActionsParams'; export type {default as GetPolicyCategoriesParams} from './GetPolicyCategories'; export type {default as GetAgentRuleSuggestionsParams} from './GetAgentRuleSuggestionsParams'; +export type {default as GetPolicyUnapprovedExpenseCountParams} from './GetPolicyUnapprovedExpenseCountParams'; export type {default as GetReportPrivateNoteParams} from './GetReportPrivateNoteParams'; export type {default as GetRouteParams} from './GetRouteParams'; export type {default as CreateAppReportParams} from './CreateAppReportParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index c7f393faa977..cf5c900e5202 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -1437,6 +1437,7 @@ const READ_COMMANDS = { GET_DUPLICATE_TRANSACTION_DETAILS: 'GetDuplicateTransactionDetails', GET_TRANSACTIONS_MATCHING_CODING_RULE: 'GetTransactionsMatchingCodingRule', GET_AGENT_RULE_SUGGESTIONS: 'GetAgentRuleSuggestions', + GET_POLICY_UNAPPROVED_EXPENSE_COUNT: 'GetPolicyUnapprovedExpenseCount', GET_AGENT_TEMPLATES: 'GetAgentTemplates', GET_ASSIGNED_SUPPORT_DATA: 'GetAssignedSupportData', OPEN_AGENTS_PAGE: 'OpenAgentsPage', @@ -1545,6 +1546,7 @@ type ReadCommandParameters = { [READ_COMMANDS.GET_DUPLICATE_TRANSACTION_DETAILS]: Parameters.GetDuplicateTransactionDetailsParams; [READ_COMMANDS.GET_TRANSACTIONS_MATCHING_CODING_RULE]: Parameters.GetTransactionsMatchingCodingRuleParams; [READ_COMMANDS.GET_AGENT_RULE_SUGGESTIONS]: Parameters.GetAgentRuleSuggestionsParams; + [READ_COMMANDS.GET_POLICY_UNAPPROVED_EXPENSE_COUNT]: Parameters.GetPolicyUnapprovedExpenseCountParams; [READ_COMMANDS.GET_AGENT_TEMPLATES]: null; [READ_COMMANDS.GET_ASSIGNED_SUPPORT_DATA]: null; [READ_COMMANDS.OPEN_AGENTS_PAGE]: null; diff --git a/src/libs/AgentRuleChangeLogUtils.ts b/src/libs/AgentRuleChangeLogUtils.ts new file mode 100644 index 000000000000..22a0b93f67dc --- /dev/null +++ b/src/libs/AgentRuleChangeLogUtils.ts @@ -0,0 +1,45 @@ +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; + +import CONST from '@src/CONST'; +import type ReportAction from '@src/types/onyx/ReportAction'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import {getOriginalMessage, isActionOfType} from './ReportActionsUtils'; + +/** + * Builds the #admins system message shown when an agent rule is added. Includes the rule's title and + * full prompt; the title is server-generated and best-effort, so it may be empty. + */ +function getAddAgentRuleMessage(translate: LocalizedTranslate, reportAction: OnyxEntry): string { + if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_AGENT_RULE)) { + return ''; + } + const message = getOriginalMessage(reportAction) ?? {}; + return translate('workspaceActions.agentRule.added', {title: message.ruleTitle ?? '', prompt: message.prompt ?? ''}); +} + +/** + * Builds the #admins system message shown when an agent rule's prompt is updated. The title is set + * once at creation and is not changed here. + */ +function getUpdateAgentRuleMessage(translate: LocalizedTranslate, reportAction: OnyxEntry): string { + if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AGENT_RULE)) { + return ''; + } + const message = getOriginalMessage(reportAction) ?? {}; + return translate('workspaceActions.agentRule.updated', {title: message.ruleTitle ?? '', prompt: message.prompt ?? ''}); +} + +/** + * Builds the #admins system message shown when an agent rule is deleted. Only the title is recorded. + */ +function getDeleteAgentRuleMessage(translate: LocalizedTranslate, reportAction: OnyxEntry): string { + if (!isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_AGENT_RULE)) { + return ''; + } + const message = getOriginalMessage(reportAction) ?? {}; + return translate('workspaceActions.agentRule.deleted', {title: message.ruleTitle ?? ''}); +} + +export {getAddAgentRuleMessage, getUpdateAgentRuleMessage, getDeleteAgentRuleMessage}; diff --git a/src/libs/ReportNameUtils.ts b/src/libs/ReportNameUtils.ts index 8430e6ebd0a8..fc67898fb900 100644 --- a/src/libs/ReportNameUtils.ts +++ b/src/libs/ReportNameUtils.ts @@ -26,6 +26,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {Str} from 'expensify-common'; import Onyx from 'react-native-onyx'; +import {getAddAgentRuleMessage, getDeleteAgentRuleMessage, getUpdateAgentRuleMessage} from './AgentRuleChangeLogUtils'; import {convertToDisplayString} from './CurrencyUtils'; import {formatPhoneNumber as formatPhoneNumberPhoneUtils} from './LocalePhoneNumber'; import {translateLocal} from './Localize'; @@ -857,6 +858,15 @@ function computeReportNameBasedOnReportAction( if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_EXPENSIFY_CARD_RULE)) { return getRemoveExpensifyCardRuleMessage(translate, parentReportAction); } + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_AGENT_RULE)) { + return getAddAgentRuleMessage(translate, parentReportAction); + } + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AGENT_RULE)) { + return getUpdateAgentRuleMessage(translate, parentReportAction); + } + if (isActionOfType(parentReportAction, CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_AGENT_RULE)) { + return getDeleteAgentRuleMessage(translate, parentReportAction); + } if (isPolicyCopyReportAction(parentReportAction)) { return Parser.htmlToText(getPolicyChangeLogCopyMessage(translate, parentReportAction)); } diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 2966a2151b75..a835ef9aa13e 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -32,6 +32,7 @@ import {Str} from 'expensify-common'; import type {OptionData} from './ReportUtils'; import {isAnonymousUser} from './actions/Session'; +import {getAddAgentRuleMessage, getDeleteAgentRuleMessage, getUpdateAgentRuleMessage} from './AgentRuleChangeLogUtils'; import {formatPhoneNumber as formatPhoneNumberPhoneUtils} from './LocalePhoneNumber'; import {formatList} from './Localize'; import { @@ -1250,6 +1251,12 @@ function getOptionData({ result.alternateText = getUpdateExpensifyCardRuleMessage(translate, lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_EXPENSIFY_CARD_RULE) { result.alternateText = getRemoveExpensifyCardRuleMessage(translate, lastAction); + } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_AGENT_RULE) { + result.alternateText = getAddAgentRuleMessage(translate, lastAction); + } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AGENT_RULE) { + result.alternateText = getUpdateAgentRuleMessage(translate, lastAction); + } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_AGENT_RULE) { + result.alternateText = getDeleteAgentRuleMessage(translate, lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_MANUAL_APPROVAL_THRESHOLD) { result.alternateText = getUpdatedManualApprovalThresholdMessage(translate, lastAction); } else if (lastAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_BUDGET) { diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 2b31c382ae08..e83d512e47ad 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -4,6 +4,7 @@ import type { AddPolicyAgentRuleParams, DeletePolicyAgentRuleParams, GetAgentRuleSuggestionsParams, + GetPolicyUnapprovedExpenseCountParams, ImportMerchantRulesSpreadsheetParams, UpdatePolicyAgentRuleParams, } from '@libs/API/parameters'; @@ -163,6 +164,21 @@ function getAgentRuleSuggestions(policyID: string | undefined) { API.read(READ_COMMANDS.GET_AGENT_RULE_SUGGESTIONS, params, {optimisticData, successData, failureData}); } +/** + * Fetches the number of submitted but not yet approved expenses on the policy, shown in the agent + * rule save confirmation so admins can choose to apply the rule to those expenses. + */ +function getPolicyUnapprovedExpenseCount(policyID: string | undefined) { + if (!policyID) { + Log.warn('Invalid params for getPolicyUnapprovedExpenseCount', {policyID}); + return; + } + + const params: GetPolicyUnapprovedExpenseCountParams = {policyID}; + + API.read(READ_COMMANDS.GET_POLICY_UNAPPROVED_EXPENSE_COUNT, params); +} + /** * Creates or updates a coding rule for the given policy * @param policyID - The ID of the policy to create/update the rule for @@ -409,7 +425,7 @@ function deletePolicyCodingRule(policy: Policy, ruleID: string) { API.write(WRITE_COMMANDS.SET_POLICY_CODING_RULE, parameters, onyxData); } -function addPolicyAgentRule(policyID: string, agentRuleID: string, prompt: string) { +function addPolicyAgentRule(policyID: string, agentRuleID: string, prompt: string, applyToExistingExpenses?: boolean) { if (!policyID || !agentRuleID || !prompt) { Log.warn('Invalid params for addPolicyAgentRule', {policyID, agentRuleID, prompt}); return; @@ -475,11 +491,14 @@ function addPolicyAgentRule(policyID: string, agentRuleID: string, prompt: strin agentRuleID, prompt, }; + if (applyToExistingExpenses) { + parameters.applyToExistingExpenses = true; + } API.write(WRITE_COMMANDS.ADD_POLICY_AGENT_RULE, parameters, onyxData); } -function updatePolicyAgentRule(policyID: string, agentRuleID: string, prompt: string, previousPrompt: string, previousTitle?: string) { +function updatePolicyAgentRule(policyID: string, agentRuleID: string, prompt: string, previousPrompt: string, previousTitle?: string, applyToExistingExpenses?: boolean) { if (!policyID || !agentRuleID || !prompt) { Log.warn('Invalid params for updatePolicyAgentRule', {policyID, agentRuleID, prompt}); return; @@ -548,6 +567,9 @@ function updatePolicyAgentRule(policyID: string, agentRuleID: string, prompt: st agentRuleID, prompt, }; + if (applyToExistingExpenses) { + parameters.applyToExistingExpenses = true; + } API.write(WRITE_COMMANDS.UPDATE_POLICY_AGENT_RULE, parameters, onyxData); } @@ -678,6 +700,7 @@ function clearPolicyAgentRuleErrors(policyID: string, agentRuleID: string, agent export { openPolicyRulesPage, getAgentRuleSuggestions, + getPolicyUnapprovedExpenseCount, setPolicyCodingRule, importMerchantRulesSpreadsheet, deletePolicyCodingRule, diff --git a/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx b/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx index b83427dc0059..da84c62a3331 100644 --- a/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx +++ b/src/pages/inbox/report/actionContents/PolicyChangeLogContent.tsx @@ -4,6 +4,7 @@ import RenderHTML from '@components/RenderHTML'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import {getAddAgentRuleMessage, getDeleteAgentRuleMessage, getUpdateAgentRuleMessage} from '@libs/AgentRuleChangeLogUtils'; import {getCleanedTagName} from '@libs/PolicyUtils'; import { getAddedApprovalRuleMessage, @@ -181,6 +182,9 @@ const POLICY_CHANGE_LOG_RESOLVERS: Record = { [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EXPENSIFY_CARD_RULE]: (translate, action) => getAddExpensifyCardRuleMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EXPENSIFY_CARD_RULE]: (translate, action) => getUpdateExpensifyCardRuleMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_EXPENSIFY_CARD_RULE]: (translate, action) => getRemoveExpensifyCardRuleMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_AGENT_RULE]: (translate, action) => getAddAgentRuleMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AGENT_RULE]: (translate, action) => getUpdateAgentRuleMessage(translate, action), + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_AGENT_RULE]: (translate, action) => getDeleteAgentRuleMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_INTEGRATION]: (translate, action) => getAddedConnectionMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_INTEGRATION]: (translate, action) => getRemovedConnectionMessage(translate, action), [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_CARD_FEED]: (translate, action) => getAddedCardFeedMessage(translate, action), diff --git a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx index f2d69c09adf4..9a0585ecfb01 100644 --- a/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/AddAgentRulePage.tsx @@ -7,6 +7,7 @@ import TabSelectorBase from '@components/TabSelector/TabSelectorBase'; import TabSelectorContextProvider from '@components/TabSelector/TabSelectorContext'; import type {TabSelectorBaseItem} from '@components/TabSelector/types'; +import useAgentRuleApplyConfirmation from '@hooks/useAgentRuleApplyConfirmation'; import useConfirmModal from '@hooks/useConfirmModal'; import useDiscardChangesConfirmation from '@hooks/useDiscardChangesConfirmation'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -75,6 +76,7 @@ function AddAgentRulePage({ const [activeTabPolicyID, setActiveTabPolicyID] = useState(policyID); const [draftValues] = useOnyx(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM_DRAFT); const tabIcons = useMemoizedLazyExpensifyIcons(['Feed', 'Pencil']); + const {confirmAgentRuleSave} = useAgentRuleApplyConfirmation(policyID); const hasDraftPrompt = !!draftValues?.[INPUT_IDS.PROMPT]?.trim(); @@ -148,59 +150,61 @@ function AddAgentRulePage({ }; const saveRule = (values: FormOnyxValues): void => { - // When the workspace has no agent rules yet, the backend creates the "RuleBot" agent and adds it as - // an admin. Surface a one-time modal explaining this side effect before navigating back. - const isFirstRule = isEmptyObject(policy?.rules?.agentRules); - suppressDiscardPrompt(); - addPolicyAgentRule(policyID, rand64(), values[INPUT_IDS.PROMPT]); - clearDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM); - if (!isFirstRule) { - navigateBackToAgentsTab(); - return; - } - linkPressedRef.current = false; - const handleAgentsLinkPress = () => { - linkPressedRef.current = true; - closeModal(); - }; - - if (isRulesRevampEnabled) { - Tab.setSelectedTab(CONST.TAB.RULES_TAB_TYPE, CONST.TAB.RULES.AGENTS); - } - - Navigation.dismissModal({ - afterTransition: () => { - showConfirmModal({ - title: translate('workspace.rules.agentRules.agentCreatedTitle'), - titleStyles: styles.textHeadlineH1, - prompt: ( - - - - ), - confirmText: isRulesRevampEnabled ? translate('workspace.rules.agentRules.gotIt') : translate('common.buttonConfirm'), - shouldShowCancelButton: false, - shouldUseSuccessStyleForConfirm: true, - iconSource: BotAvatarBlue, - iconFill: false, - shouldCenterIcon: true, - iconWidth: variables.iconSizeUltraLarge, - iconHeight: variables.iconSizeUltraLarge, - iconAdditionalStyles: { - borderRadius: variables.iconSizeUltraLarge / 2, - overflow: 'hidden', - marginTop: 12, - }, - }).then(() => { - if (!linkPressedRef.current) { - return; - } - Navigation.navigate(ROUTES.SETTINGS_AGENTS); - }); - }, + confirmAgentRuleSave((applyToExistingExpenses) => { + // When the workspace has no agent rules yet, the backend creates the "RuleBot" agent and adds it as + // an admin. Surface a one-time modal explaining this side effect before navigating back. + const isFirstRule = isEmptyObject(policy?.rules?.agentRules); + suppressDiscardPrompt(); + addPolicyAgentRule(policyID, rand64(), values[INPUT_IDS.PROMPT], applyToExistingExpenses); + clearDraftValues(ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM); + if (!isFirstRule) { + navigateBackToAgentsTab(); + return; + } + linkPressedRef.current = false; + const handleAgentsLinkPress = () => { + linkPressedRef.current = true; + closeModal(); + }; + + if (isRulesRevampEnabled) { + Tab.setSelectedTab(CONST.TAB.RULES_TAB_TYPE, CONST.TAB.RULES.AGENTS); + } + + Navigation.dismissModal({ + afterTransition: () => { + showConfirmModal({ + title: translate('workspace.rules.agentRules.agentCreatedTitle'), + titleStyles: styles.textHeadlineH1, + prompt: ( + + + + ), + confirmText: isRulesRevampEnabled ? translate('workspace.rules.agentRules.gotIt') : translate('common.buttonConfirm'), + shouldShowCancelButton: false, + shouldUseSuccessStyleForConfirm: true, + iconSource: BotAvatarBlue, + iconFill: false, + shouldCenterIcon: true, + iconWidth: variables.iconSizeUltraLarge, + iconHeight: variables.iconSizeUltraLarge, + iconAdditionalStyles: { + borderRadius: variables.iconSizeUltraLarge / 2, + overflow: 'hidden', + marginTop: 12, + }, + }).then(() => { + if (!linkPressedRef.current) { + return; + } + Navigation.navigate(ROUTES.SETTINGS_AGENTS); + }); + }, + }); }); }; diff --git a/src/pages/workspace/rules/AgentRules/EditAgentRulePage.tsx b/src/pages/workspace/rules/AgentRules/EditAgentRulePage.tsx index 645645af22c0..0077e40f5c85 100644 --- a/src/pages/workspace/rules/AgentRules/EditAgentRulePage.tsx +++ b/src/pages/workspace/rules/AgentRules/EditAgentRulePage.tsx @@ -8,6 +8,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; +import useAgentRuleApplyConfirmation from '@hooks/useAgentRuleApplyConfirmation'; import useConfirmModal from '@hooks/useConfirmModal'; import useIsInLandscapeMode from '@hooks/useIsInLandscapeMode'; import useLocalize from '@hooks/useLocalize'; @@ -53,6 +54,7 @@ function EditAgentRulePage({ const policy = usePolicy(policyID); const agentRule = policy?.rules?.agentRules?.[ruleID]; const formRef = useRef(null); + const {confirmAgentRuleSave} = useAgentRuleApplyConfirmation(policyID); const describeRuleLabel = isRulesRevampEnabled ? translate('workspace.rules.agentRules.describeRuleForConcierge') : translate('workspace.rules.agentRules.describeRuleTitle'); const submitFormOnModEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => { @@ -75,10 +77,14 @@ function EditAgentRulePage({ const saveRule = (values: FormOnyxValues): void => { const newPrompt = values[INPUT_IDS.PROMPT]; const previousPrompt = agentRule?.prompt ?? ''; - if (newPrompt !== previousPrompt) { - updatePolicyAgentRule(policyID, ruleID, newPrompt, previousPrompt, agentRule?.title); + if (newPrompt === previousPrompt) { + Navigation.goBack(); + return; } - Navigation.goBack(); + confirmAgentRuleSave((applyToExistingExpenses) => { + updatePolicyAgentRule(policyID, ruleID, newPrompt, previousPrompt, agentRule?.title, applyToExistingExpenses); + Navigation.goBack(); + }); }; const handleDelete = () => { diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index ad58e5e4491f..aea207951c24 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -906,6 +906,21 @@ type OriginalMessageSpendRuleChangeLog = { currency?: string; }; +/** Model of a workspace agent rule change log action (add, update, or delete) */ +type OriginalMessageAgentRuleChangeLog = { + /** ID of the policy the agent rule belongs to */ + policyID?: string; + + /** ID of the agent rule that changed */ + ruleID?: string; + + /** Server-generated one-line title of the agent rule */ + ruleTitle?: string; + + /** Natural-language prompt of the agent rule (present for add/update, omitted for delete) */ + prompt?: string; +}; + /** Model of a policy copy change log action */ type OriginalMessagePolicyChangeCopyLog = { /** The ID of the source policy from which the user copied settings */ @@ -1774,6 +1789,9 @@ type OriginalMessageMap = { [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EXPENSIFY_CARD_RULE]: OriginalMessageSpendRuleChangeLog; [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_EXPENSIFY_CARD_RULE]: OriginalMessageSpendRuleChangeLog; [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.REMOVE_EXPENSIFY_CARD_RULE]: OriginalMessageSpendRuleChangeLog; + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_AGENT_RULE]: OriginalMessageAgentRuleChangeLog; + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AGENT_RULE]: OriginalMessageAgentRuleChangeLog; + [CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_AGENT_RULE]: OriginalMessageAgentRuleChangeLog; } & Record, OriginalMessageChangeLog>; type OriginalMessage = T extends keyof OriginalMessageMap ? OriginalMessageMap[T] : never; diff --git a/tests/actions/PolicyRulesTest.ts b/tests/actions/PolicyRulesTest.ts index d1ac7d83abc0..4c4ee5a8a2a1 100644 --- a/tests/actions/PolicyRulesTest.ts +++ b/tests/actions/PolicyRulesTest.ts @@ -1,5 +1,14 @@ import OnyxUpdateManager from '@libs/actions/OnyxUpdateManager'; -import {addPolicyAgentRule, clearPolicyAgentRuleErrors, clearPolicyCodingRuleErrors, deletePolicyAgentRule, updatePolicyAgentRule} from '@libs/actions/Policy/Rules'; +import { + addPolicyAgentRule, + clearPolicyAgentRuleErrors, + clearPolicyCodingRuleErrors, + deletePolicyAgentRule, + getPolicyUnapprovedExpenseCount, + updatePolicyAgentRule, +} from '@libs/actions/Policy/Rules'; +import * as API from '@libs/API'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -101,6 +110,22 @@ describe('actions/PolicyRules', () => { const policy = await getPolicy(fakePolicy.id); expect(policy?.rules?.agentRules).toBeFalsy(); }); + + it('sends applyToExistingExpenses only when the rule should be applied to existing expenses', () => { + const writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + + addPolicyAgentRule('1', 'agentRule1', 'Prompt', true); + expect(writeSpy).toHaveBeenLastCalledWith( + WRITE_COMMANDS.ADD_POLICY_AGENT_RULE, + {policyID: '1', agentRuleID: 'agentRule1', prompt: 'Prompt', applyToExistingExpenses: true}, + expect.anything(), + ); + + addPolicyAgentRule('1', 'agentRule2', 'Prompt'); + expect(writeSpy).toHaveBeenLastCalledWith(WRITE_COMMANDS.ADD_POLICY_AGENT_RULE, {policyID: '1', agentRuleID: 'agentRule2', prompt: 'Prompt'}, expect.anything()); + + writeSpy.mockRestore(); + }); }); describe('updatePolicyAgentRule', () => { @@ -191,6 +216,38 @@ describe('actions/PolicyRules', () => { expect(policy?.rules?.agentRules?.[agentRuleID]?.prompt).toBe('Original'); expect(policy?.rules?.agentRules?.[agentRuleID]?.pendingAction).toBeFalsy(); }); + + it('sends applyToExistingExpenses only when the rule should be applied to existing expenses', () => { + // eslint-disable-next-line rulesdir/no-multiple-api-calls + const writeSpy = jest.spyOn(API, 'write').mockImplementation(() => Promise.resolve()); + + updatePolicyAgentRule('1', 'agentRule1', 'New', 'Old', 'Old title', true); + expect(writeSpy).toHaveBeenLastCalledWith( + WRITE_COMMANDS.UPDATE_POLICY_AGENT_RULE, + {policyID: '1', agentRuleID: 'agentRule1', prompt: 'New', applyToExistingExpenses: true}, + expect.anything(), + ); + + updatePolicyAgentRule('1', 'agentRule1', 'New', 'Old', 'Old title'); + expect(writeSpy).toHaveBeenLastCalledWith(WRITE_COMMANDS.UPDATE_POLICY_AGENT_RULE, {policyID: '1', agentRuleID: 'agentRule1', prompt: 'New'}, expect.anything()); + + writeSpy.mockRestore(); + }); + }); + + describe('getPolicyUnapprovedExpenseCount', () => { + it('reads the unapproved expense count for the policy', () => { + // eslint-disable-next-line rulesdir/no-multiple-api-calls + const readSpy = jest.spyOn(API, 'read').mockImplementation(() => {}); + + getPolicyUnapprovedExpenseCount('1'); + expect(readSpy).toHaveBeenCalledWith(READ_COMMANDS.GET_POLICY_UNAPPROVED_EXPENSE_COUNT, {policyID: '1'}); + + getPolicyUnapprovedExpenseCount(undefined); + expect(readSpy).toHaveBeenCalledTimes(1); + + readSpy.mockRestore(); + }); }); describe('deletePolicyAgentRule', () => { diff --git a/tests/unit/AgentRuleChangeLogUtilsTest.ts b/tests/unit/AgentRuleChangeLogUtilsTest.ts new file mode 100644 index 000000000000..b092e9821f15 --- /dev/null +++ b/tests/unit/AgentRuleChangeLogUtilsTest.ts @@ -0,0 +1,139 @@ +import {getAddAgentRuleMessage, getDeleteAgentRuleMessage, getUpdateAgentRuleMessage} from '@libs/AgentRuleChangeLogUtils'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; +import type {ReportAction} from '@src/types/onyx'; + +import {translateLocal} from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +describe('AgentRuleChangeLogUtils', () => { + beforeAll(() => { + IntlStore.load(CONST.LOCALES.DEFAULT); + return waitForBatchedUpdates(); + }); + + describe('getAddAgentRuleMessage', () => { + it('returns empty string for wrong action type', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_EXPENSIFY_CARD_RULE, + reportActionID: '1', + created: '', + originalMessage: {}, + } as ReportAction; + expect(getAddAgentRuleMessage(translateLocal, action)).toBe(''); + }); + + it('includes the rule title and full prompt', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_AGENT_RULE, + reportActionID: '1', + created: '', + originalMessage: { + policyID: '1', + ruleID: '2', + ruleTitle: 'Receipts required', + prompt: 'Flag any expense over $25 that is missing a receipt', + }, + } as ReportAction; + expect(getAddAgentRuleMessage(translateLocal, action)).toBe('added the agent rule "Receipts required": Flag any expense over $25 that is missing a receipt'); + }); + + it('falls back to prompt-only when the title is empty', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_AGENT_RULE, + reportActionID: '1', + created: '', + originalMessage: { + policyID: '1', + ruleID: '2', + ruleTitle: '', + prompt: 'Reject any expense that includes alcohol', + }, + } as ReportAction; + expect(getAddAgentRuleMessage(translateLocal, action)).toBe('added an agent rule: Reject any expense that includes alcohol'); + }); + }); + + describe('getUpdateAgentRuleMessage', () => { + it('returns empty string for wrong action type', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_AGENT_RULE, + reportActionID: '1', + created: '', + originalMessage: {}, + } as ReportAction; + expect(getUpdateAgentRuleMessage(translateLocal, action)).toBe(''); + }); + + it('includes the preserved title and new prompt', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AGENT_RULE, + reportActionID: '1', + created: '', + originalMessage: { + policyID: '1', + ruleID: '2', + ruleTitle: 'Receipts required', + prompt: 'Reject any expense that includes alcohol', + }, + } as ReportAction; + expect(getUpdateAgentRuleMessage(translateLocal, action)).toBe('updated the agent rule "Receipts required" to: Reject any expense that includes alcohol'); + }); + + it('falls back to prompt-only when the title is empty', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AGENT_RULE, + reportActionID: '1', + created: '', + originalMessage: { + policyID: '1', + ruleID: '2', + ruleTitle: '', + prompt: 'Reject any expense that includes alcohol', + }, + } as ReportAction; + expect(getUpdateAgentRuleMessage(translateLocal, action)).toBe('updated an agent rule to: Reject any expense that includes alcohol'); + }); + }); + + describe('getDeleteAgentRuleMessage', () => { + it('returns empty string for wrong action type', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_AGENT_RULE, + reportActionID: '1', + created: '', + originalMessage: {}, + } as ReportAction; + expect(getDeleteAgentRuleMessage(translateLocal, action)).toBe(''); + }); + + it('includes only the rule title', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_AGENT_RULE, + reportActionID: '1', + created: '', + originalMessage: { + policyID: '1', + ruleID: '2', + ruleTitle: 'Receipts required', + }, + } as ReportAction; + expect(getDeleteAgentRuleMessage(translateLocal, action)).toBe('removed the agent rule "Receipts required"'); + }); + + it('falls back to a generic message when the title is empty', () => { + const action = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_AGENT_RULE, + reportActionID: '1', + created: '', + originalMessage: { + policyID: '1', + ruleID: '2', + ruleTitle: '', + }, + } as ReportAction; + expect(getDeleteAgentRuleMessage(translateLocal, action)).toBe('removed an agent rule'); + }); + }); +}); diff --git a/tests/unit/ReportNameUtilsTest.ts b/tests/unit/ReportNameUtilsTest.ts index 483e1d85d579..3b342edddfcc 100644 --- a/tests/unit/ReportNameUtilsTest.ts +++ b/tests/unit/ReportNameUtilsTest.ts @@ -760,6 +760,113 @@ describe('ReportNameUtils', () => { expect(name).toBe('removed card feed "Amex Corporate"'); }); + test('ADD_AGENT_RULE parent action', () => { + const thread: Report = createWorkspaceThread(170); + const parentAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_AGENT_RULE, + reportActionID: String(thread.parentReportActionID), + message: [], + created: '', + lastModified: '', + actorAccountID: 1, + person: [], + originalMessage: { + ruleID: '4242424242', + ruleTitle: 'Receipts required', + prompt: 'Reject expenses without receipts', + }, + } as unknown as ReportAction; + + const parentId = String(thread.parentReportID); + const actionId = String(thread.parentReportActionID); + const reportActionsCollection: Record = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentId}`]: {[actionId]: parentAction}, + }; + + const name = computeReportName( + thread, + emptyCollections.reports, + emptyCollections.policies, + undefined, + undefined, + participantsPersonalDetails, + reportActionsCollection, + currentUserAccountID, + ); + expect(name).toBe('added the agent rule "Receipts required": Reject expenses without receipts'); + }); + + test('UPDATE_AGENT_RULE parent action', () => { + const thread: Report = createWorkspaceThread(171); + const parentAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AGENT_RULE, + reportActionID: String(thread.parentReportActionID), + message: [], + created: '', + lastModified: '', + actorAccountID: 1, + person: [], + originalMessage: { + ruleID: '4242424242', + ruleTitle: 'Receipts required', + prompt: 'Reject expenses over $25 without receipts', + }, + } as unknown as ReportAction; + + const parentId = String(thread.parentReportID); + const actionId = String(thread.parentReportActionID); + const reportActionsCollection: Record = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentId}`]: {[actionId]: parentAction}, + }; + + const name = computeReportName( + thread, + emptyCollections.reports, + emptyCollections.policies, + undefined, + undefined, + participantsPersonalDetails, + reportActionsCollection, + currentUserAccountID, + ); + expect(name).toBe('updated the agent rule "Receipts required" to: Reject expenses over $25 without receipts'); + }); + + test('DELETE_AGENT_RULE parent action', () => { + const thread: Report = createWorkspaceThread(172); + const parentAction: ReportAction = { + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_AGENT_RULE, + reportActionID: String(thread.parentReportActionID), + message: [], + created: '', + lastModified: '', + actorAccountID: 1, + person: [], + originalMessage: { + ruleID: '4242424242', + ruleTitle: 'Receipts required', + }, + } as unknown as ReportAction; + + const parentId = String(thread.parentReportID); + const actionId = String(thread.parentReportActionID); + const reportActionsCollection: Record = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${parentId}`]: {[actionId]: parentAction}, + }; + + const name = computeReportName( + thread, + emptyCollections.reports, + emptyCollections.policies, + undefined, + undefined, + participantsPersonalDetails, + reportActionsCollection, + currentUserAccountID, + ); + expect(name).toBe('removed the agent rule "Receipts required"'); + }); + test('RENAME_CARD_FEED parent action', () => { const thread: Report = createWorkspaceThread(102); const parentAction: ReportAction = { diff --git a/tests/unit/SidebarUtilsTest.ts b/tests/unit/SidebarUtilsTest.ts index 066f57eeb9c2..f771775d1341 100644 --- a/tests/unit/SidebarUtilsTest.ts +++ b/tests/unit/SidebarUtilsTest.ts @@ -2591,6 +2591,126 @@ describe('SidebarUtils', () => { expect(result?.alternateText).toBe('added card feed "Visa Commercial"'); }); + it('returns the correct alternate text for ADD_AGENT_RULE action', async () => { + const report: Report = { + ...createRandomReport(4, 'policyAdmins'), + participants: {'18921695': {notificationPreference: 'always'}}, + }; + const lastAction: ReportAction = { + ...createRandomReportAction(2), + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.ADD_AGENT_RULE, + originalMessage: {ruleID: '4242424242', ruleTitle: 'Receipts required', prompt: 'Reject expenses without receipts'}, + }; + const reportActions: ReportActions = {[lastAction.reportActionID]: lastAction}; + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, reportActions); + }); + + const result = SidebarUtils.getOptionData({ + report, + reportAttributes: undefined, + reportNameValuePairs: {}, + personalDetails: {}, + policy: undefined, + invoiceReceiverPolicy: undefined, + parentReportAction: undefined, + conciergeReportID: '', + oneTransactionThreadReport: undefined, + card: undefined, + translate: translateLocal, + localeCompare, + lastAction, + lastActionReport: undefined, + isReportArchived: undefined, + currentUserAccountID: 0, + currentUserLogin: CURRENT_USER_LOGIN, + reportAttributesDerived: undefined, + }); + + expect(result?.alternateText).toBe('added the agent rule "Receipts required": Reject expenses without receipts'); + }); + + it('returns the correct alternate text for UPDATE_AGENT_RULE action', async () => { + const report: Report = { + ...createRandomReport(4, 'policyAdmins'), + participants: {'18921695': {notificationPreference: 'always'}}, + }; + const lastAction: ReportAction = { + ...createRandomReportAction(2), + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.UPDATE_AGENT_RULE, + originalMessage: {ruleID: '4242424242', ruleTitle: 'Receipts required', prompt: 'Reject expenses over $25 without receipts'}, + }; + const reportActions: ReportActions = {[lastAction.reportActionID]: lastAction}; + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, reportActions); + }); + + const result = SidebarUtils.getOptionData({ + report, + reportAttributes: undefined, + reportNameValuePairs: {}, + personalDetails: {}, + policy: undefined, + invoiceReceiverPolicy: undefined, + parentReportAction: undefined, + conciergeReportID: '', + oneTransactionThreadReport: undefined, + card: undefined, + translate: translateLocal, + localeCompare, + lastAction, + lastActionReport: undefined, + isReportArchived: undefined, + currentUserAccountID: 0, + currentUserLogin: CURRENT_USER_LOGIN, + reportAttributesDerived: undefined, + }); + + expect(result?.alternateText).toBe('updated the agent rule "Receipts required" to: Reject expenses over $25 without receipts'); + }); + + it('returns the correct alternate text for DELETE_AGENT_RULE action', async () => { + const report: Report = { + ...createRandomReport(4, 'policyAdmins'), + participants: {'18921695': {notificationPreference: 'always'}}, + }; + const lastAction: ReportAction = { + ...createRandomReportAction(2), + actionName: CONST.REPORT.ACTIONS.TYPE.POLICY_CHANGE_LOG.DELETE_AGENT_RULE, + originalMessage: {ruleID: '4242424242', ruleTitle: 'Receipts required'}, + }; + const reportActions: ReportActions = {[lastAction.reportActionID]: lastAction}; + await act(async () => { + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, report); + await Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, reportActions); + }); + + const result = SidebarUtils.getOptionData({ + report, + reportAttributes: undefined, + reportNameValuePairs: {}, + personalDetails: {}, + policy: undefined, + invoiceReceiverPolicy: undefined, + parentReportAction: undefined, + conciergeReportID: '', + oneTransactionThreadReport: undefined, + card: undefined, + translate: translateLocal, + localeCompare, + lastAction, + lastActionReport: undefined, + isReportArchived: undefined, + currentUserAccountID: 0, + currentUserLogin: CURRENT_USER_LOGIN, + reportAttributesDerived: undefined, + }); + + expect(result?.alternateText).toBe('removed the agent rule "Receipts required"'); + }); + it('returns the correct alternate text for DELETE_CARD_FEED action', async () => { const report: Report = { ...createRandomReport(4, 'policyAdmins'),