diff --git a/assets/images/simple-illustrations/simple-illustration__foodtruck-tacos.svg b/assets/images/simple-illustrations/simple-illustration__foodtruck-tacos.svg new file mode 100644 index 000000000000..43501efd70c5 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__foodtruck-tacos.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/CONST/index.ts b/src/CONST/index.ts index e220c17feb57..a4b9e8cb4096 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4244,6 +4244,45 @@ const CONST = { TAX: 'tax', }, }, + SPEND_RULES: { + CATEGORIES: { + AIRLINES: 'airlines', + ALCOHOL_AND_BARS: 'alcoholAndBars', + AMAZON_AND_BOOKSTORES: 'amazonAndBookstores', + AUTOMOTIVE: 'automotive', + CAR_RENTALS: 'carRentals', + DINING: 'dining', + FUEL_AND_GAS: 'fuelAndGas', + GOVERNMENT_AND_NON_PROFITS: 'governmentAndNonProfits', + GROCERIES: 'groceries', + GYMS_AND_FITNESS: 'gymsAndFitness', + HEALTHCARE: 'healthcare', + HOTELS: 'hotels', + INTERNET_AND_PHONE: 'internetAndPhone', + OFFICE_SUPPLIES: 'officeSupplies', + PARKING_AND_TOLLS: 'parkingAndTolls', + PROFESSIONAL_SERVICES: 'professionalServices', + RETAIL: 'retail', + SHIPPING_AND_DELIVERY: 'shippingAndDelivery', + SOFTWARE: 'software', + TRANSIT_AND_RIDESHARE: 'transitAndRideshare', + TRAVEL_AGENCIES: 'travelAgencies', + }, + FORM: { + FIELDS: { + CARD_IDS: 'cardIDs', + RESTRICTION_ACTION: 'restrictionAction', + MERCHANT_NAMES: 'merchantNames', + MERCHANT_MATCH_TYPES: 'merchantMatchTypes', + CATEGORIES: 'categories', + MAX_AMOUNT: 'maxAmount', + }, + }, + ACTION: { + ALLOW: 'allow', + BLOCK: 'block', + }, + }, get SUBSCRIPTION_PRICES() { return { @@ -9207,6 +9246,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', @@ -9216,6 +9256,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 fbe3c4ab40a2..c149e0f0aba9 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1066,6 +1066,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', @@ -1209,6 +1211,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 81f3e74ad7ae..337e643ae8b3 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2997,6 +2997,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 f03c1f37d738..5a0929788e0d 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -822,6 +822,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', @@ -833,6 +837,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/Icon/chunks/illustrations.chunk.ts b/src/components/Icon/chunks/illustrations.chunk.ts index fd232f9afa7d..910d4c9615d4 100644 --- a/src/components/Icon/chunks/illustrations.chunk.ts +++ b/src/components/Icon/chunks/illustrations.chunk.ts @@ -135,6 +135,7 @@ import Filters from '@assets/images/simple-illustrations/simple-illustration__fi import FishbowlBlue from '@assets/images/simple-illustrations/simple-illustration__fishbowl--blue.svg'; import Flash from '@assets/images/simple-illustrations/simple-illustration__flash.svg'; import FolderOpen from '@assets/images/simple-illustrations/simple-illustration__folder-open.svg'; +import FoodTruck from '@assets/images/simple-illustrations/simple-illustration__foodtruck-tacos.svg'; import Gears from '@assets/images/simple-illustrations/simple-illustration__gears.svg'; import HandCard from '@assets/images/simple-illustrations/simple-illustration__handcard.svg'; import HeadSet from '@assets/images/simple-illustrations/simple-illustration__headset.svg'; @@ -396,6 +397,7 @@ const Illustrations = { Target, Trophy, Trophy1, + FoodTruck, // Multifactor Authentication Illustrations MagnifyingGlassSpyMouthClosed, diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index a38baf7bdb37..29912af4be39 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -538,6 +538,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 976746329dd9..6f3553e3ebfb 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 e14207e1461c..7d8cfe24def5 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 8f411c70e681..1ada26f464d2 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6813,6 +6813,62 @@ 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: 'Erlauben', + spendRuleSectionTitle: 'Ausgabenregel', + restrictionType: 'Beschränkungstyp', + restrictionTypeHelpAllow: 'Ausgaben werden genehmigt, wenn sie einem beliebigen Händler oder einer Kategorie entsprechen und einen Höchstbetrag nicht überschreiten.', + restrictionTypeHelpBlock: 'Buchungen werden abgelehnt, wenn sie mit einem Händler oder einer Kategorie übereinstimmen oder einen Höchstbetrag überschreiten.', + addMerchant: 'Händler hinzufügen', + merchantContains: 'Händler enthält', + merchantExactlyMatches: 'Händler stimmt exakt überein', + noBlockedMerchants: 'Keine blockierten Händler', + addMerchantToBlockSpend: 'Händler hinzufügen, um Ausgaben zu blockieren', + noAllowedMerchants: 'Keine erlaubten Händler', + addMerchantToAllowSpend: 'Fügen Sie einen Händler hinzu, um Ausgaben zu erlauben', + 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 Ausgabenkategoriebeschrä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', + summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} weitere`, + confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Wenden Sie mindestens eine Ausgabenregel auf eine Karte an', + confirmErrorCardRequired: 'Feld „Karte“ ist erforderlich', + confirmErrorApplyAtLeastOneSpendRule: 'Wenden Sie mindestens eine Ausgabenregel an', + categories: 'Kategorien', + merchants: 'Händler', + max: 'Max', + categoryOptions: { + [CONST.SPEND_RULES.CATEGORIES.AIRLINES]: 'Fluggesellschaften', + [CONST.SPEND_RULES.CATEGORIES.ALCOHOL_AND_BARS]: 'Alkohol und Bars', + [CONST.SPEND_RULES.CATEGORIES.AMAZON_AND_BOOKSTORES]: 'Amazon und Buchhandlungen', + [CONST.SPEND_RULES.CATEGORIES.AUTOMOTIVE]: 'Automobilindustrie', + [CONST.SPEND_RULES.CATEGORIES.CAR_RENTALS]: 'Mietwagen', + [CONST.SPEND_RULES.CATEGORIES.DINING]: 'Essen', + [CONST.SPEND_RULES.CATEGORIES.FUEL_AND_GAS]: 'Kraftstoff und Gas', + [CONST.SPEND_RULES.CATEGORIES.GOVERNMENT_AND_NON_PROFITS]: 'Regierung und gemeinnützige Organisationen', + [CONST.SPEND_RULES.CATEGORIES.GROCERIES]: 'Lebensmittel', + [CONST.SPEND_RULES.CATEGORIES.GYMS_AND_FITNESS]: 'Fitnessstudios und Fitness', + [CONST.SPEND_RULES.CATEGORIES.HEALTHCARE]: 'Gesundheitswesen', + [CONST.SPEND_RULES.CATEGORIES.HOTELS]: 'Hotels', + [CONST.SPEND_RULES.CATEGORIES.INTERNET_AND_PHONE]: 'Internet und Telefon', + [CONST.SPEND_RULES.CATEGORIES.OFFICE_SUPPLIES]: 'Bürobedarf', + [CONST.SPEND_RULES.CATEGORIES.PARKING_AND_TOLLS]: 'Parken und Mautgebühren', + [CONST.SPEND_RULES.CATEGORIES.PROFESSIONAL_SERVICES]: 'Professionelle Dienstleistungen', + [CONST.SPEND_RULES.CATEGORIES.RETAIL]: 'Einzelhandel', + [CONST.SPEND_RULES.CATEGORIES.SHIPPING_AND_DELIVERY]: 'Versand und Lieferung', + [CONST.SPEND_RULES.CATEGORIES.SOFTWARE]: 'Software', + [CONST.SPEND_RULES.CATEGORIES.TRANSIT_AND_RIDESHARE]: 'Öffentlicher Nahverkehr und Fahrgemeinschaften', + [CONST.SPEND_RULES.CATEGORIES.TRAVEL_AGENCIES]: 'Reisebüros', + }, }, }, planTypePage: { diff --git a/src/languages/en.ts b/src/languages/en.ts index a5867646e848..1640c05cac50 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6768,6 +6768,62 @@ 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', + summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} more`, + confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Apply at least one spend rule to one card', + confirmErrorCardRequired: 'Card is a required field', + confirmErrorApplyAtLeastOneSpendRule: 'Apply at least one spend rule', + categories: 'Categories', + merchants: 'Merchants', + max: 'Max', + categoryOptions: { + [CONST.SPEND_RULES.CATEGORIES.AIRLINES]: 'Airlines', + [CONST.SPEND_RULES.CATEGORIES.ALCOHOL_AND_BARS]: 'Alcohol and bars', + [CONST.SPEND_RULES.CATEGORIES.AMAZON_AND_BOOKSTORES]: 'Amazon and bookstores', + [CONST.SPEND_RULES.CATEGORIES.AUTOMOTIVE]: 'Automotive', + [CONST.SPEND_RULES.CATEGORIES.CAR_RENTALS]: 'Car rentals', + [CONST.SPEND_RULES.CATEGORIES.DINING]: 'Dining', + [CONST.SPEND_RULES.CATEGORIES.FUEL_AND_GAS]: 'Fuel and gas', + [CONST.SPEND_RULES.CATEGORIES.GOVERNMENT_AND_NON_PROFITS]: 'Government and non-profits', + [CONST.SPEND_RULES.CATEGORIES.GROCERIES]: 'Groceries', + [CONST.SPEND_RULES.CATEGORIES.GYMS_AND_FITNESS]: 'Gyms and fitness', + [CONST.SPEND_RULES.CATEGORIES.HEALTHCARE]: 'Healthcare', + [CONST.SPEND_RULES.CATEGORIES.HOTELS]: 'Hotels', + [CONST.SPEND_RULES.CATEGORIES.INTERNET_AND_PHONE]: 'Internet and phone', + [CONST.SPEND_RULES.CATEGORIES.OFFICE_SUPPLIES]: 'Office supplies', + [CONST.SPEND_RULES.CATEGORIES.PARKING_AND_TOLLS]: 'Parking and tolls', + [CONST.SPEND_RULES.CATEGORIES.PROFESSIONAL_SERVICES]: 'Professional services', + [CONST.SPEND_RULES.CATEGORIES.RETAIL]: 'Retail', + [CONST.SPEND_RULES.CATEGORIES.SHIPPING_AND_DELIVERY]: 'Shipping and delivery', + [CONST.SPEND_RULES.CATEGORIES.SOFTWARE]: 'Software', + [CONST.SPEND_RULES.CATEGORIES.TRANSIT_AND_RIDESHARE]: 'Transit and rideshare', + [CONST.SPEND_RULES.CATEGORIES.TRAVEL_AGENCIES]: 'Travel agencies', + }, }, }, planTypePage: { diff --git a/src/languages/es.ts b/src/languages/es.ts index ae51e8001082..9745520ac489 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6643,6 +6643,62 @@ ${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', + summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} más`, + confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Aplica al menos una regla de gasto a una tarjeta', + confirmErrorCardRequired: 'La tarjeta es un campo obligatorio', + confirmErrorApplyAtLeastOneSpendRule: 'Aplica al menos una regla de gasto', + categories: 'Categorías', + merchants: 'Comerciantes', + max: 'Máx.', + categoryOptions: { + [CONST.SPEND_RULES.CATEGORIES.AIRLINES]: 'Aerolíneas', + [CONST.SPEND_RULES.CATEGORIES.ALCOHOL_AND_BARS]: 'Bares y restaurantes', + [CONST.SPEND_RULES.CATEGORIES.AMAZON_AND_BOOKSTORES]: 'Amazon y librerías', + [CONST.SPEND_RULES.CATEGORIES.AUTOMOTIVE]: 'Automotriz', + [CONST.SPEND_RULES.CATEGORIES.CAR_RENTALS]: 'Alquiler de coches', + [CONST.SPEND_RULES.CATEGORIES.DINING]: 'Comida y bebida', + [CONST.SPEND_RULES.CATEGORIES.FUEL_AND_GAS]: 'Combustible y gasolina', + [CONST.SPEND_RULES.CATEGORIES.GOVERNMENT_AND_NON_PROFITS]: 'Gobierno y organizaciones sin ánimo de lucro', + [CONST.SPEND_RULES.CATEGORIES.GROCERIES]: 'Supermercados', + [CONST.SPEND_RULES.CATEGORIES.GYMS_AND_FITNESS]: 'Gimnasios y fitness', + [CONST.SPEND_RULES.CATEGORIES.HEALTHCARE]: 'Salud', + [CONST.SPEND_RULES.CATEGORIES.HOTELS]: 'Hoteles', + [CONST.SPEND_RULES.CATEGORIES.INTERNET_AND_PHONE]: 'Internet y teléfono', + [CONST.SPEND_RULES.CATEGORIES.OFFICE_SUPPLIES]: 'Oficina', + [CONST.SPEND_RULES.CATEGORIES.PARKING_AND_TOLLS]: 'Parking y peajes', + [CONST.SPEND_RULES.CATEGORIES.PROFESSIONAL_SERVICES]: 'Servicios profesionales', + [CONST.SPEND_RULES.CATEGORIES.RETAIL]: 'Tiendas', + [CONST.SPEND_RULES.CATEGORIES.SHIPPING_AND_DELIVERY]: 'Envío y entrega', + [CONST.SPEND_RULES.CATEGORIES.SOFTWARE]: 'Software', + [CONST.SPEND_RULES.CATEGORIES.TRANSIT_AND_RIDESHARE]: 'Transporte y transporte compartido', + [CONST.SPEND_RULES.CATEGORIES.TRAVEL_AGENCIES]: 'Agencias de viajes', + }, }, }, }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index ae6196763821..b90ba479851a 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6836,6 +6836,62 @@ 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épense', + 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 commerçant ou catégorie et 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: 'Ajouter 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, indépendamment des restrictions liées au commerçant et à la catégorie de dépense.', + currencyMismatchTitle: 'Incompatibilité de devise', + currencyMismatchPrompt: 'Pour définir un montant maximal, sélectionnez des cartes qui sont réglées dans la même devise.', + reviewSelectedCards: 'Examiner les cartes sélectionnées', + summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} de plus`, + confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Appliquez au moins une règle de dépense à une carte', + confirmErrorCardRequired: 'La carte est un champ obligatoire', + confirmErrorApplyAtLeastOneSpendRule: 'Appliquez au moins une règle de dépense', + categories: 'Catégories', + merchants: 'Commerçants', + max: 'Max', + categoryOptions: { + [CONST.SPEND_RULES.CATEGORIES.AIRLINES]: 'Compagnies aériennes', + [CONST.SPEND_RULES.CATEGORIES.ALCOHOL_AND_BARS]: 'Alcool et bars', + [CONST.SPEND_RULES.CATEGORIES.AMAZON_AND_BOOKSTORES]: 'Amazon et les librairies', + [CONST.SPEND_RULES.CATEGORIES.AUTOMOTIVE]: 'Automobile', + [CONST.SPEND_RULES.CATEGORIES.CAR_RENTALS]: 'Locations de voiture', + [CONST.SPEND_RULES.CATEGORIES.DINING]: 'Restauration', + [CONST.SPEND_RULES.CATEGORIES.FUEL_AND_GAS]: 'Carburant et gaz', + [CONST.SPEND_RULES.CATEGORIES.GOVERNMENT_AND_NON_PROFITS]: 'Secteur public et organisations à but non lucratif', + [CONST.SPEND_RULES.CATEGORIES.GROCERIES]: 'Courses', + [CONST.SPEND_RULES.CATEGORIES.GYMS_AND_FITNESS]: 'Salles de sport et fitness', + [CONST.SPEND_RULES.CATEGORIES.HEALTHCARE]: 'Santé', + [CONST.SPEND_RULES.CATEGORIES.HOTELS]: 'Hôtels', + [CONST.SPEND_RULES.CATEGORIES.INTERNET_AND_PHONE]: 'Internet et téléphone', + [CONST.SPEND_RULES.CATEGORIES.OFFICE_SUPPLIES]: 'Fournitures de bureau', + [CONST.SPEND_RULES.CATEGORIES.PARKING_AND_TOLLS]: 'Parking et péages', + [CONST.SPEND_RULES.CATEGORIES.PROFESSIONAL_SERVICES]: 'Services professionnels', + [CONST.SPEND_RULES.CATEGORIES.RETAIL]: 'Vente au détail', + [CONST.SPEND_RULES.CATEGORIES.SHIPPING_AND_DELIVERY]: 'Expédition et livraison', + [CONST.SPEND_RULES.CATEGORIES.SOFTWARE]: 'Logiciel', + [CONST.SPEND_RULES.CATEGORIES.TRANSIT_AND_RIDESHARE]: 'Transports en commun et VTC', + [CONST.SPEND_RULES.CATEGORIES.TRAVEL_AGENCIES]: 'Agences de voyages', + }, }, }, planTypePage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 21b0d587e97e..5e1f41988eb9 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1071,15 +1071,12 @@ const translations: TranslationDeepObject = { if (!added && !updated) { return 'Nessuna categoria è stata aggiunta o aggiornata.'; } - if (added && updated) { return `${added} ${added === 1 ? 'categoria aggiunta' : 'categorie aggiunte'}, ${updated} ${updated === 1 ? 'categoria aggiornata' : 'categorie aggiornate'}.`; } - if (added) { return added === 1 ? 'È stata aggiunta 1 categoria.' : `Sono state aggiunte ${added} categorie.`; } - return updated === 1 ? 'È stata aggiornata 1 categoria.' : `Sono state aggiornate ${updated} categorie.`; }, importCompanyCardTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => @@ -6802,6 +6799,62 @@ 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 le carte', + saveRule: 'Salva regola', + allow: 'Consenti', + spendRuleSectionTitle: 'Regola di spesa', + restrictionType: 'Tipo di restrizione', + restrictionTypeHelpAllow: 'Le spese vengono approvate se corrispondono a qualsiasi venditore o categoria e non superano un importo massimo.', + restrictionTypeHelpBlock: 'Le transazioni vengono rifiutate se corrispondono a un esercente o a una categoria, oppure se superano un importo massimo.', + addMerchant: 'Aggiungi esercente', + merchantContains: 'Il commerciante contiene', + merchantExactlyMatches: 'Il commerciante corrisponde esattamente', + noBlockedMerchants: 'Nessun esercente bloccato', + addMerchantToBlockSpend: 'Aggiungi un esercente da bloccare', + noAllowedMerchants: 'Nessun esercente consentito', + addMerchantToAllowSpend: 'Aggiungi un esercente per consentire la spesa', + 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 sul commerciante e sulla categoria di spesa.', + currencyMismatchTitle: 'Valuta non corrispondente', + currencyMismatchPrompt: 'Per impostare un importo massimo, seleziona carte che si regolano nella stessa valuta.', + reviewSelectedCards: 'Controlla le carte selezionate', + summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} altro`, + confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Applica almeno una regola di spesa a una carta', + confirmErrorCardRequired: 'Il campo Carta è obbligatorio', + confirmErrorApplyAtLeastOneSpendRule: 'Applica almeno una regola di spesa', + categories: 'Categorie', + merchants: 'Esercenti', + max: 'Massimo', + categoryOptions: { + [CONST.SPEND_RULES.CATEGORIES.AIRLINES]: 'Compagnie aeree', + [CONST.SPEND_RULES.CATEGORIES.ALCOHOL_AND_BARS]: 'Alcol e bar', + [CONST.SPEND_RULES.CATEGORIES.AMAZON_AND_BOOKSTORES]: 'Amazon e librerie', + [CONST.SPEND_RULES.CATEGORIES.AUTOMOTIVE]: 'Automotive', + [CONST.SPEND_RULES.CATEGORIES.CAR_RENTALS]: 'Noleggio auto', + [CONST.SPEND_RULES.CATEGORIES.DINING]: 'Ristoranti', + [CONST.SPEND_RULES.CATEGORIES.FUEL_AND_GAS]: 'Carburante e gas', + [CONST.SPEND_RULES.CATEGORIES.GOVERNMENT_AND_NON_PROFITS]: 'Pubblica amministrazione e non profit', + [CONST.SPEND_RULES.CATEGORIES.GROCERIES]: 'Spesa supermercato', + [CONST.SPEND_RULES.CATEGORIES.GYMS_AND_FITNESS]: 'Palestre e fitness', + [CONST.SPEND_RULES.CATEGORIES.HEALTHCARE]: 'Sanità', + [CONST.SPEND_RULES.CATEGORIES.HOTELS]: 'Hotel', + [CONST.SPEND_RULES.CATEGORIES.INTERNET_AND_PHONE]: 'Internet e telefono', + [CONST.SPEND_RULES.CATEGORIES.OFFICE_SUPPLIES]: "Materiale d'ufficio", + [CONST.SPEND_RULES.CATEGORIES.PARKING_AND_TOLLS]: 'Parcheggi e pedaggi', + [CONST.SPEND_RULES.CATEGORIES.PROFESSIONAL_SERVICES]: 'Servizi professionali', + [CONST.SPEND_RULES.CATEGORIES.RETAIL]: 'Vendita al dettaglio', + [CONST.SPEND_RULES.CATEGORIES.SHIPPING_AND_DELIVERY]: 'Spedizione e consegna', + [CONST.SPEND_RULES.CATEGORIES.SOFTWARE]: 'Software', + [CONST.SPEND_RULES.CATEGORIES.TRANSIT_AND_RIDESHARE]: 'Trasporti e ride sharing', + [CONST.SPEND_RULES.CATEGORIES.TRAVEL_AGENCIES]: 'Agenzie di viaggio', + }, }, }, planTypePage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index eccda8fc9a4f..0694dcfb6db8 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1054,15 +1054,12 @@ const translations: TranslationDeepObject = { if (!added && !updated) { return 'カテゴリーは追加も更新もされていません。'; } - if (added && updated) { return `${added}件のカテゴリーを追加し、${updated}件のカテゴリーを更新しました。`; } - if (added) { return added === 1 ? 'カテゴリーを1件追加しました。' : `${added}件のカテゴリーを追加しました。`; } - return updated === 1 ? 'カテゴリーを1件更新しました。' : `${updated}件のカテゴリーを更新しました。`; }, importCompanyCardTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => @@ -6727,6 +6724,62 @@ ${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: '選択したカードを確認', + summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}、ほか +${count} 件`, + confirmErrorApplyAtLeastOneSpendRuleToOneCard: '少なくとも1つの支出ルールを1枚のカードに適用してください', + confirmErrorCardRequired: 'カードは必須項目です', + confirmErrorApplyAtLeastOneSpendRule: '少なくとも 1 つの支出ルールを適用してください', + categories: 'カテゴリ', + merchants: '加盟店', + max: '最大', + categoryOptions: { + [CONST.SPEND_RULES.CATEGORIES.AIRLINES]: '航空会社', + [CONST.SPEND_RULES.CATEGORIES.ALCOHOL_AND_BARS]: '酒類とバー', + [CONST.SPEND_RULES.CATEGORIES.AMAZON_AND_BOOKSTORES]: 'Amazon と書店', + [CONST.SPEND_RULES.CATEGORIES.AUTOMOTIVE]: '自動車', + [CONST.SPEND_RULES.CATEGORIES.CAR_RENTALS]: 'レンタカー', + [CONST.SPEND_RULES.CATEGORIES.DINING]: '外食', + [CONST.SPEND_RULES.CATEGORIES.FUEL_AND_GAS]: '燃料・ガス', + [CONST.SPEND_RULES.CATEGORIES.GOVERNMENT_AND_NON_PROFITS]: '政府機関・非営利団体', + [CONST.SPEND_RULES.CATEGORIES.GROCERIES]: '食料品', + [CONST.SPEND_RULES.CATEGORIES.GYMS_AND_FITNESS]: 'ジム・フィットネス', + [CONST.SPEND_RULES.CATEGORIES.HEALTHCARE]: '医療', + [CONST.SPEND_RULES.CATEGORIES.HOTELS]: 'ホテル', + [CONST.SPEND_RULES.CATEGORIES.INTERNET_AND_PHONE]: 'インターネットと電話', + [CONST.SPEND_RULES.CATEGORIES.OFFICE_SUPPLIES]: '事務用品', + [CONST.SPEND_RULES.CATEGORIES.PARKING_AND_TOLLS]: '駐車料金と通行料金', + [CONST.SPEND_RULES.CATEGORIES.PROFESSIONAL_SERVICES]: 'プロフェッショナルサービス', + [CONST.SPEND_RULES.CATEGORIES.RETAIL]: '小売業', + [CONST.SPEND_RULES.CATEGORIES.SHIPPING_AND_DELIVERY]: '配送と配達', + [CONST.SPEND_RULES.CATEGORIES.SOFTWARE]: 'ソフトウェア', + [CONST.SPEND_RULES.CATEGORIES.TRANSIT_AND_RIDESHARE]: '交通機関とライドシェア', + [CONST.SPEND_RULES.CATEGORIES.TRAVEL_AGENCIES]: '旅行代理店', + }, }, }, planTypePage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 7336d6d4a873..34a1717024af 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1070,15 +1070,12 @@ const translations: TranslationDeepObject = { if (!added && !updated) { return 'Er zijn geen categorieën toegevoegd of bijgewerkt.'; } - if (added && updated) { return `${added} ${added === 1 ? 'categorie' : 'categorieën'} toegevoegd, ${updated} ${updated === 1 ? 'categorie' : 'categorieën'} bijgewerkt.`; } - if (added) { return added === 1 ? '1 categorie is toegevoegd.' : `${added} categorieën zijn toegevoegd.`; } - return updated === 1 ? '1 categorie is bijgewerkt.' : `${updated} categorieën zijn bijgewerkt.`; }, importCompanyCardTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => @@ -6783,6 +6780,62 @@ 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: 'Betalingen worden geweigerd als ze overeenkomen met een handelaar of categorie, of een maximumbedrag overschrijden.', + addMerchant: 'Handelaar toevoegen', + merchantContains: 'Handelaar bevat', + merchantExactlyMatches: 'Handelaar 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: 'Uitgavencategorie', + maxAmount: 'Maximumbedrag', + maxAmountHelp: 'Elke betaling boven dit bedrag wordt geweigerd, ongeacht beperkingen voor handelaar en uitgavencategorie.', + currencyMismatchTitle: 'Valutamismatch', + currencyMismatchPrompt: 'Om een maximumbedrag in te stellen, selecteer je kaarten die in dezelfde valuta worden vereffend.', + reviewSelectedCards: 'Geselecteerde kaarten bekijken', + summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} meer`, + confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Pas minstens één bestedingsregel toe op één kaart', + confirmErrorCardRequired: 'Kaart is een verplicht veld', + confirmErrorApplyAtLeastOneSpendRule: 'Pas minstens één bestedingsregel toe', + categories: 'Categorieën', + merchants: 'Handelaars', + max: 'Max', + categoryOptions: { + [CONST.SPEND_RULES.CATEGORIES.AIRLINES]: 'Luchtvaartmaatschappijen', + [CONST.SPEND_RULES.CATEGORIES.ALCOHOL_AND_BARS]: 'Alcohol en bars', + [CONST.SPEND_RULES.CATEGORIES.AMAZON_AND_BOOKSTORES]: 'Amazon en boekhandels', + [CONST.SPEND_RULES.CATEGORIES.AUTOMOTIVE]: 'Auto-industrie', + [CONST.SPEND_RULES.CATEGORIES.CAR_RENTALS]: 'Autoverhuur', + [CONST.SPEND_RULES.CATEGORIES.DINING]: 'Eten en drinken', + [CONST.SPEND_RULES.CATEGORIES.FUEL_AND_GAS]: 'Brandstof en gas', + [CONST.SPEND_RULES.CATEGORIES.GOVERNMENT_AND_NON_PROFITS]: 'Overheid en non-profitorganisaties', + [CONST.SPEND_RULES.CATEGORIES.GROCERIES]: 'Boodschappen', + [CONST.SPEND_RULES.CATEGORIES.GYMS_AND_FITNESS]: 'Sportscholen en fitness', + [CONST.SPEND_RULES.CATEGORIES.HEALTHCARE]: 'Gezondheidszorg', + [CONST.SPEND_RULES.CATEGORIES.HOTELS]: 'Hotels', + [CONST.SPEND_RULES.CATEGORIES.INTERNET_AND_PHONE]: 'Internet en telefoon', + [CONST.SPEND_RULES.CATEGORIES.OFFICE_SUPPLIES]: 'Kantoorbenodigdheden', + [CONST.SPEND_RULES.CATEGORIES.PARKING_AND_TOLLS]: 'Parkeren en tol', + [CONST.SPEND_RULES.CATEGORIES.PROFESSIONAL_SERVICES]: 'Professionele diensten', + [CONST.SPEND_RULES.CATEGORIES.RETAIL]: 'Detailhandel', + [CONST.SPEND_RULES.CATEGORIES.SHIPPING_AND_DELIVERY]: 'Verzending en levering', + [CONST.SPEND_RULES.CATEGORIES.SOFTWARE]: 'Software', + [CONST.SPEND_RULES.CATEGORIES.TRANSIT_AND_RIDESHARE]: 'Openbaar vervoer en ritdiensten', + [CONST.SPEND_RULES.CATEGORIES.TRAVEL_AGENCIES]: 'Reisbureaus', + }, }, }, planTypePage: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index c9b6fed1f72a..1b3b31d31fcb 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1071,15 +1071,12 @@ const translations: TranslationDeepObject = { if (!added && !updated) { return 'Nie dodano ani nie zaktualizowano żadnych kategorii.'; } - if (added && updated) { return `Dodano ${added} ${added === 1 ? 'kategorię' : 'kategorie'}, zaktualizowano ${updated} ${updated === 1 ? 'kategorię' : 'kategorie'}.`; } - if (added) { return added === 1 ? 'Dodano 1 kategorię.' : `Dodano ${added} kategorie.`; } - return updated === 1 ? 'Zaktualizowano 1 kategorię.' : `Zaktualizowano ${updated} kategorie.`; }, importCompanyCardTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => (transactions > 1 ? `Dodano ${transactions} transakcji.` : 'Dodano 1 transakcję.'), @@ -6774,6 +6771,62 @@ 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: 'Reguła wydatków', + restrictionType: 'Typ ograniczenia', + restrictionTypeHelpAllow: 'Obciążenia są zatwierdzane, jeśli pasują do jakiegokolwiek sprzedawcy lub kategorii i nie przekraczają maksymalnej kwoty.', + restrictionTypeHelpBlock: 'Obciążenia są odrzucane, jeśli pasują do jakiegokolwiek sprzedawcy lub kategorii albo przekraczają maksymalną kwotę.', + addMerchant: 'Dodaj sprzedawcę', + merchantContains: 'Sprzedawca zawiera', + merchantExactlyMatches: 'Sprzedawca dokładnie pasuje', + noBlockedMerchants: 'Brak zablokowanych sprzedawców', + addMerchantToBlockSpend: 'Dodaj sprzedawcę, aby zablokować wydatki', + noAllowedMerchants: 'Brak dozwolonych sprzedawców', + addMerchantToAllowSpend: 'Dodaj sprzedawcę, aby zezwolić na wydatki', + matchType: 'Typ dopasowania', + matchTypeContains: 'Zawiera', + matchTypeExact: 'Dokładnie pasuje', + spendCategory: 'Kategoria wydatków', + maxAmount: 'Maksymalna kwota', + maxAmountHelp: 'Każda transakcja powyżej tej kwoty zostanie odrzucona, niezależnie od ograniczeń dotyczących sprzedawcy i kategorii wydatków.', + currencyMismatchTitle: 'Niezgodność waluty', + currencyMismatchPrompt: 'Aby ustawić maksymalną kwotę, wybierz karty rozliczane w tej samej walucie.', + reviewSelectedCards: 'Przejrzyj wybrane karty', + summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} więcej`, + confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Zastosuj co najmniej jedną regułę wydatków do jednej karty', + confirmErrorCardRequired: 'Pole „Karta” jest wymagane', + confirmErrorApplyAtLeastOneSpendRule: 'Zastosuj co najmniej jedną regułę wydatków', + categories: 'Kategorie', + merchants: 'Sprzedawcy', + max: 'Maks', + categoryOptions: { + [CONST.SPEND_RULES.CATEGORIES.AIRLINES]: 'Linie lotnicze', + [CONST.SPEND_RULES.CATEGORIES.ALCOHOL_AND_BARS]: 'Alkohol i bary', + [CONST.SPEND_RULES.CATEGORIES.AMAZON_AND_BOOKSTORES]: 'Amazon i księgarnie', + [CONST.SPEND_RULES.CATEGORIES.AUTOMOTIVE]: 'Motoryzacja', + [CONST.SPEND_RULES.CATEGORIES.CAR_RENTALS]: 'Wypożyczalnie samochodów', + [CONST.SPEND_RULES.CATEGORIES.DINING]: 'Posiłki', + [CONST.SPEND_RULES.CATEGORIES.FUEL_AND_GAS]: 'Paliwo i gaz', + [CONST.SPEND_RULES.CATEGORIES.GOVERNMENT_AND_NON_PROFITS]: 'Sektor publiczny i organizacje non-profit', + [CONST.SPEND_RULES.CATEGORIES.GROCERIES]: 'Zakupy spożywcze', + [CONST.SPEND_RULES.CATEGORIES.GYMS_AND_FITNESS]: 'Siłownie i fitness', + [CONST.SPEND_RULES.CATEGORIES.HEALTHCARE]: 'Opieka zdrowotna', + [CONST.SPEND_RULES.CATEGORIES.HOTELS]: 'Hotele', + [CONST.SPEND_RULES.CATEGORIES.INTERNET_AND_PHONE]: 'Internet i telefon', + [CONST.SPEND_RULES.CATEGORIES.OFFICE_SUPPLIES]: 'Artykuły biurowe', + [CONST.SPEND_RULES.CATEGORIES.PARKING_AND_TOLLS]: 'Parking i opłaty drogowe', + [CONST.SPEND_RULES.CATEGORIES.PROFESSIONAL_SERVICES]: 'Usługi profesjonalne', + [CONST.SPEND_RULES.CATEGORIES.RETAIL]: 'Sprzedaż detaliczna', + [CONST.SPEND_RULES.CATEGORIES.SHIPPING_AND_DELIVERY]: 'Wysyłka i dostawa', + [CONST.SPEND_RULES.CATEGORIES.SOFTWARE]: 'Oprogramowanie', + [CONST.SPEND_RULES.CATEGORIES.TRANSIT_AND_RIDESHARE]: 'Transport i przejazdy współdzielone', + [CONST.SPEND_RULES.CATEGORIES.TRAVEL_AGENCIES]: 'Biura podróży', + }, }, }, planTypePage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index f43fe751cfe5..2db38568b5de 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1069,15 +1069,12 @@ const translations: TranslationDeepObject = { if (!added && !updated) { return 'Nenhuma categoria foi adicionada ou atualizada.'; } - if (added && updated) { return `${added} ${added === 1 ? 'categoria' : 'categorias'} adicionada${added === 1 ? '' : 's'}, ${updated} ${updated === 1 ? 'categoria' : 'categorias'} atualizada${updated === 1 ? '' : 's'}.`; } - if (added) { return added === 1 ? '1 categoria foi adicionada.' : `${added} categorias foram adicionadas.`; } - return updated === 1 ? '1 categoria foi atualizada.' : `${updated} categorias foram atualizadas.`; }, importCompanyCardTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => @@ -6781,6 +6778,62 @@ 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: 'As cobranças são aprovadas se corresponderem a qualquer comerciante ou categoria e não excederem um valor máximo.', + restrictionTypeHelpBlock: 'As cobranças são recusadas se corresponderem a qualquer estabelecimento ou categoria, ou se excederem um valor máximo.', + addMerchant: 'Adicionar comerciante', + merchantContains: 'Comerciante 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 estabelecimento e categoria de gasto.', + currencyMismatchTitle: 'Incompatibilidade de moeda', + currencyMismatchPrompt: 'Para definir um valor máximo, selecione cartões que liquidem na mesma moeda.', + reviewSelectedCards: 'Revisar cartões selecionados', + summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} mais`, + confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Aplicar pelo menos uma regra de gasto a um cartão', + confirmErrorCardRequired: 'O campo Cartão é obrigatório', + confirmErrorApplyAtLeastOneSpendRule: 'Aplicar pelo menos uma regra de gasto', + categories: 'Categorias', + merchants: 'Comerciantes', + max: 'Máx.', + categoryOptions: { + [CONST.SPEND_RULES.CATEGORIES.AIRLINES]: 'Companhias aéreas', + [CONST.SPEND_RULES.CATEGORIES.ALCOHOL_AND_BARS]: 'Álcool e bares', + [CONST.SPEND_RULES.CATEGORIES.AMAZON_AND_BOOKSTORES]: 'Amazon e livrarias', + [CONST.SPEND_RULES.CATEGORIES.AUTOMOTIVE]: 'Automotivo', + [CONST.SPEND_RULES.CATEGORIES.CAR_RENTALS]: 'Aluguel de carros', + [CONST.SPEND_RULES.CATEGORIES.DINING]: 'Refeições', + [CONST.SPEND_RULES.CATEGORIES.FUEL_AND_GAS]: 'Combustível e gás', + [CONST.SPEND_RULES.CATEGORIES.GOVERNMENT_AND_NON_PROFITS]: 'Governo e organizações sem fins lucrativos', + [CONST.SPEND_RULES.CATEGORIES.GROCERIES]: 'Supermercado', + [CONST.SPEND_RULES.CATEGORIES.GYMS_AND_FITNESS]: 'Academias e fitness', + [CONST.SPEND_RULES.CATEGORIES.HEALTHCARE]: 'Saúde', + [CONST.SPEND_RULES.CATEGORIES.HOTELS]: 'Hotéis', + [CONST.SPEND_RULES.CATEGORIES.INTERNET_AND_PHONE]: 'Internet e telefone', + [CONST.SPEND_RULES.CATEGORIES.OFFICE_SUPPLIES]: 'Material de escritório', + [CONST.SPEND_RULES.CATEGORIES.PARKING_AND_TOLLS]: 'Estacionamento e pedágios', + [CONST.SPEND_RULES.CATEGORIES.PROFESSIONAL_SERVICES]: 'Serviços profissionais', + [CONST.SPEND_RULES.CATEGORIES.RETAIL]: 'Varejo', + [CONST.SPEND_RULES.CATEGORIES.SHIPPING_AND_DELIVERY]: 'Envio e entrega', + [CONST.SPEND_RULES.CATEGORIES.SOFTWARE]: 'Software', + [CONST.SPEND_RULES.CATEGORIES.TRANSIT_AND_RIDESHARE]: 'Transporte público e carros de aplicativo', + [CONST.SPEND_RULES.CATEGORIES.TRAVEL_AGENCIES]: 'Agências de viagens', + }, }, }, planTypePage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 91244efc29fe..e03a218460ca 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6603,6 +6603,62 @@ ${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: '检查所选卡片', + summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary},还有 +${count} 个`, + confirmErrorApplyAtLeastOneSpendRuleToOneCard: '至少将一条支出规则应用到一张卡上', + confirmErrorCardRequired: '“卡”是必填字段', + confirmErrorApplyAtLeastOneSpendRule: '至少应用一条支出规则', + categories: '类别', + merchants: '商家', + max: '最大', + categoryOptions: { + [CONST.SPEND_RULES.CATEGORIES.AIRLINES]: '航空公司', + [CONST.SPEND_RULES.CATEGORIES.ALCOHOL_AND_BARS]: '酒精和酒吧', + [CONST.SPEND_RULES.CATEGORIES.AMAZON_AND_BOOKSTORES]: 'Amazon 和书店', + [CONST.SPEND_RULES.CATEGORIES.AUTOMOTIVE]: '汽车', + [CONST.SPEND_RULES.CATEGORIES.CAR_RENTALS]: '租车', + [CONST.SPEND_RULES.CATEGORIES.DINING]: '餐饮', + [CONST.SPEND_RULES.CATEGORIES.FUEL_AND_GAS]: '燃料和汽油', + [CONST.SPEND_RULES.CATEGORIES.GOVERNMENT_AND_NON_PROFITS]: '政府和非营利组织', + [CONST.SPEND_RULES.CATEGORIES.GROCERIES]: '杂货', + [CONST.SPEND_RULES.CATEGORIES.GYMS_AND_FITNESS]: '健身房和健身', + [CONST.SPEND_RULES.CATEGORIES.HEALTHCARE]: '医疗保健', + [CONST.SPEND_RULES.CATEGORIES.HOTELS]: '酒店', + [CONST.SPEND_RULES.CATEGORIES.INTERNET_AND_PHONE]: '网络和电话', + [CONST.SPEND_RULES.CATEGORIES.OFFICE_SUPPLIES]: '办公用品', + [CONST.SPEND_RULES.CATEGORIES.PARKING_AND_TOLLS]: '停车和过路费', + [CONST.SPEND_RULES.CATEGORIES.PROFESSIONAL_SERVICES]: '专业服务', + [CONST.SPEND_RULES.CATEGORIES.RETAIL]: '零售', + [CONST.SPEND_RULES.CATEGORIES.SHIPPING_AND_DELIVERY]: '配送与交付', + [CONST.SPEND_RULES.CATEGORIES.SOFTWARE]: '软件', + [CONST.SPEND_RULES.CATEGORIES.TRANSIT_AND_RIDESHARE]: '公共交通和网约车', + [CONST.SPEND_RULES.CATEGORIES.TRAVEL_AGENCIES]: '旅行社', + }, }, }, planTypePage: { diff --git a/src/libs/API/parameters/SetExpensifyCardRuleParams.ts b/src/libs/API/parameters/SetExpensifyCardRuleParams.ts new file mode 100644 index 000000000000..ab47148ef022 --- /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 04208236c001..d42a14bfd550 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -353,6 +353,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 fafe1c8bf369..c85c32bb34ea 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/CardUtils.ts b/src/libs/CardUtils.ts index 9b5ed39286a1..7d493b401ff1 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -1525,6 +1525,36 @@ function getCardCurrency(card?: OnyxEntry, cardSettings?: OnyxEntry): string | undefined { + if (!cardIDs?.length) { + return undefined; + } + + const cardsRecord = cardsList ?? {}; + const currencies = new Set(); + + for (const cardID of cardIDs) { + const card = cardsRecord[cardID]; + if (!card) { + continue; + } + + const currency = card.nameValuePairs?.currency; + if (currency) { + currencies.add(currency); + } + } + + if (currencies.size !== 1) { + return undefined; + } + + return Array.from(currencies).at(0); +} + function getCardHintText(validFrom: string | undefined, validThru: string | undefined, assigneeTimeZone: SelectedTimezone | undefined, translate: LocalizedTranslate) { if (!validFrom || !validThru) { return; @@ -1676,6 +1706,7 @@ export { getDisplayableExpensifyCards, isExpiredCard, getCardCurrency, + getSelectedCardsSharedCurrency, getCardHintText, resolveTransactionCardFields, }; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 851b5b30685c..3a98255cd6b9 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 03d7f0957f7a..fdd824010562 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -294,6 +294,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 27648bd3ca95..515d4d5c5e3e 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1440,6 +1440,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 41ed86a7e876..e6716b99bb84 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, @@ -29,8 +30,11 @@ import Log from '@libs/Log'; import {isReportOpenOrUnsubmitted} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {SpendRuleForm} from '@src/types/form'; +import {isSpendRuleCategory} from '@src/types/form/SpendRuleForm'; import type {Card, CompanyCardFeedWithDomainID, Report, Transaction} from '@src/types/onyx'; import type {CardLimitType, ExpensifyCardDetails, IssueNewCardData, IssueNewCardStep} from '@src/types/onyx/Card'; +import type {ExpensifyCardRule, ExpensifyCardRuleFilter} from '@src/types/onyx/ExpensifyCardSettings'; import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails'; import type {ConnectionName} from '@src/types/onyx/Policy'; import type {SavedCSVColumnLayoutData} from '@src/types/onyx/SavedCSVColumnLayout'; @@ -1555,6 +1559,196 @@ function queueExpensifyCardForBilling(feedCountry: string, domainAccountID: numb API.write(WRITE_COMMANDS.QUEUE_EXPENSIFY_CARD_FOR_BILLING, parameters); } +function isSpendRuleASTNode(value: unknown): value is ExpensifyCardRuleFilter { + return !!value && typeof value === 'object' && 'left' in value && 'operator' in value && 'right' in value; +} + +function combineSpendRuleASTNodes(nodes: ExpensifyCardRuleFilter[], operator: ValueOf): ExpensifyCardRuleFilter | undefined { + const [firstNode, ...remainingNodes] = nodes; + if (!firstNode) { + return undefined; + } + + return remainingNodes.reduce((accumulator, node) => ({left: accumulator, operator, right: node}), firstNode); +} + +function buildSpendRuleAST(spendRuleValues: SpendRuleForm): ExpensifyCardRule | undefined { + const cardIDs = spendRuleValues.cardIDs ?? []; + if (cardIDs.length === 0) { + return undefined; + } + + const merchantNames = (spendRuleValues.merchantNames ?? []).map((merchant) => merchant.trim()).filter((merchant) => merchant !== ''); + const merchantMatchTypes = spendRuleValues.merchantMatchTypes ?? []; + const categories = (spendRuleValues.categories ?? []).map((category) => category.trim()).filter((category) => category !== ''); + const maxAmount = spendRuleValues.maxAmount?.trim() ?? ''; + + const cardNode: ExpensifyCardRuleFilter = { + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + right: cardIDs, + }; + + const exactMerchantNames = merchantNames.filter((_, index) => merchantMatchTypes.at(index) === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); + const containsMerchantNames = merchantNames.filter((_, index) => merchantMatchTypes.at(index) !== CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); + const merchantNodes: ExpensifyCardRuleFilter[] = []; + + if (exactMerchantNames.length > 0) { + merchantNodes.push({ + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + right: exactMerchantNames, + }); + } + + if (containsMerchantNames.length > 0) { + merchantNodes.push({ + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, + operator: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + right: containsMerchantNames, + }); + } + + const merchantNode = combineSpendRuleASTNodes(merchantNodes, CONST.SEARCH.SYNTAX_OPERATORS.OR); + const categoryNode = + categories.length > 0 + ? { + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + right: categories, + } + : undefined; + + const criteriaNode = combineSpendRuleASTNodes([merchantNode, categoryNode].filter(Boolean) as ExpensifyCardRuleFilter[], CONST.SEARCH.SYNTAX_OPERATORS.OR); + const amountNode = + maxAmount !== '' + ? { + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, + operator: + spendRuleValues.restrictionAction === CONST.SPEND_RULES.ACTION.BLOCK + ? CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN + : CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO, + right: [maxAmount], + } + : undefined; + + const ruleNode = combineSpendRuleASTNodes( + [amountNode, criteriaNode].filter(Boolean) as ExpensifyCardRuleFilter[], + spendRuleValues.restrictionAction === CONST.SPEND_RULES.ACTION.BLOCK ? CONST.SEARCH.SYNTAX_OPERATORS.OR : CONST.SEARCH.SYNTAX_OPERATORS.AND, + ); + const filters = combineSpendRuleASTNodes([cardNode, ruleNode].filter(Boolean) as ExpensifyCardRuleFilter[], CONST.SEARCH.SYNTAX_OPERATORS.AND); + + if (!filters) { + return undefined; + } + + return { + created: DateUtils.getDBTime(), + action: spendRuleValues.restrictionAction ?? CONST.SPEND_RULES.ACTION.ALLOW, + filters, + }; +} + +function getSpendRuleFormValuesFromCardRule(cardRule: ExpensifyCardRule): SpendRuleForm | undefined { + if (!cardRule || typeof cardRule !== 'object' || !('filters' in cardRule) || !('action' in cardRule)) { + return undefined; + } + + if (!isSpendRuleASTNode(cardRule.filters)) { + return undefined; + } + + const formValues: SpendRuleForm = { + cardIDs: [], + restrictionAction: cardRule.action, + merchantNames: [], + merchantMatchTypes: [], + categories: [], + maxAmount: '', + }; + + const traverseFilters = (filterNode: ExpensifyCardRuleFilter) => { + const {left, operator, right} = filterNode; + + if (isSpendRuleASTNode(left)) { + traverseFilters(left); + } + + if (isSpendRuleASTNode(right)) { + traverseFilters(right); + return; + } + + if (typeof left !== 'string' || !Array.isArray(right)) { + return; + } + + if (left === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) { + formValues.cardIDs = right; + return; + } + + if (left === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { + formValues.maxAmount = typeof right === 'string' ? right : (right.at(0) ?? ''); + return; + } + + if (left === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY) { + formValues.categories = right.filter(isSpendRuleCategory); + return; + } + + if (left === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT) { + formValues.merchantNames = [...formValues.merchantNames, ...right]; + formValues.merchantMatchTypes = [...formValues.merchantMatchTypes, ...right.map(() => operator)]; + } + }; + + traverseFilters(cardRule.filters); + + return formValues; +} + +function setExpensifyCardRule(domainAccountID: number, cardRuleID: string, spendRuleValues: SpendRuleForm) { + const ruleID = cardRuleID; + const ruleAST = buildSpendRuleAST(spendRuleValues); + if (!ruleAST) { + return; + } + + const optimisticData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${domainAccountID}`, + value: { + cardRules: { + [ruleID]: ruleAST, + }, + }, + }, + ]; + + const failureData: Array> = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${domainAccountID}`, + value: { + cardRules: { + [ruleID]: null, + }, + }, + }, + ]; + + const parameters: SetExpensifyCardRuleParams = { + domainAccountID, + cardRuleID: ruleID, + cardRuleValue: JSON.stringify(ruleAST), + }; + + API.write(WRITE_COMMANDS.SET_EXPENSIFY_CARD_RULE, parameters, {optimisticData, failureData}); +} + /** * 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 +1850,7 @@ export { clearIssueNewCardFormData, setDraftInviteAccountID, resolveFraudAlert, + setExpensifyCardRule, + getSpendRuleFormValuesFromCardRule, }; export type {ReplacementReason}; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index e161fa357241..0b1bec9825fb 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 4a0741f8e8b5..1685065dc958 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 000000000000..faa25375a5ff --- /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 000000000000..225c4f9269cb --- /dev/null +++ b/src/pages/workspace/rules/SpendRules/SpendRuleCardPage.tsx @@ -0,0 +1,252 @@ +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 FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +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 {getSpendRuleFormValuesFromCardRule} from '@libs/actions/Card'; +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} 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; + } + + for (const rule of Object.values(cardRules)) { + const formValues = getSpendRuleFormValuesFromCardRule(rule); + for (const cardID of formValues?.cardIDs ?? []) { + const numericCardID = Number(cardID); + if (Number.isFinite(numericCardID)) { + cardIDs.add(numericCardID); + } + } + } + + 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 {isOffline} = useNetwork({ + onReconnect: () => { + openPolicyExpensifyCardsPage(policyID, defaultFundID); + }, + }); + + const isCardSettingsLoading = !isOffline && (!expensifyCardSettings || expensifyCardSettings.isLoading) && !expensifyCardSettings?.hasOnceLoaded; + const eligibleCards = expensifyCardSettings ? 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]); + + 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 = () => { + if (isCardSettingsLoading) { + return; + } + + const eligibleCardIDs = new Set(eligibleCards.map((card) => String(card.cardID))); + const validSelectedCardIDs = selectedCardIDs.filter((cardID) => eligibleCardIDs.has(cardID)); + + if (validSelectedCardIDs.length !== selectedCardIDs.length) { + setSelectedCardIDs(validSelectedCardIDs); + return; + } + + updateDraftSpendRule({cardIDs: validSelectedCardIDs}); + Navigation.goBack(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); + }; + + const headerMessage = getHeaderMessage(listData.length > 0, false, inputValue, countryCode, false); + + return ( + + {isCardSettingsLoading ? ( + + ) : ( + + 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 000000000000..154b246b0086 --- /dev/null +++ b/src/pages/workspace/rules/SpendRules/SpendRuleCategoryPage.tsx @@ -0,0 +1,150 @@ +import {useFocusEffect} from '@react-navigation/native'; +import React, {useCallback, useState} from 'react'; +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 {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 ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import {SPEND_RULE_CATEGORIES} from '@src/types/form/SpendRuleForm'; +import type {SpendRuleCategory} from '@src/types/form/SpendRuleForm'; + +type CategoryListItem = ListItem & { + value: SpendRuleCategory; +}; + +type SpendRuleCategoryPageProps = PlatformStackScreenProps; + +function SpendRuleCategoryPage({route}: SpendRuleCategoryPageProps) { + const {policyID} = route.params; + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + const illustrations = useMemoizedLazyIllustrations(['Telescope']); + + const [spendRuleForm] = useOnyx(ONYXKEYS.FORMS.SPEND_RULE_FORM); + + const [selectedCategories, setSelectedCategories] = useState([]); + + useFocusEffect( + useCallback(() => { + setSelectedCategories(spendRuleForm?.categories ?? []); + }, [spendRuleForm?.categories]), + ); + + const categoryItems: CategoryListItem[] = SPEND_RULE_CATEGORIES.map((category) => ({ + keyForList: category, + text: translate(`workspace.rules.spendRules.categoryOptions.${category}`), + value: category, + })); + + 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 000000000000..c0233369058e --- /dev/null +++ b/src/pages/workspace/rules/SpendRules/SpendRuleMaxAmountPage.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import {View} from 'react-native'; +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, getSelectedCardsSharedCurrency} 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'; + +type SpendRuleMaxAmountPageProps = PlatformStackScreenProps; + +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 = getSelectedCardsSharedCurrency(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 000000000000..31dfb1cf7b93 --- /dev/null +++ b/src/pages/workspace/rules/SpendRules/SpendRuleMerchantEditPage.tsx @@ -0,0 +1,151 @@ +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) => { + setMatchType(item.value); + }; + + 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 000000000000..dd5e12de10e0 --- /dev/null +++ b/src/pages/workspace/rules/SpendRules/SpendRuleMerchantsPage.tsx @@ -0,0 +1,119 @@ +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(['FoodTruck']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus']); + + const restrictionAction = spendRuleForm?.restrictionAction ?? CONST.SPEND_RULES.ACTION.ALLOW; + const merchantNames = spendRuleForm?.merchantNames ?? []; + const merchantMatchTypes = spendRuleForm?.merchantMatchTypes ?? []; + + const emptyStateTitle = + restrictionAction === CONST.SPEND_RULES.ACTION.BLOCK ? translate('workspace.rules.spendRules.noBlockedMerchants') : translate('workspace.rules.spendRules.noAllowedMerchants'); + + const emptyStateSubtitle = + restrictionAction === CONST.SPEND_RULES.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 000000000000..cede12a24247 --- /dev/null +++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx @@ -0,0 +1,233 @@ +import React, {useEffect, useState} 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 {setExpensifyCardRule} from '@libs/actions/Card'; +import {clearDraftSpendRule, updateDraftSpendRule} from '@libs/actions/User'; +import {filterInactiveCards, getCardDescriptionForSearchTable, getSelectedCardsSharedCurrency} from '@libs/CardUtils'; +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 type {SpendRuleCategory} from '@src/types/form/SpendRuleForm'; +import SpendRuleRestrictionTypeToggle from './SpendRuleRestrictionTypeToggle'; +import getTruncatedSpendRuleSummary from './SpendRuleSummaryUtils'; + +type SpendRulePageBaseProps = { + policyID: string; + titleKey: TranslationPaths; + testID: string; +}; + +function getErrorMessage(hasSelectedCards: boolean, hasAnyRuleApplied: boolean, translate: (path: TranslationPaths) => string) { + if (!hasSelectedCards && !hasAnyRuleApplied) { + return translate('workspace.rules.spendRules.confirmErrorCardRequired'); + } + if (!hasSelectedCards) { + return translate('workspace.rules.spendRules.confirmErrorApplyAtLeastOneSpendRuleToOneCard'); + } + if (!hasAnyRuleApplied) { + return translate('workspace.rules.spendRules.confirmErrorApplyAtLeastOneSpendRule'); + } + return ''; +} + +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}); + const [isErrorVisible, setIsErrorVisible] = useState(false); + + useEffect(() => () => clearDraftSpendRule(), []); + + const cardIDs = spendRuleForm?.cardIDs; + const restrictionAction = spendRuleForm?.restrictionAction ?? CONST.SPEND_RULES.ACTION.ALLOW; + const merchantNames = spendRuleForm?.merchantNames ?? []; + const categories = spendRuleForm?.categories ?? []; + const maxAmount = spendRuleForm?.maxAmount ?? ''; + + const clearError = () => { + setIsErrorVisible(false); + }; + + const selectedCurrency = getSelectedCardsSharedCurrency(cardIDs, cardsList); + const parsedMaxAmount = Number.parseFloat(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 getCardsMenuTitle(cardIDsToSummarize: string[] | undefined): string { + return getTruncatedSpendRuleSummary( + cardIDsToSummarize?.map((id) => { + const card = cardsList?.[id]; + if (card === undefined) { + return id; + } + const accountID = card.accountID ?? CONST.DEFAULT_NUMBER_ID; + const displayName = getDisplayNameOrDefault(personalDetails?.[accountID], '', false); + return getCardDescriptionForSearchTable(card, displayName || undefined) || id; + }), + (summary, count) => translate('workspace.rules.spendRules.summaryMoreCount', {summary, count}), + ); + } + + function getMerchantMenuTitle(merchantNamesToSummarize: string[] | undefined): string { + return getTruncatedSpendRuleSummary(merchantNamesToSummarize, (summary, count) => translate('workspace.rules.spendRules.summaryMoreCount', {summary, count})); + } + + function getCategoryMenuTitle(categoriesToSummarize: SpendRuleCategory[] | undefined): string { + return getTruncatedSpendRuleSummary( + categoriesToSummarize?.map((category) => translate(`workspace.rules.spendRules.categoryOptions.${category}`)), + (summary, count) => translate('workspace.rules.spendRules.summaryMoreCount', {summary, count}), + ); + } + + const cardsMenuTitle = getCardsMenuTitle(cardIDs); + const categoriesMenuTitle = getCategoryMenuTitle(categories); + + const hasSelectedCards = !!cardIDs?.length; + const hasAnyMerchant = merchantNames.some((name) => name.trim() !== ''); + const hasAnyCategory = categories.length > 0; + const hasMaxAmount = maxAmount.trim() !== ''; + const hasAnyRuleApplied = hasAnyMerchant || hasAnyCategory || hasMaxAmount; + const errorMessage = getErrorMessage(hasSelectedCards, hasAnyRuleApplied, translate); + + const handleSaveRule = () => { + if (errorMessage) { + setIsErrorVisible(true); + return; + } + + if (!cardIDs) { + return; + } + + clearError(); + setExpensifyCardRule(domainAccountID, rand64(), spendRuleForm); + clearDraftSpendRule(); + Navigation.goBack(); + }; + + return ( + + + + + {translate('workspace.rules.spendRules.cardsSectionTitle')} + { + clearError(); + Navigation.navigate(ROUTES.RULES_SPEND_CARD.getRoute(policyID)); + }} + shouldShowRightIcon + title={cardsMenuTitle} + numberOfLinesTitle={2} + titleStyle={styles.flex1} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM} + /> + {translate('workspace.rules.spendRules.spendRuleSectionTitle')} + + { + clearError(); + updateDraftSpendRule({restrictionAction: action}); + }} + /> + + { + clearError(); + 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} + /> + { + clearError(); + 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} + /> + { + clearError(); + if (!selectedCurrency) { + openCurrencyMismatchModal(); + return; + } + 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 000000000000..ee9bb865bbcd --- /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_RULES.ACTION.ALLOW; + const isBlockSelected = restrictionAction === CONST.SPEND_RULES.ACTION.BLOCK; + + const restrictionTypeHelperText = isAllowSelected ? translate('workspace.rules.spendRules.restrictionTypeHelpAllow') : translate('workspace.rules.spendRules.restrictionTypeHelpBlock'); + + return ( + <> + + {translate('workspace.rules.spendRules.restrictionType')} + +