diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 2861ee0950f5d..4c47962362480 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4221,6 +4221,24 @@ const CONST = { }, }, + SPEND_RULE_FORM: { + FIELDS: { + CARD_IDS: 'cardIDs', + RESTRICTION_ACTION: 'restrictionAction', + MERCHANT_NAMES: 'merchantNames', + MERCHANT_MATCH_TYPES: 'merchantMatchTypes', + CATEGORIES: 'categories', + MAX_AMOUNT: 'maxAmount', + }, + }, + + SPEND_CARD_RULE: { + ACTION: { + ALLOW: 'allow', + BLOCK: 'block', + }, + }, + get SUBSCRIPTION_PRICES() { return { [this.PAYMENT_CARD_CURRENCY.USD]: { @@ -9163,6 +9181,7 @@ const CONST = { THREE_DOT_MENU: 'WorkspaceAccounting-ThreeDotMenu', }, RULES: { + ADD_SPEND_RULE: 'WorkspaceRules-AddSpendRule', INDIVIDUAL_EXPENSES_MENU_ITEM: 'WorkspaceRules-IndividualExpensesMenuItem', SPEND_RULE_ITEM: 'WorkspaceRules-SpendRuleItem', MERCHANT_RULE_ITEM: 'WorkspaceRules-MerchantRuleItem', @@ -9172,6 +9191,9 @@ const CONST = { MERCHANT_RULE_PREVIEW_MATCHES: 'WorkspaceRules-MerchantRulePreviewMatches', MERCHANT_RULE_DELETE: 'WorkspaceRules-MerchantRuleDelete', CATEGORY_SELECTOR: 'WorkspaceRules-CategorySelector', + SPEND_RULE_SECTION_ITEM: 'WorkspaceRules-SpendRuleSectionItem', + SPEND_RULE_SAVE: 'WorkspaceRules-SpendRuleSave', + SPEND_RULE_RESTRICTION_TYPE: 'WorkspaceRules-SpendRuleRestrictionType', }, EXPENSIFY_CARD: { ISSUE_CARD_BUTTON: 'WorkspaceExpensifyCard-IssueCardButton', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 889b439411127..157afc5502267 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1063,6 +1063,8 @@ const ONYXKEYS = { EXPENSE_RULE_FORM_DRAFT: 'expenseRuleFormDraft', MERCHANT_RULE_FORM: 'merchantRuleForm', MERCHANT_RULE_FORM_DRAFT: 'merchantRuleFormDraft', + SPEND_RULE_FORM: 'spendRuleForm', + SPEND_RULE_FORM_DRAFT: 'spendRuleFormDraft', ADD_DOMAIN_MEMBER_FORM: 'addDomainMemberForm', ADD_DOMAIN_MEMBER_FORM_DRAFT: 'addDomainMemberFormDraft', ADD_WORK_EMAIL_FORM: 'addWorkEmailForm', @@ -1205,6 +1207,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.SPLIT_EXPENSE_EDIT_DATES]: FormTypes.SplitExpenseEditDateForm; [ONYXKEYS.FORMS.EXPENSE_RULE_FORM]: FormTypes.ExpenseRuleForm; [ONYXKEYS.FORMS.MERCHANT_RULE_FORM]: FormTypes.MerchantRuleForm; + [ONYXKEYS.FORMS.SPEND_RULE_FORM]: FormTypes.SpendRuleForm; [ONYXKEYS.FORMS.ADD_DOMAIN_MEMBER_FORM]: FormTypes.AddDomainMemberForm; [ONYXKEYS.FORMS.ADD_WORK_EMAIL_FORM]: FormTypes.AddWorkEmailForm; }; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 5bba8411bddb0..bec4f89cc475a 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2983,6 +2983,30 @@ const ROUTES = { route: 'workspaces/:policyID/rules/merchant-rules/new', getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new` as const, }, + RULES_SPEND_NEW: { + route: 'workspaces/:policyID/rules/spend-rules/new', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/spend-rules/new` as const, + }, + RULES_SPEND_CARD: { + route: 'workspaces/:policyID/rules/spend-rules/new/card', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/spend-rules/new/card` as const, + }, + RULES_SPEND_CATEGORY: { + route: 'workspaces/:policyID/rules/spend-rules/new/category', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/spend-rules/new/category` as const, + }, + RULES_SPEND_MAX_AMOUNT: { + route: 'workspaces/:policyID/rules/spend-rules/new/max-amount', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/spend-rules/new/max-amount` as const, + }, + RULES_SPEND_MERCHANTS: { + route: 'workspaces/:policyID/rules/spend-rules/new/merchants', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/spend-rules/new/merchants` as const, + }, + RULES_SPEND_MERCHANT_EDIT: { + route: 'workspaces/:policyID/rules/spend-rules/new/merchants/:merchantIndex', + getRoute: (policyID: string, merchantIndex: string) => `workspaces/${policyID}/rules/spend-rules/new/merchants/${merchantIndex}` as const, + }, RULES_MERCHANT_MERCHANT_TO_MATCH: { route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/merchant-to-match', getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/merchant-to-match` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 0bfb6e514ef04..abdbd0141cc5e 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -819,6 +819,10 @@ const SCREENS = { RULES_CUSTOM: 'Rules_Custom', RULES_PROHIBITED_DEFAULT: 'Rules_Prohibited_Default', RULES_MERCHANT_NEW: 'Rules_Merchant_New', + RULES_SPEND_NEW: 'Rules_Spend_New', + RULES_SPEND_CARD: 'Rules_Spend_Card', + RULES_SPEND_CATEGORY: 'Rules_Spend_Category', + RULES_SPEND_MAX_AMOUNT: 'Rules_Spend_Max_Amount', RULES_MERCHANT_MERCHANT_TO_MATCH: 'Rules_Merchant_Merchant_To_Match', RULES_MERCHANT_MATCH_TYPE: 'Rules_Merchant_Match_Type', RULES_MERCHANT_MERCHANT: 'Rules_Merchant_Merchant', @@ -830,6 +834,8 @@ const SCREENS = { RULES_MERCHANT_BILLABLE: 'Rules_Merchant_Billable', RULES_MERCHANT_PREVIEW_MATCHES: 'Rules_Merchant_Preview_Matches', RULES_MERCHANT_EDIT: 'Rules_Merchant_Edit', + RULES_SPEND_MERCHANTS: 'Rules_Spend_Merchants', + RULES_SPEND_MERCHANT_EDIT: 'Rules_Spend_Merchant_Edit', PER_DIEM: 'Per_Diem', PER_DIEM_IMPORT: 'Per_Diem_Import', PER_DIEM_IMPORTED: 'Per_Diem_Imported', diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 2280563fa3225..2177789961ef0 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -537,6 +537,7 @@ function BaseSelectionList({ canSelectMultiple={canSelectMultiple} onSelectAll={handleSelectAll} headerStyle={style?.listHeaderWrapperStyle} + selectAllTextStyle={style?.listHeaderSelectAllTextStyle} shouldShowSelectAllButton={!!onSelectAll} shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} /> diff --git a/src/components/SelectionList/components/ListHeader.tsx b/src/components/SelectionList/components/ListHeader.tsx index 976746329dd97..6f3553e3ebfb0 100644 --- a/src/components/SelectionList/components/ListHeader.tsx +++ b/src/components/SelectionList/components/ListHeader.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import type {StyleProp, ViewStyle} from 'react-native'; +import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import Checkbox from '@components/Checkbox'; import {PressableWithFeedback} from '@components/Pressable'; @@ -22,6 +22,9 @@ type ListHeaderProps = { /** Styles for the list header wrapper */ headerStyle?: StyleProp; + /** Styles for the "Select all" text (merged after textStrong) */ + selectAllTextStyle?: StyleProp; + /** Function called when the select all button is pressed */ onSelectAll: () => void; @@ -38,6 +41,7 @@ function ListHeader({ canSelectMultiple, onSelectAll, headerStyle, + selectAllTextStyle, shouldShowSelectAllButton, shouldPreventDefaultFocusOnSelectRow, }: ListHeaderProps) { @@ -84,7 +88,7 @@ function ListHeader({ dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} onMouseDown={handleMouseDown} > - {translate('workspace.people.selectAll')} + {translate('workspace.people.selectAll')} )} diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 8b6b8eca5300c..82a67f5d218fa 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -204,6 +204,9 @@ type SelectionListStyle = { /** Styles for the list header wrapper */ listHeaderWrapperStyle?: StyleProp; + /** Styles for the default "Select all" label in the list header (merged after textStrong) */ + listHeaderSelectAllTextStyle?: StyleProp; + /** Styles for the title container of the list item */ listItemTitleContainerStyles?: StyleProp; diff --git a/src/languages/de.ts b/src/languages/de.ts index 188abc0f135fa..0496e130b14c1 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6722,6 +6722,33 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu schützen.`, }, + addSpendRule: 'Ausgaberegel hinzufügen', + cardPageTitle: 'Karte', + cardsSectionTitle: 'Karten', + chooseCards: 'Karten auswählen', + saveRule: 'Regel speichern', + allow: 'Zulassen', + spendRuleSectionTitle: 'Ausgabenregel', + restrictionType: 'Beschränkungstyp', + restrictionTypeHelpAllow: 'Buchungen werden genehmigt, wenn sie einem beliebigen Händler oder einer Kategorie entsprechen und einen Höchstbetrag nicht überschreiten.', + restrictionTypeHelpBlock: 'Buchungen werden abgelehnt, wenn sie einem Händler oder einer Kategorie entsprechen oder einen Höchstbetrag überschreiten.', + addMerchant: 'Händler hinzufügen', + merchantContains: 'Händler enthält', + merchantExactlyMatches: 'Händler stimmt genau überein', + noBlockedMerchants: 'Keine blockierten Händler', + addMerchantToBlockSpend: 'Händler zum Sperren von Ausgaben hinzufügen', + noAllowedMerchants: 'Keine erlaubten Händler', + addMerchantToAllowSpend: 'Fügen Sie einen Händler hinzu, um Ausgaben zu ermöglichen', + matchType: 'Übereinstimmungstyp', + matchTypeContains: 'Enthält', + matchTypeExact: 'Stimmt genau überein', + spendCategory: 'Ausgabenkategorie', + maxAmount: 'Maximalbetrag', + maxAmountHelp: 'Jede Belastung über diesem Betrag wird abgelehnt, unabhängig von Händler- und Ausgabenkategorienbeschränkungen.', + currencyMismatchTitle: 'Währungsinkonsistenz', + currencyMismatchPrompt: 'Um einen Höchstbetrag festzulegen, wählen Sie Karten aus, die in derselben Währung abgerechnet werden.', + reviewSelectedCards: 'Ausgewählte Karten prüfen', + merchantsMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} weitere`, }, }, planTypePage: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 7b7fe7b4be528..3d229cef15756 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6699,6 +6699,33 @@ const translations = { title: 'Expensify Cards offer built-in protection - always', description: `Expensify always declines these charges:\n\n • Adult services\n • ATMs\n • Gambling\n • Money transfers\n\nAdd more spend rules to protect company cash flow.`, }, + addSpendRule: 'Add spend rule', + cardPageTitle: 'Card', + cardsSectionTitle: 'Cards', + chooseCards: 'Choose cards', + saveRule: 'Save rule', + allow: 'Allow', + spendRuleSectionTitle: 'Spend rule', + restrictionType: 'Restriction type', + restrictionTypeHelpAllow: "Charges are approved if they match any merchant or category, and don't exceed a max amount.", + restrictionTypeHelpBlock: 'Charges are declined if they match any merchant or category, or exceed a max amount.', + addMerchant: 'Add merchant', + merchantContains: 'Merchant contains', + merchantExactlyMatches: 'Merchant exactly matches', + noBlockedMerchants: 'No blocked merchants', + addMerchantToBlockSpend: 'Add a merchant to block spend', + noAllowedMerchants: 'No allowed merchants', + addMerchantToAllowSpend: 'Add a merchant to allow spend', + matchType: 'Match type', + matchTypeContains: 'Contains', + matchTypeExact: 'Matches exactly', + spendCategory: 'Spend category', + maxAmount: 'Max amount', + maxAmountHelp: 'Any charge over this amount will be declined, regardless of merchant and spend category restrictions.', + currencyMismatchTitle: 'Currency mismatch', + currencyMismatchPrompt: 'To set a max amount, select cards that settle in the same currency.', + reviewSelectedCards: 'Review selected cards', + merchantsMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} more`, }, }, planTypePage: { diff --git a/src/languages/es.ts b/src/languages/es.ts index ef63afc01de64..bf34551b6bb73 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6614,6 +6614,33 @@ ${amount} para ${merchant} - ${date}`, title: 'Las tarjetas Expensify ofrecen protección integrada, siempre', description: `Expensify siempre rechaza estos cargos:\n\n • Servicios para adultos\n • Cajeros automáticos\n • Juegos de azar\n • Transferencias de dinero\n\nAgregue más reglas de gasto para proteger el flujo de caja de la empresa.`, }, + addSpendRule: 'Añadir regla de gastos', + cardPageTitle: 'Tarjeta', + cardsSectionTitle: 'Tarjetas', + chooseCards: 'Elegir tarjetas', + saveRule: 'Guardar regla', + allow: 'Permitir', + spendRuleSectionTitle: 'Regla de gastos', + restrictionType: 'Tipo de restricción', + restrictionTypeHelpAllow: 'Los cargos se aprueban si coinciden con cualquier comerciante o categoría, y no superan un importe máximo.', + restrictionTypeHelpBlock: 'Los cargos se rechazan si coinciden con cualquier comerciante o categoría, o superan un importe máximo.', + addMerchant: 'Añadir comerciante', + merchantContains: 'El comerciante contiene', + merchantExactlyMatches: 'El comerciante coincide exactamente', + noBlockedMerchants: 'No hay comerciantes bloqueados', + addMerchantToBlockSpend: 'Añade un comerciante para bloquear gastos', + noAllowedMerchants: 'No hay comerciantes permitidos', + addMerchantToAllowSpend: 'Añade un comerciante para permitir gastos', + matchType: 'Tipo de coincidencia', + matchTypeContains: 'Contiene', + matchTypeExact: 'Coincide exactamente', + spendCategory: 'Categoría de gasto', + maxAmount: 'Importe máximo', + maxAmountHelp: 'Cualquier cargo por encima de este importe se rechazará, independientemente de las restricciones de comerciante y categoría de gasto.', + currencyMismatchTitle: 'Moneda no coincide', + currencyMismatchPrompt: 'Para establecer un importe máximo, selecciona tarjetas que se liquiden en la misma moneda.', + reviewSelectedCards: 'Revisar tarjetas seleccionadas', + merchantsMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} más`, }, }, }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index c2cdb8824a4ba..0ce1ecc920dc6 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6745,6 +6745,34 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’entreprise.`, }, + addSpendRule: 'Ajouter une règle de dépenses', + cardPageTitle: 'Carte', + cardsSectionTitle: 'Cartes', + chooseCards: 'Choisir des cartes', + saveRule: 'Enregistrer la règle', + allow: 'Autoriser', + spendRuleSectionTitle: 'Règle de dépense', + restrictionType: 'Type de restriction', + restrictionTypeHelpAllow: + 'Les frais sont approuvés s’ils correspondent à n’importe quel marchand ou à n’importe quelle catégorie, et qu’ils ne dépassent pas un montant maximal.', + restrictionTypeHelpBlock: 'Les frais sont refusés s’ils correspondent à un commerçant ou à une catégorie, ou s’ils dépassent un montant maximal.', + addMerchant: 'Ajouter un commerçant', + merchantContains: 'Le commerçant contient', + merchantExactlyMatches: 'Le commerçant correspond exactement', + noBlockedMerchants: 'Aucun commerçant bloqué', + addMerchantToBlockSpend: 'Ajouter un commerçant pour bloquer les dépenses', + noAllowedMerchants: 'Aucun commerçant autorisé', + addMerchantToAllowSpend: 'Ajoutez un commerçant pour autoriser les dépenses', + matchType: 'Type de correspondance', + matchTypeContains: 'Contient', + matchTypeExact: 'Correspond exactement', + spendCategory: 'Catégorie de dépense', + maxAmount: 'Montant maximal', + maxAmountHelp: 'Toute transaction supérieure à ce montant sera refusée, quels que soient le commerçant et les restrictions de catégorie de dépense.', + currencyMismatchTitle: 'Incompatibilité de devise', + currencyMismatchPrompt: 'Pour définir un montant maximal, sélectionnez des cartes qui se règlent dans la même devise.', + reviewSelectedCards: 'Examiner les cartes sélectionnées', + merchantsMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} de plus`, }, }, planTypePage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 9b66dddbc865e..642ddc57cd1b0 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6708,6 +6708,33 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, }, + addSpendRule: 'Aggiungi regola di spesa', + cardPageTitle: 'Carta', + cardsSectionTitle: 'Carte', + chooseCards: 'Scegli carte', + saveRule: 'Salva regola', + allow: 'Consenti', + spendRuleSectionTitle: 'Regola di spesa', + restrictionType: 'Tipo di restrizione', + restrictionTypeHelpAllow: 'Gli addebiti vengono approvati se corrispondono a un qualsiasi esercente o categoria e non superano un importo massimo.', + restrictionTypeHelpBlock: 'Gli addebiti vengono rifiutati se corrispondono a un esercente o a una categoria, oppure se superano un importo massimo.', + addMerchant: 'Aggiungi esercente', + merchantContains: 'Il commerciante contiene', + merchantExactlyMatches: 'Il nome dell’esercente corrisponde esattamente', + noBlockedMerchants: 'Nessun esercente bloccato', + addMerchantToBlockSpend: 'Aggiungi un esercente da bloccare', + noAllowedMerchants: 'Nessun esercente consentito', + addMerchantToAllowSpend: 'Aggiungi un esercente per consentire le spese', + matchType: 'Tipo di corrispondenza', + matchTypeContains: 'Contiene', + matchTypeExact: 'Corrisponde esattamente', + spendCategory: 'Categoria di spesa', + maxAmount: 'Importo massimo', + maxAmountHelp: 'Qualsiasi addebito superiore a questo importo verrà rifiutato, indipendentemente dalle restrizioni su esercente e categoria di spesa.', + currencyMismatchTitle: 'Valuta non corrispondente', + currencyMismatchPrompt: 'Per impostare un importo massimo, seleziona carte che si chiudono nella stessa valuta.', + reviewSelectedCards: 'Verifica le carte selezionate', + merchantsMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} in più`, }, }, planTypePage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 86b71e85951a1..7f6a37903b0f4 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6634,6 +6634,33 @@ ${reportName} 会社のキャッシュフローを守るために、支出ルールをさらに追加しましょう。`, }, + addSpendRule: '支出ルールを追加', + cardPageTitle: 'カード', + cardsSectionTitle: 'カード', + chooseCards: 'カードを選択', + saveRule: 'ルールを保存', + allow: '許可する', + spendRuleSectionTitle: '支出ルール', + restrictionType: '制限タイプ', + restrictionTypeHelpAllow: '加盟店またはカテゴリのいずれかに一致し、上限金額を超えない場合、チャージは承認されます。', + restrictionTypeHelpBlock: '加盟店やカテゴリに一致する場合、または上限金額を超えた場合は、請求は拒否されます。', + addMerchant: '支払先を追加', + merchantContains: '支払先に含まれる', + merchantExactlyMatches: '取引先が完全一致', + noBlockedMerchants: 'ブロックされている加盟店はありません', + addMerchantToBlockSpend: '支出をブロックする取引先を追加', + noAllowedMerchants: '許可されている加盟店はありません', + addMerchantToAllowSpend: '支出を許可する加盟店を追加してください', + matchType: 'マッチタイプ', + matchTypeContains: '含む', + matchTypeExact: '完全一致', + spendCategory: '支出カテゴリ', + maxAmount: '最大金額', + maxAmountHelp: '加盟店や支出カテゴリの制限にかかわらず、この金額を超えるすべての利用は承認されません。', + currencyMismatchTitle: '通貨の不一致', + currencyMismatchPrompt: '上限金額を設定するには、同じ通貨で精算されるカードを選択してください。', + reviewSelectedCards: '選択したカードを確認', + merchantsMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}、ほか +${count} 件`, }, }, planTypePage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 60385f41e8620..b7a8147a80be6 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6688,6 +6688,33 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar Voeg meer bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, }, + addSpendRule: 'Uitgaveregel toevoegen', + cardPageTitle: 'Kaart', + cardsSectionTitle: 'Kaarten', + chooseCards: 'Kaarten kiezen', + saveRule: 'Regel opslaan', + allow: 'Toestaan', + spendRuleSectionTitle: 'Bestedingsregel', + restrictionType: 'Restrictietype', + restrictionTypeHelpAllow: 'Kosten worden goedgekeurd als ze overeenkomen met een handelaar of categorie, en een maximumbedrag niet overschrijden.', + restrictionTypeHelpBlock: 'Transacties worden geweigerd als ze overeenkomen met een handelaar of categorie, of een maximumbedrag overschrijden.', + addMerchant: 'Winkel toevoegen', + merchantContains: 'Naam verkoper bevat', + merchantExactlyMatches: 'Merchant komt exact overeen', + noBlockedMerchants: 'Geen geblokkeerde handelaren', + addMerchantToBlockSpend: 'Voeg een handelaar toe om uitgaven te blokkeren', + noAllowedMerchants: 'Geen toegestane handelaren', + addMerchantToAllowSpend: 'Voeg een handelaar toe om uitgaven toe te staan', + matchType: 'Overeenkomsttype', + matchTypeContains: 'Bevat', + matchTypeExact: 'Komt exact overeen', + spendCategory: 'Uitgavecategorie', + maxAmount: 'Maximaal bedrag', + maxAmountHelp: 'Elke transactie boven dit bedrag wordt geweigerd, ongeacht beperkingen op verkoper en uitgavecategorie.', + currencyMismatchTitle: 'Valutaverschil', + currencyMismatchPrompt: 'Om een maximumbedrag in te stellen, selecteer kaarten die in dezelfde valuta worden verrekend.', + reviewSelectedCards: 'Geselecteerde kaarten controleren', + merchantsMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} extra`, }, }, planTypePage: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 3338a013920eb..100623f147e7c 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6681,6 +6681,33 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, }, + addSpendRule: 'Dodaj regułę wydatków', + cardPageTitle: 'Karta', + cardsSectionTitle: 'Karty', + chooseCards: 'Wybierz karty', + saveRule: 'Zapisz regułę', + allow: 'Zezwól', + spendRuleSectionTitle: 'Zasada wydatków', + restrictionType: 'Typ ograniczenia', + restrictionTypeHelpAllow: 'Opłaty są zatwierdzane, jeśli pasują do dowolnego sprzedawcy lub kategorii i nie przekraczają maksymalnej kwoty.', + restrictionTypeHelpBlock: 'Transakcje są odrzucane, jeśli pasują do jakiegokolwiek sprzedawcy lub kategorii albo przekraczają maksymalną kwotę.', + addMerchant: 'Dodaj sprzedawcę', + merchantContains: 'Sprzedawca zawiera', + merchantExactlyMatches: 'Sprzedawca jest dokładnie taki sam', + noBlockedMerchants: 'Brak zablokowanych sprzedawców', + addMerchantToBlockSpend: 'Dodaj sprzedawcę, aby zablokować wydatki', + noAllowedMerchants: 'Brak dozwolonych sprzedawców', + addMerchantToAllowSpend: 'Dodaj sprzedawcę, aby umożliwić wydatki', + matchType: 'Typ dopasowania', + matchTypeContains: 'Zawiera', + matchTypeExact: 'Dokładne dopasowania', + spendCategory: 'Kategoria wydatków', + maxAmount: 'Maksymalna kwota', + maxAmountHelp: 'Każda transakcja powyżej tej kwoty zostanie odrzucona, niezależnie od ograniczeń sprzedawcy i kategorii wydatków.', + currencyMismatchTitle: 'Niezgodność waluty', + currencyMismatchPrompt: 'Aby ustawić maksymalną kwotę, wybierz karty rozliczane w tej samej walucie.', + reviewSelectedCards: 'Przejrzyj wybrane karty', + merchantsMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} więcej`, }, }, planTypePage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index a11fd86a0d46f..3b07cfbbb3c0b 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6686,6 +6686,33 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, + addSpendRule: 'Adicionar regra de gasto', + cardPageTitle: 'Cartão', + cardsSectionTitle: 'Cartões', + chooseCards: 'Escolher cartões', + saveRule: 'Salvar regra', + allow: 'Permitir', + spendRuleSectionTitle: 'Regra de gasto', + restrictionType: 'Tipo de restrição', + restrictionTypeHelpAllow: 'Os lançamentos são aprovados se corresponderem a qualquer comerciante ou categoria e não ultrapassarem um valor máximo.', + restrictionTypeHelpBlock: 'As cobranças são recusadas se corresponderem a qualquer comerciante ou categoria, ou se excederem um valor máximo.', + addMerchant: 'Adicionar comerciante', + merchantContains: 'Fornecedor contém', + merchantExactlyMatches: 'Estabelecimento corresponde exatamente', + noBlockedMerchants: 'Nenhum comerciante bloqueado', + addMerchantToBlockSpend: 'Adicionar um comerciante para bloquear gastos', + noAllowedMerchants: 'Nenhum comerciante permitido', + addMerchantToAllowSpend: 'Adicione um comerciante para permitir gastos', + matchType: 'Tipo de correspondência', + matchTypeContains: 'Contém', + matchTypeExact: 'Corresponde exatamente', + spendCategory: 'Categoria de gasto', + maxAmount: 'Valor máximo', + maxAmountHelp: 'Qualquer cobrança acima desse valor será recusada, independentemente das restrições de comerciante e categoria de gasto.', + currencyMismatchTitle: 'Moeda incompatível', + currencyMismatchPrompt: 'Para definir um valor máximo, selecione cartões que sejam liquidados na mesma moeda.', + reviewSelectedCards: 'Revisar cartões selecionados', + merchantsMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} a mais`, }, }, planTypePage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 0fdb5a5368936..e5c40c1c88140 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6521,6 +6521,33 @@ ${reportName} 添加更多消费规则以保护公司现金流。`, }, + addSpendRule: '添加支出规则', + cardPageTitle: '卡', + cardsSectionTitle: '卡片', + chooseCards: '选择卡片', + saveRule: '保存规则', + allow: '允许', + spendRuleSectionTitle: '支出规则', + restrictionType: '限制类型', + restrictionTypeHelpAllow: '如果符合任一商户或类别,且未超过最大金额,则费用将被批准。', + restrictionTypeHelpBlock: '如果交易符合任一商户或类别,或超过最高金额,将被拒绝。', + addMerchant: '添加商户', + merchantContains: '商家包含', + merchantExactlyMatches: '商户完全匹配', + noBlockedMerchants: '没有被屏蔽的商户', + addMerchantToBlockSpend: '添加要限制消费的商户', + noAllowedMerchants: '没有允许的商家', + addMerchantToAllowSpend: '添加商户以允许消费', + matchType: '匹配类型', + matchTypeContains: '包含', + matchTypeExact: '完全匹配', + spendCategory: '支出类别', + maxAmount: '最高金额', + maxAmountHelp: '无论商户或支出类别限制如何,任何超过此金额的消费都会被拒绝。', + currencyMismatchTitle: '货币不匹配', + currencyMismatchPrompt: '要设置最高金额,请选择以同一货币结算的卡。', + reviewSelectedCards: '查看已选择的卡片', + merchantsMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary},以及另外 +${count} 项`, }, }, planTypePage: { diff --git a/src/libs/API/parameters/SetExpensifyCardRuleParams.ts b/src/libs/API/parameters/SetExpensifyCardRuleParams.ts new file mode 100644 index 0000000000000..ab47148ef022f --- /dev/null +++ b/src/libs/API/parameters/SetExpensifyCardRuleParams.ts @@ -0,0 +1,7 @@ +type SetExpensifyCardRuleParams = { + domainAccountID: number; + cardRuleID: string; + cardRuleValue: string; +}; + +export default SetExpensifyCardRuleParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 6883fe16b5cf8..f30daf39b949f 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -354,6 +354,7 @@ export type {default as DisablePolicyBillableModeParams} from './DisablePolicyBi export type {default as SetWorkspaceEReceiptsEnabled} from './SetWorkspaceEReceiptsEnabled'; export type {default as SetPolicyAttendeeTrackingEnabledParams} from './SetPolicyAttendeeTrackingEnabledParams'; export type {default as ConfigureExpensifyCardsForPolicyParams} from './ConfigureExpensifyCardsForPolicyParams'; +export type {default as SetExpensifyCardRuleParams} from './SetExpensifyCardRuleParams'; export type {default as CreateExpensifyCardParams} from './CreateExpensifyCardParams'; export type {default as UpdateExpensifyCardTitleParams} from './UpdateExpensifyCardTitleParams'; export type {default as AddDelegateParams} from './AddDelegateParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index cd07177133cda..8f9590d50deb0 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -469,6 +469,7 @@ const WRITE_COMMANDS = { UPDATE_WORKSPACE_APPROVAL: 'UpdateWorkspaceApproval', REMOVE_WORKSPACE_APPROVAL: 'RemoveWorkspaceApproval', CONFIGURE_EXPENSIFY_CARDS_FOR_POLICY: 'ConfigureExpensifyCardsForPolicy', + SET_EXPENSIFY_CARD_RULE: 'SetExpensifyCardRule', CREATE_EXPENSIFY_CARD: 'CreateExpensifyCard', CREATE_ADMIN_ISSUED_VIRTUAL_CARD: 'CreateAdminIssuedVirtualCard', QUEUE_EXPENSIFY_CARD_FOR_BILLING: 'Domain_QueueExpensifyCardForBilling', @@ -1077,6 +1078,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_WORKSPACE_APPROVAL]: Parameters.UpdateWorkspaceApprovalParams; [WRITE_COMMANDS.REMOVE_WORKSPACE_APPROVAL]: Parameters.RemoveWorkspaceApprovalParams; [WRITE_COMMANDS.CONFIGURE_EXPENSIFY_CARDS_FOR_POLICY]: Parameters.ConfigureExpensifyCardsForPolicyParams; + [WRITE_COMMANDS.SET_EXPENSIFY_CARD_RULE]: Parameters.SetExpensifyCardRuleParams; [WRITE_COMMANDS.CREATE_EXPENSIFY_CARD]: Omit; [WRITE_COMMANDS.CREATE_ADMIN_ISSUED_VIRTUAL_CARD]: Omit; [WRITE_COMMANDS.QUEUE_EXPENSIFY_CARD_FOR_BILLING]: Parameters.QueueExpensifyCardForBillingParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 644fecedb0192..d567bc7cc1723 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -891,6 +891,12 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/RulesCustomPage').default, [SCREENS.WORKSPACE.RULES_PROHIBITED_DEFAULT]: () => require('../../../../pages/workspace/rules/RulesProhibitedDefaultPage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantRulePage').default, + [SCREENS.WORKSPACE.RULES_SPEND_NEW]: () => require('../../../../pages/workspace/rules/SpendRules/AddSpendRulePage').default, + [SCREENS.WORKSPACE.RULES_SPEND_CARD]: () => require('../../../../pages/workspace/rules/SpendRules/SpendRuleCardPage').default, + [SCREENS.WORKSPACE.RULES_SPEND_CATEGORY]: () => require('../../../../pages/workspace/rules/SpendRules/SpendRuleCategoryPage').default, + [SCREENS.WORKSPACE.RULES_SPEND_MAX_AMOUNT]: () => require('../../../../pages/workspace/rules/SpendRules/SpendRuleMaxAmountPage').default, + [SCREENS.WORKSPACE.RULES_SPEND_MERCHANTS]: () => require('../../../../pages/workspace/rules/SpendRules/SpendRuleMerchantsPage').default, + [SCREENS.WORKSPACE.RULES_SPEND_MERCHANT_EDIT]: () => require('../../../../pages/workspace/rules/SpendRules/SpendRuleMerchantEditPage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantToMatchPage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_MATCH_TYPE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMatchTypePage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 645407d89e53c..bff93aff8f249 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -295,6 +295,12 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { path: ROUTES.RULES_MERCHANT_NEW.route, }, + [SCREENS.WORKSPACE.RULES_SPEND_NEW]: { + path: ROUTES.RULES_SPEND_NEW.route, + }, + [SCREENS.WORKSPACE.RULES_SPEND_CARD]: { + path: ROUTES.RULES_SPEND_CARD.route, + }, + [SCREENS.WORKSPACE.RULES_SPEND_CATEGORY]: { + path: ROUTES.RULES_SPEND_CATEGORY.route, + }, + [SCREENS.WORKSPACE.RULES_SPEND_MAX_AMOUNT]: { + path: ROUTES.RULES_SPEND_MAX_AMOUNT.route, + }, + [SCREENS.WORKSPACE.RULES_SPEND_MERCHANTS]: { + path: ROUTES.RULES_SPEND_MERCHANTS.route, + }, + [SCREENS.WORKSPACE.RULES_SPEND_MERCHANT_EDIT]: { + path: ROUTES.RULES_SPEND_MERCHANT_EDIT.route, + }, [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH]: { path: ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index d2f0b316038d6..21291afe265f6 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1474,6 +1474,25 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { policyID: string; }; + [SCREENS.WORKSPACE.RULES_SPEND_NEW]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_SPEND_CARD]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_SPEND_CATEGORY]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_SPEND_MAX_AMOUNT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_SPEND_MERCHANTS]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_SPEND_MERCHANT_EDIT]: { + policyID: string; + merchantIndex: string; + }; [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH]: { policyID: string; ruleID: string; diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 41ed86a7e8762..4c3155f39ab20 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -12,6 +12,7 @@ import type { RequestReplacementExpensifyCardParams, ResolveFraudAlertParams, RevealExpensifyCardDetailsParams, + SetExpensifyCardRuleParams, SetPersonalCardReimbursableParams, StartIssueNewCardFlowParams, UnassignCardParams, @@ -1555,6 +1556,23 @@ function queueExpensifyCardForBilling(feedCountry: string, domainAccountID: numb API.write(WRITE_COMMANDS.QUEUE_EXPENSIFY_CARD_FOR_BILLING, parameters); } +function setExpensifyCardRule(parameters: SetExpensifyCardRuleParams) { + API.write(WRITE_COMMANDS.SET_EXPENSIFY_CARD_RULE, parameters); +} + +function getSpendCardRuleValueJSON(cardIDStrings: string[], action: ValueOf) { + const numericIDs = cardIDStrings.map((id) => Number(id)).filter((id) => Number.isFinite(id)); + return JSON.stringify({ + created: new Date().toISOString(), + filters: { + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + right: numericIDs, + }, + action, + }); +} + /** * Resolves a fraud alert for a given card. * When the user clicks on the whisper it sets the optimistic data to the resolution and calls the API @@ -1656,5 +1674,7 @@ export { clearIssueNewCardFormData, setDraftInviteAccountID, resolveFraudAlert, + setExpensifyCardRule, + getSpendCardRuleValueJSON, }; export type {ReplacementReason}; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index e161fa3572414..0b1bec9825fbd 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -48,7 +48,7 @@ import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {ExpenseRuleForm, MerchantRuleForm} from '@src/types/form'; +import type {ExpenseRuleForm, MerchantRuleForm, SpendRuleForm} from '@src/types/form'; import type {AppReview, BlockedFromConcierge, CustomStatusDraft, ExpenseRule, Policy, ReportAttributesDerivedValue} from '@src/types/onyx'; import type Login from '@src/types/onyx/Login'; import type {Errors} from '@src/types/onyx/OnyxCommon'; @@ -1877,6 +1877,18 @@ function clearDraftMerchantRule() { Onyx.set(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, null); } +function setDraftSpendRule(ruleData: Partial) { + Onyx.set(ONYXKEYS.FORMS.SPEND_RULE_FORM, ruleData); +} + +function updateDraftSpendRule(ruleData: Partial) { + Onyx.merge(ONYXKEYS.FORMS.SPEND_RULE_FORM, ruleData); +} + +function clearDraftSpendRule() { + Onyx.set(ONYXKEYS.FORMS.SPEND_RULE_FORM, null); +} + export { closeAccount, setServerErrorsOnForm, @@ -1928,6 +1940,9 @@ export { setDraftMerchantRule, updateDraftMerchantRule, clearDraftMerchantRule, + setDraftSpendRule, + updateDraftSpendRule, + clearDraftSpendRule, openTroubleshootSettingsPage, openMultifactorAuthenticationRevokePage, }; diff --git a/src/pages/workspace/rules/PolicyRulesPage.tsx b/src/pages/workspace/rules/PolicyRulesPage.tsx index 4a0741f8e8b5d..1685065dc958e 100644 --- a/src/pages/workspace/rules/PolicyRulesPage.tsx +++ b/src/pages/workspace/rules/PolicyRulesPage.tsx @@ -56,7 +56,7 @@ function PolicyRulesPage({route}: PolicyRulesPageProps) { - {!!policy?.areExpensifyCardsEnabled && } + {!!policy?.areExpensifyCardsEnabled && } diff --git a/src/pages/workspace/rules/SpendRules/AddSpendRulePage.tsx b/src/pages/workspace/rules/SpendRules/AddSpendRulePage.tsx new file mode 100644 index 0000000000000..faa25375a5ffd --- /dev/null +++ b/src/pages/workspace/rules/SpendRules/AddSpendRulePage.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import type SCREENS from '@src/SCREENS'; +import SpendRulePageBase from './SpendRulePageBase'; + +type AddSpendRulePageProps = PlatformStackScreenProps; + +function AddSpendRulePage({route}: AddSpendRulePageProps) { + return ( + + ); +} + +AddSpendRulePage.displayName = 'AddSpendRulePage'; + +export default AddSpendRulePage; diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx new file mode 100644 index 0000000000000..94fd9212ada5f --- /dev/null +++ b/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx @@ -0,0 +1,233 @@ +import {useFocusEffect} from '@react-navigation/native'; +import React, {useCallback, useEffect, useState} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import BlockingView from '@components/BlockingViews/BlockingView'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import CardListItem from '@components/SelectionList/ListItem/CardListItem'; +import type {AdditionalCardProps} from '@components/SelectionList/ListItem/CardListItem'; +import type {ListItem} from '@components/SelectionList/types'; +import {useCompanyCardFeedIcons} from '@hooks/useCompanyCardIcons'; +import useDefaultFundID from '@hooks/useDefaultFundID'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import useSearchResults from '@hooks/useSearchResults'; +import useThemeIllustrations from '@hooks/useThemeIllustrations'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {updateDraftSpendRule} from '@libs/actions/User'; +import {filterCardsByPersonalDetails, filterInactiveCards, getCardFeedIcon, sortCardsByCardholderName} from '@libs/CardUtils'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {getHeaderMessage} from '@libs/OptionsListUtils'; +import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import variables from '@styles/variables'; +import {openPolicyExpensifyCardsPage} from '@userActions/Policy/Policy'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {Card, ExpensifyCardSettings, WorkspaceCardsList} from '@src/types/onyx'; +import type {ExpensifyCardRule, ExpensifyCardRuleFilter} from '@src/types/onyx/ExpensifyCardSettings'; + +type ExpensifyCardListItem = ListItem & + AdditionalCardProps & { + card: Card; + }; + +type SpendRuleCardPageProps = PlatformStackScreenProps; + +function getCardIDsWithSpendRules(cardRules: Record | undefined): Set { + const cardIDs = new Set(); + if (!cardRules) { + return cardIDs; + } + + const traverseFilters = (filters: ExpensifyCardRuleFilter) => { + if (filters.operator === CONST.SEARCH.SYNTAX_OPERATORS.AND || filters.operator === CONST.SEARCH.SYNTAX_OPERATORS.OR) { + traverseFilters(filters.left as ExpensifyCardRuleFilter); + traverseFilters(filters.right as ExpensifyCardRuleFilter); + return; + } + + if (filters.left === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID && filters.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO && Array.isArray(filters.right)) { + for (const cardID of filters.right) { + cardIDs.add(cardID); + } + } + }; + + for (const rule of Object.values(cardRules)) { + traverseFilters(rule.filters); + } + + return cardIDs; +} + +function getEligibleCards(cardsList: OnyxEntry, expensifyCardSettings: ExpensifyCardSettings) { + const {cardList, ...cards} = cardsList ?? {}; + const cardIDsWithSpendRules = getCardIDsWithSpendRules(expensifyCardSettings?.cardRules); + return Object.values(cards).filter((card: Card) => !cardIDsWithSpendRules.has(card.cardID)); +} + +function SpendRuleCardPage({route}: SpendRuleCardPageProps) { + const {policyID} = route.params; + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + const defaultFundID = useDefaultFundID(policyID); + const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards}); + const [expensifyCardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${defaultFundID}`); + const [spendRuleForm] = useOnyx(ONYXKEYS.FORMS.SPEND_RULE_FORM); + const illustrations = useMemoizedLazyIllustrations(['Telescope']); + const themeIllustrations = useThemeIllustrations(); + const companyCardFeedIcons = useCompanyCardFeedIcons(); + + const [selectedCardIDs, setSelectedCardIDs] = useState([]); + + useFocusEffect( + useCallback(() => { + setSelectedCardIDs(spendRuleForm?.cardIDs ?? []); + }, [spendRuleForm?.cardIDs]), + ); + + const eligibleCards = getEligibleCards(cardsList, expensifyCardSettings ?? {}); + + const filterCard = (card: Card, searchInput: string) => filterCardsByPersonalDetails(card, searchInput, personalDetails); + const sortCards = (cards: Card[]) => sortCardsByCardholderName(cards, personalDetails, localeCompare); + + const [inputValue, setInputValue, filteredCards] = useSearchResults(eligibleCards, filterCard, sortCards); + + const listData: ExpensifyCardListItem[] = filteredCards.map((card) => { + const accountID = card.accountID ?? CONST.DEFAULT_NUMBER_ID; + const cardOwnerPersonalDetails = personalDetails?.[accountID] ?? undefined; + const cardName = card.nameValuePairs?.cardTitle; + const displayName = getDisplayNameOrDefault(cardOwnerPersonalDetails, '', false); + return { + keyForList: String(card.cardID), + text: displayName !== '' ? displayName : (cardName ?? ''), + accountID, + card, + lastFourPAN: card.lastFourPAN, + isVirtual: !!card.nameValuePairs?.isVirtual, + shouldShowOwnersAvatar: true, + cardOwnerPersonalDetails, + bankIcon: { + icon: getCardFeedIcon(card.bank, themeIllustrations, companyCardFeedIcons), + }, + }; + }); + + useEffect(() => { + openPolicyExpensifyCardsPage(policyID, defaultFundID); + }, [defaultFundID, policyID]); + + useNetwork({ + onReconnect: () => { + openPolicyExpensifyCardsPage(policyID, defaultFundID); + }, + }); + + const toggleCard = (item: ExpensifyCardListItem) => { + setSelectedCardIDs((prev) => { + if (prev.includes(item.keyForList)) { + return prev.filter((id) => id !== item.keyForList); + } + return [...prev, item.keyForList]; + }); + }; + + const toggleSelectAll = () => { + const visibleKeys = listData.map((item) => item.keyForList); + const allVisibleSelected = visibleKeys.length > 0 && visibleKeys.every((key) => selectedCardIDs.includes(key)); + if (allVisibleSelected) { + const visibleSet = new Set(visibleKeys); + setSelectedCardIDs((prev) => prev.filter((id) => !visibleSet.has(id))); + return; + } + setSelectedCardIDs((prev) => { + const next = new Set(prev); + for (const key of visibleKeys) { + next.add(key); + } + return Array.from(next); + }); + }; + + const handleSave = () => { + updateDraftSpendRule({cardIDs: selectedCardIDs}); + Navigation.goBack(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); + }; + + const headerMessage = getHeaderMessage(listData.length > 0, false, inputValue, countryCode, false); + + return ( + + + Navigation.goBack(ROUTES.RULES_SPEND_NEW.getRoute(policyID))} + /> + 0 ? toggleSelectAll : undefined} + onCheckboxPress={toggleCard} + onSelectRow={toggleCard} + selectedItems={selectedCardIDs} + ListItem={CardListItem} + shouldUseDefaultRightHandSideCheckmark={false} + shouldUpdateFocusedIndex + shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} + listEmptyContent={ + + } + footerContent={ + + } + /> + + + ); +} + +SpendRuleCardPage.displayName = 'SpendRuleCardPage'; + +export default SpendRuleCardPage; diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleCategoryPage.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleCategoryPage.tsx new file mode 100644 index 0000000000000..f1c1205aad775 --- /dev/null +++ b/src/pages/workspace/rules/SpendRules/SpendRuleCategoryPage.tsx @@ -0,0 +1,162 @@ +import {useFocusEffect} from '@react-navigation/native'; +import React, {useCallback, useMemo, useState} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import BlockingView from '@components/BlockingViews/BlockingView'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import MultiSelectListItem from '@components/SelectionList/ListItem/MultiSelectListItem'; +import type {ListItem} from '@components/SelectionList/types'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useSearchResults from '@hooks/useSearchResults'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {updateDraftSpendRule} from '@libs/actions/User'; +import {getDecodedCategoryName} from '@libs/CategoryUtils'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type {PolicyCategories, PolicyCategory} from '@src/types/onyx'; + +type CategoryListItem = ListItem & { + value: string; +}; + +type SpendRuleCategoryPageProps = PlatformStackScreenProps; + +function getEnabledCategories(policyCategories: OnyxEntry): PolicyCategory[] { + return Object.values(policyCategories ?? {}).filter((category) => category.enabled && category.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE); +} + +function SpendRuleCategoryPage({route}: SpendRuleCategoryPageProps) { + const {policyID} = route.params; + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + const illustrations = useMemoizedLazyIllustrations(['Telescope']); + + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`); + const [spendRuleForm] = useOnyx(ONYXKEYS.FORMS.SPEND_RULE_FORM); + + const [selectedCategories, setSelectedCategories] = useState([]); + + useFocusEffect( + useCallback(() => { + setSelectedCategories(spendRuleForm?.categories ?? []); + }, [spendRuleForm?.categories]), + ); + + const categoryItems = useMemo(() => { + return getEnabledCategories(policyCategories).map((category) => { + const decodedName = getDecodedCategoryName(category.name); + return { + keyForList: category.name, + text: decodedName, + value: category.name, + }; + }); + }, [policyCategories]); + + const filterCategory = (item: CategoryListItem, searchInput: string) => (item.text ?? '').toLowerCase().includes(searchInput.toLowerCase()); + const sortCategories = (items: CategoryListItem[]) => items.sort((a, b) => localeCompare(a.text ?? '', b.text ?? '')); + + const [inputValue, setInputValue, filteredCategoryItems] = useSearchResults(categoryItems, filterCategory, sortCategories); + + const listData: CategoryListItem[] = filteredCategoryItems.map((item) => ({ + ...item, + isSelected: selectedCategories.includes(item.value), + })); + + const toggleCategory = (item: CategoryListItem) => { + setSelectedCategories((prev) => { + if (prev.includes(item.value)) { + return prev.filter((categoryName) => categoryName !== item.value); + } + return [...prev, item.value]; + }); + }; + + const toggleSelectAll = () => { + const visibleValues = listData.map((item) => item.value); + const allVisibleSelected = visibleValues.length > 0 && visibleValues.every((value) => selectedCategories.includes(value)); + if (allVisibleSelected) { + const visibleSet = new Set(visibleValues); + setSelectedCategories((prev) => prev.filter((value) => !visibleSet.has(value))); + return; + } + setSelectedCategories((prev) => { + const next = new Set(prev); + for (const value of visibleValues) { + next.add(value); + } + return Array.from(next); + }); + }; + + const handleSave = () => { + updateDraftSpendRule({categories: selectedCategories}); + Navigation.goBack(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); + }; + + return ( + + Navigation.goBack(ROUTES.RULES_SPEND_NEW.getRoute(policyID))} + /> + 0 ? toggleSelectAll : undefined} + onCheckboxPress={toggleCategory} + onSelectRow={toggleCategory} + selectedItems={selectedCategories} + ListItem={MultiSelectListItem} + shouldUpdateFocusedIndex + shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} + listEmptyContent={ + + } + footerContent={ + + } + /> + + ); +} + +SpendRuleCategoryPage.displayName = 'SpendRuleCategoryPage'; + +export default SpendRuleCategoryPage; diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleMaxAmountPage.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleMaxAmountPage.tsx new file mode 100644 index 0000000000000..0c173321bfa89 --- /dev/null +++ b/src/pages/workspace/rules/SpendRules/SpendRuleMaxAmountPage.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import AmountForm from '@components/AmountForm'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useDefaultFundID from '@hooks/useDefaultFundID'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {updateDraftSpendRule} from '@libs/actions/User'; +import {filterInactiveCards, isCard} from '@libs/CardUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/SpendRuleForm'; +import type {Card, WorkspaceCardsList} from '@src/types/onyx'; + +type SpendRuleMaxAmountPageProps = PlatformStackScreenProps; + +function getSelectedCardsCurrency(cardIDs: string[] | undefined, cardsList: OnyxEntry): string | undefined { + if (!cardIDs?.length) { + return undefined; + } + + const cardsRecord = (cardsList ?? {}) as Record | undefined>; + const currencies = new Set(); + for (const id of cardIDs) { + const card = cardsRecord[id]; + if (card === undefined || !isCard(card)) { + continue; + } + if (typeof card.nameValuePairs?.currency === 'string' && card.nameValuePairs.currency) { + currencies.add(String(card.nameValuePairs.currency)); + } + } + + if (currencies.size !== 1) { + return undefined; + } + return Array.from(currencies).at(0); +} + +function SpendRuleMaxAmountPage({route}: SpendRuleMaxAmountPageProps) { + const {policyID} = route.params; + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {inputCallbackRef} = useAutoFocusInput(); + const domainAccountID = useDefaultFundID(policyID); + + const [spendRuleForm] = useOnyx(ONYXKEYS.FORMS.SPEND_RULE_FORM); + const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${domainAccountID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards}); + + const selectedCurrency = getSelectedCardsCurrency(spendRuleForm?.cardIDs, cardsList) ?? CONST.CURRENCY.USD; + const defaultValue = spendRuleForm?.maxAmount ?? ''; + + return ( + + + Navigation.goBack(ROUTES.RULES_SPEND_NEW.getRoute(policyID))} + /> + { + updateDraftSpendRule({maxAmount: maxAmount.trim()}); + Navigation.goBack(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); + }} + submitButtonText={translate('common.save')} + enabledWhenOffline + shouldHideFixErrorsAlert + addBottomSafeAreaPadding + > + + + {translate('workspace.rules.spendRules.maxAmountHelp')} + + + + + ); +} + +SpendRuleMaxAmountPage.displayName = 'SpendRuleMaxAmountPage'; + +export default SpendRuleMaxAmountPage; diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleMerchantEditPage.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleMerchantEditPage.tsx new file mode 100644 index 0000000000000..761683f687f9a --- /dev/null +++ b/src/pages/workspace/rules/SpendRules/SpendRuleMerchantEditPage.tsx @@ -0,0 +1,163 @@ +import React, {useState} from 'react'; +import {View} from 'react-native'; +import type {ValueOf} from 'type-fest'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import type {ListItem} from '@components/SelectionList/ListItem/types'; +import Text from '@components/Text'; +import TextInput from '@components/TextInput'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {updateDraftSpendRule} from '@libs/actions/User'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import INPUT_IDS from '@src/types/form/SpendRuleForm'; + +type SpendRuleMerchantEditPageProps = PlatformStackScreenProps; + +type MatchTypeItem = ListItem & { + value: ValueOf; +}; + +function SpendRuleMerchantEditPage({route}: SpendRuleMerchantEditPageProps) { + const {policyID, merchantIndex} = route.params; + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {inputCallbackRef} = useAutoFocusInput(); + const [spendRuleForm] = useOnyx(ONYXKEYS.FORMS.SPEND_RULE_FORM); + + const merchantNames = spendRuleForm?.merchantNames ?? []; + const merchantMatchTypes = spendRuleForm?.merchantMatchTypes ?? []; + const isNew = merchantIndex === ROUTES.NEW; + const index = isNew ? -1 : Number(merchantIndex); + const existingMerchantName = isNew ? undefined : merchantNames.at(index); + const existingMerchantMatchType = isNew ? undefined : merchantMatchTypes.at(index); + + const [merchantName, setMerchantName] = useState(existingMerchantName ?? ''); + const [matchType, setMatchType] = useState>(existingMerchantMatchType ?? CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS); + + const goBack = () => { + Navigation.goBack(ROUTES.RULES_SPEND_MERCHANTS.getRoute(policyID)); + }; + + const submit = () => { + const trimmedMerchantName = merchantName.trim(); + if (!trimmedMerchantName) { + if (!isNew) { + const updatedMerchantNames = merchantNames.filter((_, merchantArrayIndex) => merchantArrayIndex !== index); + const updatedMerchantMatchTypes = merchantMatchTypes.filter((_, merchantArrayIndex) => merchantArrayIndex !== index); + updateDraftSpendRule({merchantNames: updatedMerchantNames, merchantMatchTypes: updatedMerchantMatchTypes}); + } + goBack(); + return; + } + + const updatedMerchantNames = isNew + ? [...merchantNames, trimmedMerchantName] + : merchantNames.map((name, merchantArrayIndex) => (merchantArrayIndex === index ? trimmedMerchantName : name)); + + const updatedMerchantMatchTypes = isNew + ? [...merchantMatchTypes, matchType] + : merchantMatchTypes.map((type, merchantArrayIndex) => (merchantArrayIndex === index ? matchType : type)); + updateDraftSpendRule({merchantNames: updatedMerchantNames, merchantMatchTypes: updatedMerchantMatchTypes}); + goBack(); + }; + + const matchTypeItems: MatchTypeItem[] = [ + { + value: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + keyForList: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + text: translate('workspace.rules.merchantRules.matchTypeContains'), + isSelected: matchType === CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + }, + { + value: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + keyForList: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + text: translate('workspace.rules.merchantRules.matchTypeExact'), + isSelected: matchType === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + }, + ]; + + const onSelectMatchType = (item: MatchTypeItem) => { + const nextMatchType = item.value; + setMatchType(nextMatchType); + + if (isNew) { + return; + } + + if (!merchantMatchTypes.at(index)) { + return; + } + + const updatedMerchantMatchTypes = merchantMatchTypes.map((type, merchantArrayIndex) => (merchantArrayIndex === index ? nextMatchType : type)); + updateDraftSpendRule({merchantMatchTypes: updatedMerchantMatchTypes}); + }; + + return ( + + + + + + + + + {translate('workspace.rules.spendRules.matchType')} + + + + + + ); +} + +SpendRuleMerchantEditPage.displayName = 'SpendRuleMerchantEditPage'; + +export default SpendRuleMerchantEditPage; diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleMerchantsPage.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleMerchantsPage.tsx new file mode 100644 index 0000000000000..93e259a6758ff --- /dev/null +++ b/src/pages/workspace/rules/SpendRules/SpendRuleMerchantsPage.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import BlockingView from '@components/BlockingViews/BlockingView'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItem from '@components/MenuItem'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type SpendRuleMerchantsPageProps = PlatformStackScreenProps; + +function SpendRuleMerchantsPage({route}: SpendRuleMerchantsPageProps) { + const {policyID} = route.params; + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const [spendRuleForm] = useOnyx(ONYXKEYS.FORMS.SPEND_RULE_FORM); + const illustrations = useMemoizedLazyIllustrations(['EmptyStateExpenses']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus']); + + const restrictionAction = spendRuleForm?.restrictionAction ?? CONST.SPEND_CARD_RULE.ACTION.ALLOW; + const merchantNames = spendRuleForm?.merchantNames ?? []; + const merchantMatchTypes = spendRuleForm?.merchantMatchTypes ?? []; + + const emptyStateTitle = + restrictionAction === CONST.SPEND_CARD_RULE.ACTION.BLOCK ? translate('workspace.rules.spendRules.noBlockedMerchants') : translate('workspace.rules.spendRules.noAllowedMerchants'); + + const emptyStateSubtitle = + restrictionAction === CONST.SPEND_CARD_RULE.ACTION.BLOCK + ? translate('workspace.rules.spendRules.addMerchantToBlockSpend') + : translate('workspace.rules.spendRules.addMerchantToAllowSpend'); + + const goBack = () => Navigation.goBack(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); + + const addMerchant = () => { + Navigation.navigate(ROUTES.RULES_SPEND_MERCHANT_EDIT.getRoute(policyID, ROUTES.NEW)); + }; + + return ( + + + + + + {merchantNames.length > 0 ? ( + merchantNames.map((merchantName, index) => ( + Navigation.navigate(ROUTES.RULES_SPEND_MERCHANT_EDIT.getRoute(policyID, String(index)))} + shouldShowRightIcon + title={merchantName} + titleStyle={styles.flex1} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM} + /> + )) + ) : ( + + )} + + + + + ); +} + +SpendRuleMerchantsPage.displayName = 'SpendRuleMerchantsPage'; + +export default SpendRuleMerchantsPage; diff --git a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx new file mode 100644 index 0000000000000..63ec61de7da5f --- /dev/null +++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx @@ -0,0 +1,204 @@ +import React, {useEffect} from 'react'; +import {View} from 'react-native'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; +import useConfirmModal from '@hooks/useConfirmModal'; +import useDefaultFundID from '@hooks/useDefaultFundID'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {getSpendCardRuleValueJSON, setExpensifyCardRule} from '@libs/actions/Card'; +import {clearDraftSpendRule, updateDraftSpendRule} from '@libs/actions/User'; +import {filterInactiveCards, getCardDescriptionForSearchTable, isCard} from '@libs/CardUtils'; +import {getDecodedCategoryName} from '@libs/CategoryUtils'; +import {convertToBackendAmount, convertToDisplayString} from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {rand64} from '@libs/NumberUtils'; +import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SpendRuleRestrictionTypeToggle from './SpendRuleRestrictionTypeToggle'; + +type SpendRulePageBaseProps = { + policyID: string; + titleKey: TranslationPaths; + testID: string; +}; + +const MAX_SUMMARY_CHARS = 74; + +function SpendRulePageBase({policyID, titleKey, testID}: SpendRulePageBaseProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + const domainAccountID = useDefaultFundID(policyID); + const [spendRuleForm] = useOnyx(ONYXKEYS.FORMS.SPEND_RULE_FORM); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${domainAccountID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards}); + + useEffect(() => () => clearDraftSpendRule(), []); + + const cardIDs = spendRuleForm?.cardIDs; + const restrictionAction = spendRuleForm?.restrictionAction ?? CONST.SPEND_CARD_RULE.ACTION.ALLOW; + + const cardsMenuTitle = !cardIDs?.length + ? '' + : cardIDs + .map((id) => { + const card = cardsList?.[id]; + if (card === undefined || !isCard(card)) { + return id; + } + const accountID = card.accountID ?? CONST.DEFAULT_NUMBER_ID; + const displayName = getDisplayNameOrDefault(personalDetails?.[accountID], '', false); + return getCardDescriptionForSearchTable(card, displayName || undefined) || id; + }) + .join(', '); + + const categoriesMenuTitle = (spendRuleForm?.categories ?? []).map((categoryName) => getDecodedCategoryName(categoryName)).join(', '); + + const selectedCardsCurrencies = new Set(); + for (const id of cardIDs ?? []) { + const cardValue = cardsList?.[id]; + if (cardValue === undefined || !isCard(cardValue)) { + continue; + } + if (typeof cardValue.nameValuePairs?.currency === 'string' && cardValue.nameValuePairs.currency) { + selectedCardsCurrencies.add(String(cardValue.nameValuePairs.currency)); + } + } + + const hasCurrencyMismatch = !(cardIDs?.length ?? 0) || selectedCardsCurrencies.size > 1; + const selectedCurrency = selectedCardsCurrencies.size === 1 ? Array.from(selectedCardsCurrencies).at(0) : undefined; + const parsedMaxAmount = Number.parseFloat(spendRuleForm?.maxAmount ?? ''); + const maxAmountMenuTitle = Number.isFinite(parsedMaxAmount) ? convertToDisplayString(convertToBackendAmount(parsedMaxAmount), selectedCurrency ?? CONST.CURRENCY.USD) : ''; + + const openCurrencyMismatchModal = async () => { + const result = await showConfirmModal({ + title: translate('workspace.rules.spendRules.currencyMismatchTitle'), + prompt: translate('workspace.rules.spendRules.currencyMismatchPrompt'), + confirmText: translate('workspace.rules.spendRules.reviewSelectedCards'), + cancelText: translate('common.cancel'), + }); + if (result.action !== ModalActions.CONFIRM) { + return; + } + Navigation.navigate(ROUTES.RULES_SPEND_CARD.getRoute(policyID)); + }; + + function getMerchantMenuTitle(merchantNamesToSummarize: string[] | undefined): string { + const normalizedMerchantNames = (merchantNamesToSummarize ?? []).map((merchantName) => merchantName.trim()).filter((merchantName) => merchantName !== ''); + if (!normalizedMerchantNames.length) { + return ''; + } + + let text = ''; + let shownCount = 0; + + for (const merchantName of normalizedMerchantNames) { + const nextText = text ? `${text}, ${merchantName}` : merchantName; + if (nextText.length > MAX_SUMMARY_CHARS) { + continue; + } + text = nextText; + shownCount++; + } + + const hiddenCount = Math.max(normalizedMerchantNames.length - shownCount, 0); + return text && hiddenCount > 0 ? translate('workspace.rules.spendRules.merchantsMoreCount', {summary: text, count: hiddenCount}) : text; + } + + const handleSaveRule = () => { + if (!cardIDs?.length) { + return; + } + setExpensifyCardRule({ + domainAccountID, + cardRuleID: String(rand64()), + cardRuleValue: getSpendCardRuleValueJSON(cardIDs, restrictionAction), + }); + clearDraftSpendRule(); + Navigation.goBack(); + }; + + return ( + + + + + {translate('workspace.rules.spendRules.cardsSectionTitle')} + Navigation.navigate(ROUTES.RULES_SPEND_CARD.getRoute(policyID))} + shouldShowRightIcon + title={cardsMenuTitle} + titleStyle={styles.flex1} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM} + /> + {translate('workspace.rules.spendRules.spendRuleSectionTitle')} + + updateDraftSpendRule({restrictionAction: action})} + /> + + Navigation.navigate(ROUTES.RULES_SPEND_MERCHANTS.getRoute(policyID))} + shouldShowRightIcon + title={getMerchantMenuTitle(spendRuleForm?.merchantNames)} + numberOfLinesTitle={2} + titleStyle={styles.flex1} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM} + /> + Navigation.navigate(ROUTES.RULES_SPEND_CATEGORY.getRoute(policyID))} + shouldShowRightIcon + title={categoriesMenuTitle} + numberOfLinesTitle={2} + titleStyle={styles.flex1} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM} + /> + (hasCurrencyMismatch ? openCurrencyMismatchModal() : Navigation.navigate(ROUTES.RULES_SPEND_MAX_AMOUNT.getRoute(policyID)))} + shouldShowRightIcon + title={maxAmountMenuTitle} + titleStyle={styles.flex1} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM} + /> + + + + + ); +} + +SpendRulePageBase.displayName = 'SpendRulePageBase'; + +export default SpendRulePageBase; diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleRestrictionTypeToggle.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleRestrictionTypeToggle.tsx new file mode 100644 index 0000000000000..445b1e8465686 --- /dev/null +++ b/src/pages/workspace/rules/SpendRules/SpendRuleRestrictionTypeToggle.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {ValueOf} from 'type-fest'; +import Button from '@components/Button'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; + +type SpendRuleRestrictionTypeToggleProps = { + restrictionAction: ValueOf; + onSelect: (action: ValueOf) => void; +}; + +function SpendRuleRestrictionTypeToggle({restrictionAction, onSelect}: SpendRuleRestrictionTypeToggleProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const isAllowSelected = restrictionAction === CONST.SPEND_CARD_RULE.ACTION.ALLOW; + const isBlockSelected = restrictionAction === CONST.SPEND_CARD_RULE.ACTION.BLOCK; + + const restrictionTypeHelperText = isAllowSelected ? translate('workspace.rules.spendRules.restrictionTypeHelpAllow') : translate('workspace.rules.spendRules.restrictionTypeHelpBlock'); + + return ( + <> + + {translate('workspace.rules.spendRules.restrictionType')} + +