diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 1942b24fdff7..e4660afe7ed2 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -614,6 +614,23 @@ const CONST = { MAX_LENGTH: 83, }, + GENERATED_RULE: { + PROMPT_MAX_LENGTH: 6000, + STATE: { + RULE: 'rule', + UNSUPPORTED: 'unsupported', + MULTIPLE_RULES: 'multipleRules', + UNINTELLIGIBLE: 'unintelligible', + FAILED: 'failed', + }, + RULE_TYPE: { + REQUIRE_FIELDS: 'requireFields', + FLAG_FOR_REVIEW: 'flagForReview', + RESTRICT_CARD_SPEND: 'restrictCardSpend', + EXPENSE_DEFAULTS: 'expenseDefaults', + }, + }, + EXPORT_DOWNLOAD: { STATE: { PREPARING: 'preparing', @@ -10129,12 +10146,14 @@ const CONST = { MERCHANT_RULE_ITEM: 'WorkspaceRules-MerchantRuleItem', REQUIRE_FIELDS_RULE_ITEM: 'WorkspaceRules-RequireFieldsRuleItem', REQUIRE_FIELDS_RULE_SAVE: 'WorkspaceRules-RequireFieldsRuleSave', + REQUIRE_FIELDS_RULE_DESCRIBE: 'WorkspaceRules-RequireFieldsRuleDescribe', REQUIRE_FIELDS_RULE_DELETE: 'WorkspaceRules-RequireFieldsRuleDelete', REQUIRE_FIELDS_RULE_CATEGORY: 'WorkspaceRules-RequireFieldsRuleCategory', REQUIRE_FIELDS_RULE_FIELD_TOGGLE: 'WorkspaceRules-RequireFieldsRuleFieldToggle', REQUIRE_FIELDS_RULE_DIRECTION_TOGGLE: 'WorkspaceRules-RequireFieldsRuleDirectionToggle', FLAG_FOR_REVIEW_RULE_ITEM: 'WorkspaceRules-FlagForReviewRuleItem', FLAG_FOR_REVIEW_RULE_SAVE: 'WorkspaceRules-FlagForReviewRuleSave', + FLAG_FOR_REVIEW_RULE_DESCRIBE: 'WorkspaceRules-FlagForReviewRuleDescribe', FLAG_FOR_REVIEW_RULE_DELETE: 'WorkspaceRules-FlagForReviewRuleDelete', FLAG_FOR_REVIEW_RULE_CATEGORY: 'WorkspaceRules-FlagForReviewRuleCategory', FLAG_FOR_REVIEW_RULE_AMOUNT: 'WorkspaceRules-FlagForReviewRuleAmount', @@ -10147,12 +10166,14 @@ const CONST = { IMPORT_MERCHANT_RULES: 'WorkspaceRules-ImportMerchantRules', MERCHANT_RULE_SECTION_ITEM: 'WorkspaceRules-MerchantRuleSectionItem', MERCHANT_RULE_SAVE: 'WorkspaceRules-MerchantRuleSave', + MERCHANT_RULE_DESCRIBE: 'WorkspaceRules-MerchantRuleDescribe', MERCHANT_RULE_PREVIEW_MATCHES: 'WorkspaceRules-MerchantRulePreviewMatches', MERCHANT_RULE_DELETE: 'WorkspaceRules-MerchantRuleDelete', CATEGORY_SELECTOR: 'WorkspaceRules-CategorySelector', CURRENCY_SELECTOR: 'WorkspaceRules-CurrencySelector', SPEND_RULE_SECTION_ITEM: 'WorkspaceRules-SpendRuleSectionItem', SPEND_RULE_SAVE: 'WorkspaceRules-SpendRuleSave', + SPEND_RULE_DESCRIBE: 'WorkspaceRules-SpendRuleDescribe', SPEND_RULE_DELETE: 'WorkspaceRules-SpendRuleDelete', SPEND_RULE_RESTRICTION_TYPE: 'WorkspaceRules-SpendRuleRestrictionType', AGENT_RULE_ITEM: 'WorkspaceRules-AgentRuleItem', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index b03fd4aec992..55f2ea4e35c9 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -496,6 +496,9 @@ const ONYXKEYS = { /** Is loading agent rule suggestions for the add-rule Suggestions tab? */ IS_LOADING_AGENT_TEMPLATES: 'isLoadingAgentTemplates', + /** The rule generated from an admin's description */ + GENERATED_RULE: 'generatedRule', + /** Set when we are loading fresh subscription/billing data from the server */ IS_LOADING_SUBSCRIPTION_DATA: 'isLoadingSubscriptionData', @@ -1401,6 +1404,8 @@ const ONYXKEYS = { EDIT_AGENT_PROMPT_FORM_DRAFT: 'editAgentPromptFormDraft', ADD_AGENT_RULE_FORM: 'addAgentRuleForm', ADD_AGENT_RULE_FORM_DRAFT: 'addAgentRuleFormDraft', + NEW_RULE_PROMPT_FORM: 'newRulePromptForm', + NEW_RULE_PROMPT_FORM_DRAFT: 'newRulePromptFormDraft', EDIT_AGENT_RULE_FORM: 'editAgentRuleForm', EDIT_AGENT_RULE_FORM_DRAFT: 'editAgentRuleFormDraft', RILLET_CREDENTIALS_FORM: 'rilletCredentialsForm', @@ -1565,6 +1570,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.EDIT_AGENT_NAME_FORM]: FormTypes.EditAgentNameForm; [ONYXKEYS.FORMS.EDIT_AGENT_PROMPT_FORM]: FormTypes.EditAgentPromptForm; [ONYXKEYS.FORMS.ADD_AGENT_RULE_FORM]: FormTypes.AddAgentRuleForm; + [ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM]: FormTypes.NewRulePromptForm; [ONYXKEYS.FORMS.EDIT_AGENT_RULE_FORM]: FormTypes.EditAgentRuleForm; [ONYXKEYS.FORMS.RILLET_CREDENTIALS_FORM]: FormTypes.RilletCredentialsForm; [ONYXKEYS.FORMS.DUALENTRY_CREDENTIALS_FORM]: FormTypes.DualEntryCredentialsForm; @@ -1814,6 +1820,7 @@ type OnyxValuesMapping = { [ONYXKEYS.IS_LOADING_BULK_CHANGE_APPROVER_PAGE]: boolean; [ONYXKEYS.IS_LOADING_POLICY_CODING_RULES_PREVIEW]: boolean; [ONYXKEYS.IS_LOADING_AGENT_RULE_SUGGESTIONS]: boolean; + [ONYXKEYS.GENERATED_RULE]: OnyxTypes.GeneratedRule; [ONYXKEYS.IS_LOADING_AGENT_TEMPLATES]: boolean; [ONYXKEYS.IS_LOADING_REPORT_DATA]: boolean; [ONYXKEYS.IS_SEARCH_FILTERS_CARD_DATA_LOADED]: boolean; diff --git a/src/languages/de.ts b/src/languages/de.ts index 6cb0afe249fc..65eb349fc7c8 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -8404,6 +8404,8 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und applyUpdates: 'Diese Aktualisierungen anwenden:', saveRule: 'Regel speichern', previewMatches: 'Übereinstimmungen anzeigen', + previewMatchesDescription: 'Sieh dir an, wie diese Regel auf deine Ausgaben angewendet wird', + preview: 'Vorschau', confirmError: 'Gib ein Händlerunternehmen ein und nimm mindestens eine Aktualisierung vor', confirmErrorMerchant: 'Bitte Händler eingeben', confirmErrorUpdate: 'Bitte wende mindestens ein Update an', @@ -8740,6 +8742,14 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc requireFieldsDescription: 'Bestimmte Spesenfelder vorschreiben oder auf ihre Pflicht verzichten.', createAgentRule: 'Agentenregel beschreiben', createAgentRuleDescription: 'Erstellen Sie flexible Regeln, die ausgeführt werden, wenn Sie sie benötigen.', + describe: 'Beschreiben', + describeRule: 'Beschreibe deine Regel in einfachen Worten und Concierge erstellt sie für dich', + describeRuleInputLabel: 'Beschreibe die Regel', + promptErrors: { + unsupported: 'Diese Regel wird noch nicht unterstützt – wir arbeiten daran. Bitte versuche vorerst eine andere Regel.', + unintelligible: 'Formuliere deine Regel etwa so: Wenn Ausgaben über 100 $ liegen, ist ein Beleg erforderlich.', + multipleRules: 'Bitte eine Regel nach der anderen – beschreibe eine einzelne Regel, z. B.: Einzelhandelskäufe auf allen Karten blockieren.', + }, }, expenseDefaultsTable: { tableColumnType: 'Typ', diff --git a/src/languages/el.ts b/src/languages/el.ts index cb4d7a657d82..e332f66f8a27 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -8764,6 +8764,8 @@ ${reportName}`, applyUpdates: 'Εφαρμόστε αυτές τις ενημερώσεις:', saveRule: 'Αποθήκευση κανόνα', previewMatches: 'Προεπισκόπηση αντιστοιχιών', + previewMatchesDescription: 'Δείτε πώς εφαρμόζεται αυτός ο κανόνας στις δαπάνες σας', + preview: 'Προεπισκόπηση', confirmError: 'Εισαγάγετε τον έμπορο και εφαρμόστε τουλάχιστον μία ενημέρωση', confirmErrorMerchant: 'Παρακαλούμε εισαγάγετε τον έμπορο', confirmErrorUpdate: 'Παρακαλούμε εφαρμόστε τουλάχιστον μία ενημέρωση', @@ -8830,6 +8832,14 @@ ${reportName}`, applyExpenseDefaultsDescription: 'Ενημέρωση πεδίων χωρίς να χρειάζεται ο υποβάλλων να κάνει τίποτα', createAgentRule: 'Περιγράψτε τον κανόνα πράκτορα', createAgentRuleDescription: 'Δημιουργήστε ευέλικτους κανόνες που εκτελούνται όταν τους χρειάζεστε.', + describe: 'Περιγραφή', + describeRule: 'Περιγράψτε τον κανόνα σας με απλά λόγια και ο Concierge θα τον δημιουργήσει για εσάς', + describeRuleInputLabel: 'Περιγράψτε τον κανόνα', + promptErrors: { + unsupported: 'Αυτός ο κανόνας δεν υποστηρίζεται ακόμη — το δουλεύουμε. Δοκιμάστε προς το παρόν έναν διαφορετικό κανόνα.', + unintelligible: 'Δοκιμάστε να διατυπώσετε τον κανόνα σας έτσι: αν οι δαπάνες ξεπερνούν τα 100 $, να απαιτείται απόδειξη.', + multipleRules: 'Έναν κανόνα τη φορά — δοκιμάστε να περιγράψετε έναν μόνο κανόνα, π.χ.: αποκλεισμός αγορών λιανικής σε όλες τις κάρτες.', + }, }, expenseDefaultsTable: { tableColumnType: 'Τύπος', diff --git a/src/languages/en.ts b/src/languages/en.ts index 5bb2666b9706..b8bb71ce5c93 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -8954,6 +8954,8 @@ const translations = { applyUpdates: 'Apply these updates:', saveRule: 'Save rule', previewMatches: 'Preview matches', + previewMatchesDescription: 'See how this rule applies to your expenses', + preview: 'Preview', confirmError: 'Enter merchant and apply at least one update', confirmErrorMerchant: 'Please enter merchant', confirmErrorUpdate: 'Please apply at least one update', @@ -9012,6 +9014,14 @@ const translations = { applyExpenseDefaultsDescription: 'Update fields without submitter doing anything', createAgentRule: 'Describe agent rule', createAgentRuleDescription: 'Create flexible rules that run when you need.', + describe: 'Describe', + describeRule: 'Describe your rule in plain English and have Concierge build the rule for you', + describeRuleInputLabel: 'Describe the rule', + promptErrors: { + unsupported: "That rule isn't supported yet — we're working on it. Please try a different rule for now.", + unintelligible: 'Try phrasing your rule like: if expenses have a value over $100, then require a receipt.', + multipleRules: 'One rule at a time, please — try describing a single rule, like: block retail purchases on all cards.', + }, }, expenseDefaultsTable: { tableColumnType: 'Type', diff --git a/src/languages/es.ts b/src/languages/es.ts index 940fe1d21d22..6e0ff1b4ffde 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -8437,6 +8437,8 @@ ${reportName}`, applyUpdates: 'Aplicar estas actualizaciones:', saveRule: 'Guardar regla', previewMatches: 'Vista previa de coincidencias', + previewMatchesDescription: 'Mira cómo se aplica esta regla a tus gastos', + preview: 'Vista previa', confirmError: 'Ingresa comerciante y aplica al menos una actualización', confirmErrorMerchant: 'Por favor ingresa comerciante', confirmErrorUpdate: 'Por favor aplica al menos una actualización', @@ -8746,6 +8748,14 @@ ${reportName}`, requireFieldsDescription: 'Exige campos específicos de gasto o renuncia a exigirlos.', createAgentRule: 'Describir regla de agente', createAgentRuleDescription: 'Crea reglas flexibles que se ejecuten cuando las necesites.', + describe: 'Describir', + describeRule: 'Describe tu regla con tus propias palabras y Concierge la creará por ti', + describeRuleInputLabel: 'Describe la regla', + promptErrors: { + unsupported: 'Esa regla aún no es compatible: estamos trabajando en ello. Prueba con otra regla por ahora.', + unintelligible: 'Prueba a formular tu regla así: si los gastos superan los $100, exige un recibo.', + multipleRules: 'Una regla a la vez, por favor. Prueba a describir una sola regla, como: bloquear compras en comercios minoristas en todas las tarjetas.', + }, }, expenseDefaultsTable: { tableColumnType: 'Tipo', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 13a28c3febf8..4a8d09e7bf69 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -8424,6 +8424,8 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip applyUpdates: 'Appliquer ces mises à jour :', saveRule: 'Enregistrer la règle', previewMatches: 'Aperçu des correspondances', + previewMatchesDescription: 'Voyez comment cette règle s’applique à vos dépenses', + preview: 'Aperçu', confirmError: 'Saisissez un commerçant et appliquez au moins une mise à jour', confirmErrorMerchant: 'Veuillez saisir le commerçant', confirmErrorUpdate: 'Veuillez appliquer au moins une mise à jour', @@ -8761,6 +8763,14 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e requireFieldsDescription: 'Rendre certains champs de dépense obligatoires ou renoncer à les exiger.', createAgentRule: 'Décrire la règle de l’agent', createAgentRuleDescription: 'Créez des règles flexibles qui s’exécutent quand vous en avez besoin.', + describe: 'Décrire', + describeRule: 'Décrivez votre règle avec vos propres mots et Concierge la créera pour vous', + describeRuleInputLabel: 'Décrivez la règle', + promptErrors: { + unsupported: 'Cette règle n’est pas encore prise en charge — nous y travaillons. Essayez une autre règle pour le moment.', + unintelligible: 'Essayez de formuler votre règle ainsi : si les dépenses dépassent 100 $, exigez un reçu.', + multipleRules: 'Une règle à la fois, s’il vous plaît — essayez de décrire une seule règle, comme : bloquer les achats en magasin sur toutes les cartes.', + }, }, expenseDefaultsTable: { tableColumnType: 'Type', diff --git a/src/languages/it.ts b/src/languages/it.ts index 2d02fdae45ed..2b29fd0a45d8 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -8355,6 +8355,8 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo applyUpdates: 'Applica questi aggiornamenti:', saveRule: 'Salva regola', previewMatches: 'Anteprima corrispondenze', + previewMatchesDescription: 'Guarda come questa regola si applica alle tue spese', + preview: 'Anteprima', confirmError: 'Inserisci l’esercente e applica almeno un aggiornamento', confirmErrorMerchant: 'Inserisci l’esercente', confirmErrorUpdate: 'Applica almeno un aggiornamento', @@ -8691,6 +8693,14 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, requireFieldsDescription: 'Richiedi campi specifici per le spese oppure rinuncia a renderli obbligatori.', createAgentRule: 'Descrivi regola agente', createAgentRuleDescription: 'Crea regole flessibili che vengono eseguite quando ne hai bisogno.', + describe: 'Descrivi', + describeRule: 'Descrivi la tua regola con parole semplici e Concierge la creerà per te', + describeRuleInputLabel: 'Descrivi la regola', + promptErrors: { + unsupported: 'Questa regola non è ancora supportata: ci stiamo lavorando. Per ora prova una regola diversa.', + unintelligible: 'Prova a formulare la regola così: se le spese superano i 100 $, richiedi una ricevuta.', + multipleRules: 'Una regola alla volta, per favore: prova a descrivere una singola regola, ad esempio bloccare gli acquisti al dettaglio su tutte le carte.', + }, }, expenseDefaultsTable: { tableColumnType: 'Tipo', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 0a9d10675c07..fe94957d6a31 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -8250,6 +8250,8 @@ ${reportName}`, applyUpdates: 'これらの更新を適用する', saveRule: 'ルールを保存', previewMatches: 'プレビュー候補を表示', + previewMatchesDescription: 'このルールが経費にどう適用されるかを確認', + preview: 'プレビュー', confirmError: '店舗名を入力し、少なくとも1つの更新を適用してください', confirmErrorMerchant: '加盟店を入力してください', confirmErrorUpdate: '少なくとも 1 つの更新を適用してください', @@ -8582,6 +8584,14 @@ ${reportName}`, requireFieldsDescription: '特定の経費項目フィールドを必須にするか、必須条件を免除します。', createAgentRule: 'エージェントルールの説明', createAgentRuleDescription: '必要なときに実行される柔軟なルールを作成できます。', + describe: '説明する', + describeRule: 'ルールを自然な言葉で説明すると、Concierge がルールを作成します', + describeRuleInputLabel: 'ルールを説明', + promptErrors: { + unsupported: 'そのルールはまだ対応していません。現在対応を進めています。ほかのルールをお試しください。', + unintelligible: '「経費が100ドルを超える場合は領収書を必須にする」のように書いてみてください。', + multipleRules: '一度に1つのルールのみです。「すべてのカードで小売店での購入をブロックする」のように、1つのルールを説明してください。', + }, }, expenseDefaultsTable: { tableColumnType: '種類', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 074b9356ffbb..1cdecd410d94 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -8332,6 +8332,8 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar applyUpdates: 'Pas deze updates toe:', saveRule: 'Regel opslaan', previewMatches: 'Voorbeelden van overeenkomsten bekijken', + previewMatchesDescription: 'Bekijk hoe deze regel op je uitgaven wordt toegepast', + preview: 'Voorbeeld', confirmError: 'Voer handelaar in en pas minstens één wijziging toe', confirmErrorMerchant: 'Voer handelaar in', confirmErrorUpdate: 'Pas ten minste één wijziging toe', @@ -8666,6 +8668,14 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, requireFieldsDescription: 'Specifieke onkostenvakken verplicht stellen of afzien van het verplicht stellen ervan.', createAgentRule: 'Agentregel beschrijven', createAgentRuleDescription: 'Maak flexibele regels die worden uitgevoerd wanneer jij dat nodig hebt.', + describe: 'Beschrijven', + describeRule: 'Beschrijf je regel in gewone taal en Concierge maakt de regel voor je', + describeRuleInputLabel: 'Beschrijf de regel', + promptErrors: { + unsupported: 'Die regel wordt nog niet ondersteund — we werken eraan. Probeer voorlopig een andere regel.', + unintelligible: 'Formuleer je regel bijvoorbeeld zo: als uitgaven hoger zijn dan $100, vraag dan een bon.', + multipleRules: 'Eén regel per keer graag — beschrijf één enkele regel, bijvoorbeeld: retailaankopen op alle kaarten blokkeren.', + }, }, expenseDefaultsTable: { tableColumnType: 'Type', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 3fd0bc63d861..da0310a7f914 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -8354,6 +8354,8 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i applyUpdates: 'Zastosuj te aktualizacje:', saveRule: 'Zapisz regułę', previewMatches: 'Podgląd dopasowań', + previewMatchesDescription: 'Zobacz, jak ta reguła stosuje się do Twoich wydatków', + preview: 'Podgląd', confirmError: 'Wprowadź sprzedawcę i zastosuj co najmniej jedną zmianę', confirmErrorMerchant: 'Wprowadź sprzedawcę', confirmErrorUpdate: 'Zastosuj co najmniej jedną aktualizację', @@ -8689,6 +8691,14 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, requireFieldsDescription: 'Wymagaj konkretnych pól wydatku lub zrezygnuj z ich wymagania.', createAgentRule: 'Opisz regułę agenta', createAgentRuleDescription: 'Twórz elastyczne reguły, które uruchamiają się wtedy, gdy ich potrzebujesz.', + describe: 'Opisz', + describeRule: 'Opisz swoją regułę własnymi słowami, a Concierge utworzy ją za Ciebie', + describeRuleInputLabel: 'Opisz regułę', + promptErrors: { + unsupported: 'Ta reguła nie jest jeszcze obsługiwana — pracujemy nad tym. Na razie spróbuj innej reguły.', + unintelligible: 'Spróbuj sformułować regułę tak: jeśli wydatek przekracza 100 $, wymagaj paragonu.', + multipleRules: 'Jedna reguła naraz — opisz pojedynczą regułę, na przykład: zablokuj zakupy detaliczne na wszystkich kartach.', + }, }, expenseDefaultsTable: { tableColumnType: 'Typ', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 7f4783027dc9..e59d9468130e 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -8338,6 +8338,8 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e applyUpdates: 'Aplicar estas atualizações:', saveRule: 'Salvar regra', previewMatches: 'Pré-visualizar correspondências', + previewMatchesDescription: 'Veja como esta regra se aplica às suas despesas', + preview: 'Pré-visualizar', confirmError: 'Insira o comerciante e aplique pelo menos uma atualização', confirmErrorMerchant: 'Insira o comerciante', confirmErrorUpdate: 'Aplicar pelo menos uma atualização', @@ -8673,6 +8675,14 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, requireFieldsDescription: 'Exigir campos específicos de despesa ou dispensar a exigência deles.', createAgentRule: 'Descrever regra do agente', createAgentRuleDescription: 'Crie regras flexíveis que sejam executadas quando você precisar.', + describe: 'Descrever', + describeRule: 'Descreva sua regra em linguagem simples e o Concierge criará a regra para você', + describeRuleInputLabel: 'Descreva a regra', + promptErrors: { + unsupported: 'Essa regra ainda não tem suporte — estamos trabalhando nisso. Tente uma regra diferente por enquanto.', + unintelligible: 'Tente formular sua regra assim: se a despesa for maior que $100, exija um recibo.', + multipleRules: 'Uma regra por vez, por favor — tente descrever uma única regra, como: bloquear compras no varejo em todos os cartões.', + }, }, expenseDefaultsTable: { tableColumnType: 'Tipo', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 411f1d1b94d4..e09c8cc3dd89 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -8045,6 +8045,8 @@ ${reportName}`, applyUpdates: '应用这些更新:', saveRule: '保存规则', previewMatches: '预览匹配项', + previewMatchesDescription: '查看此规则如何应用于你的费用', + preview: '预览', confirmError: '输入商户并应用至少一项更新', confirmErrorMerchant: '请输入商户', confirmErrorUpdate: '请至少应用一个更新', @@ -8364,6 +8366,14 @@ ${reportName}`, requireFieldsDescription: '要求填写特定报销字段,或免除其必填限制。', createAgentRule: '描述代理规则', createAgentRuleDescription: '创建可按需运行的灵活规则。', + describe: '描述', + describeRule: '用通俗的语言描述你的规则,Concierge 会为你创建规则', + describeRuleInputLabel: '描述规则', + promptErrors: { + unsupported: '该规则暂不支持,我们正在开发中。请先尝试其他规则。', + unintelligible: '可以这样描述规则:如果支出超过 100 美元,则需要收据。', + multipleRules: '请一次描述一条规则,例如:在所有卡上阻止零售消费。', + }, }, expenseDefaultsTable: { tableColumnType: '类型', diff --git a/src/libs/API/parameters/GenerateRuleParams.ts b/src/libs/API/parameters/GenerateRuleParams.ts new file mode 100644 index 000000000000..211b9e4ca5cb --- /dev/null +++ b/src/libs/API/parameters/GenerateRuleParams.ts @@ -0,0 +1,10 @@ +import type {GeneratedRuleType} from '@src/types/onyx/GeneratedRule'; + +type GenerateRuleParams = { + policyID: string; + generationID: string; + ruleType: GeneratedRuleType; + prompt: string; +}; + +export default GenerateRuleParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index aca27f7b67df..2ff5c93ff55f 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -498,6 +498,7 @@ export type {default as SetPolicyExpenseMaxAmountNoItemizedReceipt} from './SetP export type {default as SetPolicyExpenseMaxAmount} from './SetPolicyExpenseMaxAmount'; export type {default as SetPolicyExpenseMaxAge} from './SetPolicyExpenseMaxAge'; export type {default as AddPolicyAgentRuleParams} from './AddPolicyAgentRuleParams'; +export type {default as GenerateRuleParams} from './GenerateRuleParams'; export type {default as UpdatePolicyAgentRuleParams} from './UpdatePolicyAgentRuleParams'; export type {default as DeletePolicyAgentRuleParams} from './DeletePolicyAgentRuleParams'; export type {default as UpdateCustomRules} from './UpdateCustomRules'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 88955926c0e5..99e533193ca5 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -305,6 +305,7 @@ const WRITE_COMMANDS = { SET_POLICY_REQUIRE_MAP_OR_GPS: 'SetPolicyRequireMapOrGPS', SET_POLICY_EXPENSE_MAX_AGE: 'SetPolicyExpenseMaxAge', ADD_POLICY_AGENT_RULE: 'AddPolicyAgentRule', + GENERATE_RULE: 'GenerateRule', UPDATE_POLICY_AGENT_RULE: 'UpdatePolicyAgentRule', DELETE_POLICY_AGENT_RULE: 'DeletePolicyAgentRule', UPDATE_CUSTOM_RULES: 'UpdateCustomRules', @@ -1119,6 +1120,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AMOUNT]: Parameters.SetPolicyExpenseMaxAmount; [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AGE]: Parameters.SetPolicyExpenseMaxAge; [WRITE_COMMANDS.ADD_POLICY_AGENT_RULE]: Parameters.AddPolicyAgentRuleParams; + [WRITE_COMMANDS.GENERATE_RULE]: Parameters.GenerateRuleParams; [WRITE_COMMANDS.UPDATE_POLICY_AGENT_RULE]: Parameters.UpdatePolicyAgentRuleParams; [WRITE_COMMANDS.DELETE_POLICY_AGENT_RULE]: Parameters.DeletePolicyAgentRuleParams; [WRITE_COMMANDS.UPDATE_CUSTOM_RULES]: Parameters.UpdateCustomRules; diff --git a/src/libs/ExportOnyxState/masking.ts b/src/libs/ExportOnyxState/masking.ts index a6716eae06b5..b1454ef827a9 100644 --- a/src/libs/ExportOnyxState/masking.ts +++ b/src/libs/ExportOnyxState/masking.ts @@ -451,6 +451,7 @@ const onyxKeysToMaskFragileData = new Set([ ONYXKEYS.DUPLICATE_WORKSPACE, ONYXKEYS.FREQUENTLY_USED_EMOJIS, ONYXKEYS.FUND_LIST, + ONYXKEYS.GENERATED_RULE, ONYXKEYS.GPS_DRAFT_DETAILS, ONYXKEYS.IMPORTED_SPREADSHEET, ONYXKEYS.IMPORTED_SPREADSHEET_MEMBER_DATA, diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 197ece413bd5..f8d94fc18ae5 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -1,3 +1,4 @@ +import {clearDraftValues} from '@libs/actions/FormActions'; import {getImportFailedFinalModal} from '@libs/actions/ImportSpreadsheet'; // Namespace import on purpose. `rulesdir/no-api-side-effects-method` only matches member calls, so importing // `makeRequestWithSideEffects` by name would quietly switch that guardrail off. @@ -5,6 +6,7 @@ import * as API from '@libs/API'; import type { AddPolicyAgentRuleParams, DeletePolicyAgentRuleParams, + GenerateRuleParams, GetAgentRuleSuggestionsParams, ImportMerchantRulesSpreadsheetParams, UpdatePolicyAgentRuleParams, @@ -20,8 +22,10 @@ import {getIsOffline} from '@libs/NetworkState'; import {rand64} from '@libs/NumberUtils'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ExpenseDefaultAction} from '@src/types/onyx/ExpenseDefaultRules'; +import type {GeneratedRuleState, GeneratedRuleType} from '@src/types/onyx/GeneratedRule'; import type {ImportFinalModal} from '@src/types/onyx/ImportedSpreadsheet'; import type Policy from '@src/types/onyx/Policy'; import type {AgentRule, CodingRule, CodingRuleFilter} from '@src/types/onyx/Policy'; @@ -399,6 +403,66 @@ function deleteMerchantRule(policyID: string, ruleID: string, rule: Rule | undef API.write(WRITE_COMMANDS.SET_POLICY_CODING_RULE, parameters, onyxData); } +const PROMPT_ERROR_BY_STATE: Partial> = { + [CONST.GENERATED_RULE.STATE.UNSUPPORTED]: 'workspace.rules.newRule.promptErrors.unsupported', + [CONST.GENERATED_RULE.STATE.MULTIPLE_RULES]: 'workspace.rules.newRule.promptErrors.multipleRules', + [CONST.GENERATED_RULE.STATE.UNINTELLIGIBLE]: 'workspace.rules.newRule.promptErrors.unintelligible', +}; + +/** + * Asks Concierge to turn a description into values for the given rule form. The response only confirms the job was + * queued. The answer arrives later under `ONYXKEYS.GENERATED_RULE`, so the form stays loading until it does. + * @returns the generationID the answer carries + */ +function generateRule(policyID: string, ruleType: GeneratedRuleType, prompt: string): string { + const generationID = rand64(); + type GenerateRuleKey = typeof ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM | typeof ONYXKEYS.GENERATED_RULE; + + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, + value: {isLoading: true, errors: null}, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.GENERATED_RULE, + value: null, + }, + ]; + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, + value: {isLoading: false, errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage')}, + }, + ]; + + const parameters: GenerateRuleParams = {policyID, generationID, ruleType, prompt}; + + API.write(WRITE_COMMANDS.GENERATE_RULE, parameters, {optimisticData, failureData}); + + return generationID; +} + +/** Shows on the prompt form why a description did not become a rule */ +function setNewRulePromptError(state: GeneratedRuleState) { + Onyx.merge(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, { + isLoading: false, + errors: getMicroSecondOnyxErrorWithTranslationKey(PROMPT_ERROR_BY_STATE[state] ?? 'common.genericErrorMessage'), + }); +} + +function clearGeneratedRule() { + Onyx.set(ONYXKEYS.GENERATED_RULE, null); +} + +function clearNewRulePrompt() { + Onyx.set(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM, null); + clearDraftValues(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM); + clearGeneratedRule(); +} + function addPolicyAgentRule(policyID: string, agentRuleID: string, prompt: string) { if (!policyID || !agentRuleID || !prompt) { Log.warn('Invalid params for addPolicyAgentRule', {policyID, agentRuleID, prompt}); @@ -662,6 +726,10 @@ export { deleteMerchantRule, getTransactionsMatchingCodingRule, addPolicyAgentRule, + generateRule, + setNewRulePromptError, + clearGeneratedRule, + clearNewRulePrompt, updatePolicyAgentRule, deletePolicyAgentRule, clearMerchantRuleErrors, diff --git a/src/pages/workspace/rules/DescribeRuleButton.tsx b/src/pages/workspace/rules/DescribeRuleButton.tsx new file mode 100644 index 000000000000..c0a2bcd09114 --- /dev/null +++ b/src/pages/workspace/rules/DescribeRuleButton.tsx @@ -0,0 +1,68 @@ +/** + * Describe button for a rule editor's footer. It opens the modal where an admin describes the rule in plain English. + */ +import Button from '@components/Button'; + +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import CONST from '@src/CONST'; +import type {GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; + +import React, {useState} from 'react'; + +import DescribeRuleModal from './DescribeRuleModal'; + +type DescribeRuleButtonProps = { + /** Sentry label for the button */ + sentryLabel: string; +} & ( + | { + policyID: string; + + /** The rule type of the editor the button is in, so Concierge fills in that form */ + ruleType: GeneratedRuleType; + + /** Called with the generated form values once Concierge has built the rule */ + onRuleGenerated: (values: GeneratedRuleValues) => void; + + onPress?: never; + } + | { + /** Opens a DescribeRuleModal that the editor renders itself. Use it when the footer can remount while the modal is open, + * for example a footer that moves on rotation, because the button's own modal would close with it. */ + onPress: () => void; + } +); + +function DescribeRuleButton(props: DescribeRuleButtonProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const [isModalVisible, setIsModalVisible] = useState(false); + + return ( + <> + + {!props.onPress && ( + setIsModalVisible(false)} + policyID={props.policyID} + ruleType={props.ruleType} + onRuleGenerated={props.onRuleGenerated} + /> + )} + + ); +} + +DescribeRuleButton.displayName = 'DescribeRuleButton'; + +export default DescribeRuleButton; diff --git a/src/pages/workspace/rules/DescribeRuleModal.tsx b/src/pages/workspace/rules/DescribeRuleModal.tsx new file mode 100644 index 000000000000..89762fb17839 --- /dev/null +++ b/src/pages/workspace/rules/DescribeRuleModal.tsx @@ -0,0 +1,183 @@ +/** + * Modal where an admin describes a rule in plain English and Concierge fills in the open rule editor's form. + */ +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues, FormRef} from '@components/Form/types'; +import Modal from '@components/Modal'; +import type {AnimatedTextInputRef} from '@components/RNTextInput'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getDecodedCategoryName} from '@libs/CategoryUtils'; + +import {clearGeneratedRule, clearNewRulePrompt, generateRule, setNewRulePromptError} from '@userActions/Policy/Rules'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import INPUT_IDS from '@src/types/form/NewRulePromptForm'; +import type {PolicyCategories} from '@src/types/onyx'; +import type {GeneratedRuleType, GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; + +import type {TextInputKeyPressEvent} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; + +import {useFocusEffect} from '@react-navigation/native'; +import React, {useEffect, useRef} from 'react'; +import {View} from 'react-native'; + +type NewRulePromptFormID = typeof ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM; + +type DescribeRuleModalProps = { + isVisible: boolean; + + onClose: () => void; + + policyID: string; + + /** The rule type of the editor the modal opens from, so Concierge fills in that form */ + ruleType: GeneratedRuleType; + + /** Called with the generated form values once Concierge has built the rule */ + onRuleGenerated: (values: GeneratedRuleValues) => void; +}; + +/** Replaces the generated category with the workspace's own spelling of it, or drops it when the workspace has no such category */ +function withPolicyCategory(values: GeneratedRuleValues, policyCategories: OnyxEntry): GeneratedRuleValues { + const {category, ...rest} = values; + if (!category) { + return values; + } + const policyCategory = Object.values(policyCategories ?? {}).find((candidate) => candidate.enabled && getDecodedCategoryName(candidate.name) === getDecodedCategoryName(category)); + return policyCategory ? {...rest, category: policyCategory.name} : rest; +} + +function DescribeRuleModal({isVisible, onClose, policyID, ruleType, onRuleGenerated}: DescribeRuleModalProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + // We need to use isSmallScreenWidth here because the Modal breaks in RHP with shouldUseNarrowLayout. + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); + const formRef = useRef(null); + const inputRef = useRef(null); + const pendingGenerationIDRef = useRef(undefined); + const [generatedRule] = useOnyx(ONYXKEYS.GENERATED_RULE); + const [newRulePromptForm] = useOnyx(ONYXKEYS.FORMS.NEW_RULE_PROMPT_FORM); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + const generatedRuleID = generatedRule?.generationID; + const generatedRuleState = generatedRule?.state; + const generatedRuleValues = generatedRule?.rule; + const inputLabel = translate('workspace.rules.newRule.describeRuleInputLabel'); + + const close = () => { + pendingGenerationIDRef.current = undefined; + clearNewRulePrompt(); + onClose(); + }; + + useEffect(() => { + if (!generatedRuleID || !generatedRuleState || generatedRuleID !== pendingGenerationIDRef.current) { + return; + } + if (generatedRuleState !== CONST.GENERATED_RULE.STATE.RULE) { + setNewRulePromptError(generatedRuleState); + clearGeneratedRule(); + return; + } + onRuleGenerated(withPolicyCategory(generatedRuleValues ?? {}, policyCategories)); + close(); + }, [close, generatedRuleID, generatedRuleState, generatedRuleValues, onRuleGenerated, policyCategories]); + + useEffect(() => { + if (!isVisible || !newRulePromptForm?.isLoading || pendingGenerationIDRef.current) { + return; + } + clearNewRulePrompt(); + }, [isVisible, newRulePromptForm?.isLoading]); + + // Focus only after the open animation, because focusing mid-animation blurs the input on mWeb and native + useFocusEffect(() => { + if (!isVisible) { + return; + } + const focusTimeout = setTimeout(() => inputRef.current?.focus(), CONST.ANIMATED_TRANSITION); + return () => clearTimeout(focusTimeout); + }); + + const submitFormOnModEnter = (event: TextInputKeyPressEvent | KeyboardEvent) => { + if (!('key' in event)) { + return; + } + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + formRef.current?.submit(); + } + }; + + const validate = (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + if (!values[INPUT_IDS.PROMPT]?.trim()) { + errors[INPUT_IDS.PROMPT] = translate('common.error.fieldRequired'); + } + return errors; + }; + + const submit = (values: FormOnyxValues) => { + pendingGenerationIDRef.current = generateRule(policyID, ruleType, values[INPUT_IDS.PROMPT].trim()); + }; + + return ( + + + {translate('workspace.rules.newRule.describeRule')} + + + + + + ); +} + +DescribeRuleModal.displayName = 'DescribeRuleModal'; + +export default DescribeRuleModal; diff --git a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx index 631e194ff416..2ecce17a3397 100644 --- a/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx +++ b/src/pages/workspace/rules/FlagForReviewRules/FlagForReviewRulePageBase.tsx @@ -29,6 +29,7 @@ import Navigation from '@libs/Navigation/Navigation'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import DescribeRuleButton from '@pages/workspace/rules/DescribeRuleButton'; import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; import {callFunctionIfActionIsAllowed} from '@userActions/Session'; @@ -81,6 +82,7 @@ function FlagForReviewRulePageBase({ const isEditing = !!categoryName; const isCategoryLocked = isCategoryLockedProp ?? !!initialCategoryName; const canEditCategory = canWriteRules && !isCategoryLocked; + const shouldShowDescribeRule = !isEditing && !isCategoryLocked; const categorySettingsBackPath = useCategoryRuleCreateBackPath(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_RULES_FLAG_FOR_REVIEW_NEW.path); const policyCurrency = policy?.outputCurrency ?? CONST.CURRENCY.USD; @@ -215,6 +217,19 @@ function FlagForReviewRulePageBase({ shouldShowLoadingImmediatelyOnPress={false} enabledWhenOffline sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.FLAG_FOR_REVIEW_RULE_SAVE} + buttonStyles={styles.flex1} + buttonAndFooterContainerStyles={[styles.flexRow, styles.gap2]} + shouldRenderFooterAboveSubmit + footerContent={ + shouldShowDescribeRule && ( + + ) + } /> ) : null; diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index 344f5c687b6f..10cfd1c1a20e 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -43,6 +43,8 @@ import {getTagArrayFromName} from '@libs/TransactionUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import DescribeRuleButton from '@pages/workspace/rules/DescribeRuleButton'; +import DescribeRuleModal from '@pages/workspace/rules/DescribeRuleModal'; import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; import variables from '@styles/variables'; @@ -55,6 +57,7 @@ import type {MerchantRuleForm} from '@src/types/form'; import MERCHANT_RULE_INPUT_IDS from '@src/types/form/MerchantRuleForm'; import type {ExpenseDefaultRuleType} from '@src/types/form/MerchantRuleForm'; import type {PolicyTagLists} from '@src/types/onyx'; +import type {GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import getEmptyArray from '@src/types/utils/getEmptyArray'; import type IconAsset from '@src/types/utils/IconAsset'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -188,6 +191,8 @@ function MerchantRulePageBase({ const [shouldShowError, setShouldShowError] = useState(false); const {showConfirmModal} = useConfirmModal(); const [shouldUpdateMatchingTransactions, setShouldUpdateMatchingTransactions] = useState(false); + // The footer remounts when it moves on rotation, so the page owns the modal to keep it open + const [isDescribeRuleModalVisible, setIsDescribeRuleModalVisible] = useState(false); const seededCategoryTaxRuleRef = useRef(undefined); const didSeedInitialCategoryRef = useRef(false); @@ -672,52 +677,47 @@ function MerchantRulePageBase({ return ; } + const shouldShowDescribeRule = !isEditingSavedRule && !isCategoryLocked; + const applyGeneratedRule = (values: GeneratedRuleValues) => { + setDraftMerchantRule({...values, ruleType: CONST.POLICY.EXPENSE_DEFAULT_RULE_TYPE.MERCHANT}); + }; + const footer = canWriteRules ? ( - - - - {translate('workspace.rules.merchantRules.applyToExistingUnsubmittedExpenses')} - - {/* A category tax default only applies to expenses created after the rule is saved, so the switch - is locked off. `disabled` draws the lock inside the thumb and routes the press to the explainer. */} - + {!isCategoryRule && ( + + {translate('workspace.rules.merchantRules.previewMatchesDescription')} + + + )} + setIsDescribeRuleModalVisible(true)} /> - - {/* There is no set of existing expenses for a category rule to preview, so the button is hidden rather than locked. */} - {!isCategoryRule && ( - - )} - - } - /> + ) + } + /> + ) : null; const renderSectionItem = (item: SectionItemType) => { @@ -791,9 +791,35 @@ function MerchantRulePageBase({ {translate('workspace.rules.merchantRules.ifAnyExpenseMatches')} {renderSections()} + + + + {translate('workspace.rules.merchantRules.applyToExistingUnsubmittedExpenses')} + + + {isInLandscapeMode && footer} {!isInLandscapeMode && footer} + {shouldShowDescribeRule && ( + setIsDescribeRuleModalVisible(false)} + policyID={policyID} + ruleType={CONST.GENERATED_RULE.RULE_TYPE.EXPENSE_DEFAULTS} + onRuleGenerated={applyGeneratedRule} + /> + )} ); diff --git a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx index acdf89b3f71d..b4dbf4882a3d 100644 --- a/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx +++ b/src/pages/workspace/rules/RequireFieldsRules/RequireFieldsRulePageBase.tsx @@ -43,6 +43,7 @@ import type {FieldRequirementsDirection} from '@libs/RequireFieldsRulesUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import DescribeRuleButton from '@pages/workspace/rules/DescribeRuleButton'; import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; import {callFunctionIfActionIsAllowed} from '@userActions/Session'; @@ -52,11 +53,14 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES, getRequireFieldsRuleCategoryRoute, getWorkspaceCategorySettingsRoute} from '@src/ROUTES'; import type {RequireFieldsRuleForm, RequireFieldsRuleSettingFieldKey} from '@src/types/form/RequireFieldsRuleForm'; import INPUT_IDS from '@src/types/form/RequireFieldsRuleForm'; +import type {GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import {useFocusEffect} from '@react-navigation/native'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; +const SETTING_FIELD_KEYS = [INPUT_IDS.DESCRIPTION_SETTING, INPUT_IDS.ATTENDEES_SETTING, INPUT_IDS.RECEIPT_SETTING, INPUT_IDS.ITEMIZED_RECEIPT_SETTING] as const; + type RequireFieldsRulePageBaseProps = { policyID: string; categoryName?: string; @@ -79,6 +83,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, const icons = useMemoizedLazyExpensifyIcons(['Folder']); const isEditing = !!categoryName; const isCategoryLocked = isCategoryLockedProp ?? !!initialCategoryName; + const shouldShowDescribeRule = !isEditing && !isCategoryLocked; const canEditCategory = canWriteRules && !isCategoryLocked; const categorySettingsBackPath = useCategoryRuleCreateBackPath(DYNAMIC_ROUTES.WORKSPACE_CATEGORY_RULES_REQUIRE_FIELDS_NEW.path); @@ -91,6 +96,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, // Fields the user toggled directly — category reassignment marks touchedFields for save/display // without meaning the user caused coupling, so tooltips key off this set instead. const [couplingInteractionFields, setCouplingInteractionFields] = useState>(() => new Set()); + const [generatedCategoryName, setGeneratedCategoryName] = useState(); const initializedDraftForRuleKeyRef = useRef(null); const category = categoryName ? policyCategories?.[categoryName] : undefined; @@ -110,8 +116,10 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, setSelectionCategoryName(selectedCategoryName); } else if (selectionCategoryName !== selectedCategoryName) { const previousCategoryName = selectionCategoryName; - const didChangeSelectedCategory = previousCategoryName !== undefined && selectedCategoryName !== undefined; + const isGeneratedCategory = selectedCategoryName === generatedCategoryName; + const didChangeSelectedCategory = previousCategoryName !== undefined && selectedCategoryName !== undefined && !isGeneratedCategory; setSelectionCategoryName(selectedCategoryName); + setGeneratedCategoryName(undefined); if (didChangeSelectedCategory) { const previousCategory = previousCategoryName ? policyCategories?.[previousCategoryName] : undefined; @@ -121,7 +129,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, // Preserve whatever is currently shown (edit often displays category overrides // without those fields being in touchedFields yet). - for (const fieldKey of [INPUT_IDS.DESCRIPTION_SETTING, INPUT_IDS.ATTENDEES_SETTING, INPUT_IDS.RECEIPT_SETTING, INPUT_IDS.ITEMIZED_RECEIPT_SETTING] as const) { + for (const fieldKey of SETTING_FIELD_KEYS) { const displayedSetting = getRequireFieldsDisplayedSetting({ fieldKey, category: previousCategory, @@ -158,9 +166,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, // Remount after a category change loses local touched state — rebuild it from the draft. if (isEditing && categoryName && selectedCategoryName && selectedCategoryName !== categoryName && form) { - const draftSettingKeys = ([INPUT_IDS.DESCRIPTION_SETTING, INPUT_IDS.ATTENDEES_SETTING, INPUT_IDS.RECEIPT_SETTING, INPUT_IDS.ITEMIZED_RECEIPT_SETTING] as const).filter( - (fieldKey) => form[fieldKey] !== undefined, - ); + const draftSettingKeys = SETTING_FIELD_KEYS.filter((fieldKey) => form[fieldKey] !== undefined); if (draftSettingKeys.some((fieldKey) => !touchedFields.has(fieldKey))) { setTouchedFields(new Set([...touchedFields, ...draftSettingKeys])); } @@ -306,7 +312,7 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, const nextDraft: Partial = { [INPUT_IDS.CATEGORY]: form[INPUT_IDS.CATEGORY], }; - for (const settingFieldKey of [INPUT_IDS.DESCRIPTION_SETTING, INPUT_IDS.ATTENDEES_SETTING, INPUT_IDS.RECEIPT_SETTING, INPUT_IDS.ITEMIZED_RECEIPT_SETTING] as const) { + for (const settingFieldKey of SETTING_FIELD_KEYS) { if (keysToClear.includes(settingFieldKey) || form[settingFieldKey] === undefined) { continue; } @@ -425,6 +431,12 @@ function RequireFieldsRulePageBase({policyID, categoryName, initialCategoryName, return ; } + const applyGeneratedRule = (values: GeneratedRuleValues) => { + setDraftRequireFieldsRule(values); + setGeneratedCategoryName(values.category); + setTouchedFields(new Set(SETTING_FIELD_KEYS.filter((fieldKey) => values[fieldKey] !== undefined))); + }; + const footer = canWriteRules ? ( + ) + } /> ) : null; diff --git a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx index 92596602635d..4fce96f34e6c 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx @@ -33,6 +33,7 @@ import {getSpendRuleFormValuesFromCardRule, getTruncatedSpendRuleSummary} from ' import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import DescribeRuleButton from '@pages/workspace/rules/DescribeRuleButton'; import useRuleDeleteHeaderProps from '@pages/workspace/rules/useRuleDeleteHeaderProps'; import variables from '@styles/variables'; @@ -43,6 +44,8 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import type {SpendRuleCategory} from '@src/types/form/SpendRuleForm'; +import {isSpendRuleCategory} from '@src/types/form/SpendRuleForm'; +import type {GeneratedRuleValues} from '@src/types/onyx/GeneratedRule'; import type IconAsset from '@src/types/utils/IconAsset'; import type {ValueOf} from 'type-fest'; @@ -104,6 +107,12 @@ function SpendRulePageBase({policyID, ruleID, testID, upgradeBackTo}: SpendRuleP useEffect(() => () => clearDraftSpendRule(), []); + const applyGeneratedRule = (values: GeneratedRuleValues) => { + const generatedCategories = values.categories?.filter(isSpendRuleCategory); + setDraftSpendRule(generatedCategories ? {...values, categories: generatedCategories} : values); + setIsRestrictMerchantsOff(!generatedCategories?.length && !values.merchantNames?.length); + }; + useEffect(() => { if (!isEditingRule || !existingFormValues) { return; @@ -462,7 +471,19 @@ function SpendRulePageBase({policyID, ruleID, testID, upgradeBackTo}: SpendRuleP shouldShowLoadingImmediatelyOnPress={false} enabledWhenOffline sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.SPEND_RULE_SAVE} + buttonStyles={styles.flex1} + buttonAndFooterContainerStyles={[styles.flexRow, styles.gap2]} shouldRenderFooterAboveSubmit + footerContent={ + isNewRule && ( + + ) + } /> )} diff --git a/src/styles/index.ts b/src/styles/index.ts index 5dc78ee6118c..8cc7e07a083d 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6174,6 +6174,10 @@ const staticStyles = (theme: ThemeColors) => maxHeight: variables.agentRulePromptInputHeight, }, + describeRulePromptInput: { + height: variables.describeRulePromptInputHeight, + }, + emptyStateSamlIllustration: { width: 183, height: 160, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index bdaae0061cf3..b51d5cc7f000 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -381,6 +381,7 @@ export default { expensifyCardEmptyIllustrationHeight: 172, rulesNewMenuItemMinHeight: 84, agentRulePromptInputHeight: 340, + describeRulePromptInputHeight: 312, cardPreviewWidth: 235, cardDetailsActionButtonMinWidth: 140, cardScarfOverlayWidth: 264, diff --git a/src/types/form/NewRulePromptForm.ts b/src/types/form/NewRulePromptForm.ts new file mode 100644 index 000000000000..f7d791ddcbfd --- /dev/null +++ b/src/types/form/NewRulePromptForm.ts @@ -0,0 +1,19 @@ +import type {ValueOf} from 'type-fest'; + +import type Form from './Form'; + +const INPUT_IDS = { + PROMPT: 'prompt', +} as const; + +type InputID = ValueOf; + +type NewRulePromptForm = Form< + InputID, + { + [INPUT_IDS.PROMPT]: string; + } +>; + +export type {NewRulePromptForm}; +export default INPUT_IDS; diff --git a/src/types/form/index.ts b/src/types/form/index.ts index ae8f35a95920..dd91169079fe 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -130,6 +130,7 @@ export type {AddAgentForm} from './AddAgentForm'; export type {EditAgentNameForm} from './EditAgentNameForm'; export type {EditAgentPromptForm} from './EditAgentPromptForm'; export type {AddAgentRuleForm} from './AddAgentRuleForm'; +export type {NewRulePromptForm} from './NewRulePromptForm'; export type {EditAgentRuleForm} from './EditAgentRuleForm'; export type {RilletCredentialsForm} from './RilletCredentialsForm'; export type {DualEntryCredentialsForm} from './DualEntryCredentialsForm'; diff --git a/src/types/onyx/GeneratedRule.ts b/src/types/onyx/GeneratedRule.ts new file mode 100644 index 000000000000..f7543798f047 --- /dev/null +++ b/src/types/onyx/GeneratedRule.ts @@ -0,0 +1,28 @@ +import type CONST from '@src/CONST'; +import type {FlagForReviewRuleForm, MerchantRuleForm, RequireFieldsRuleForm, SpendRuleForm} from '@src/types/form'; + +import type {ValueOf} from 'type-fest'; + +/** The outcome of a generation attempt */ +type GeneratedRuleState = ValueOf; + +/** The rule type a description was mapped to */ +type GeneratedRuleType = ValueOf; + +/** The generated form values, seeding whichever draft ruleType names */ +type GeneratedRuleValues = Partial & Partial & Partial & Partial; + +/** Model of a rule generated from an admin's description */ +type GeneratedRule = { + /** The generation attempt this answer belongs to */ + generationID: string; + + /** The outcome of the generation attempt */ + state: GeneratedRuleState; + + /** The form values to seed the rule draft with */ + rule?: GeneratedRuleValues; +}; + +export default GeneratedRule; +export type {GeneratedRuleState, GeneratedRuleType, GeneratedRuleValues}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 6cbcf8970d18..9be5c2f5de6a 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -105,6 +105,7 @@ import type ExportTemplate from './ExportTemplate'; import type FrequentlyUsedEmoji from './FrequentlyUsedEmoji'; import type {FundList} from './Fund'; import type Fund from './Fund'; +import type GeneratedRule from './GeneratedRule'; import type GovernmentMileageRate from './GovernmentMileageRate'; import type GpsDraftDetails from './GpsDraftDetails'; import type HybridApp from './HybridApp'; @@ -279,6 +280,7 @@ export type { Domain, Download, ExportDownload, + GeneratedRule, DuplicateWorkspace, CopyPolicySettingsNVP, CopyPolicySettings,