From bd0571a5946cea4d1a68915b8b4d3f572c844eae Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 30 Mar 2026 12:27:48 -0600 Subject: [PATCH 01/31] Add spend rule base page --- src/ROUTES.ts | 4 ++ src/SCREENS.ts | 1 + .../ModalStackNavigators/index.tsx | 1 + .../RELATIONS/WORKSPACE_TO_RHP.ts | 1 + src/libs/Navigation/linkingConfig/config.ts | 3 ++ src/libs/Navigation/types.ts | 3 ++ .../rules/SpendRules/AddSpendRulePage.tsx | 21 ++++++++++ .../rules/SpendRules/SpendRulePageBase.tsx | 41 +++++++++++++++++++ 8 files changed, 75 insertions(+) create mode 100644 src/pages/workspace/rules/SpendRules/AddSpendRulePage.tsx create mode 100644 src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx diff --git a/src/ROUTES.ts b/src/ROUTES.ts index d371fe3dcf24e..84934bd034b31 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2943,6 +2943,10 @@ 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_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 f30e9c91cf109..589664c675343 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -802,6 +802,7 @@ 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_MERCHANT_MERCHANT_TO_MATCH: 'Rules_Merchant_Merchant_To_Match', RULES_MERCHANT_MATCH_TYPE: 'Rules_Merchant_Match_Type', RULES_MERCHANT_MERCHANT: 'Rules_Merchant_Merchant', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index d3c8df88248ec..e2a3429d5d4b4 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -882,6 +882,7 @@ 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_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 cd2fb229d2172..9b21e9a529a18 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -292,6 +292,7 @@ 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_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 5a5ca4666919d..ad4c16878b706 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1466,6 +1466,9 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { policyID: string; }; + [SCREENS.WORKSPACE.RULES_SPEND_NEW]: { + policyID: string; + }; [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH]: { policyID: string; ruleID: string; diff --git a/src/pages/workspace/rules/SpendRules/AddSpendRulePage.tsx b/src/pages/workspace/rules/SpendRules/AddSpendRulePage.tsx new file mode 100644 index 0000000000000..faa25375a5ffd --- /dev/null +++ b/src/pages/workspace/rules/SpendRules/AddSpendRulePage.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import type SCREENS from '@src/SCREENS'; +import SpendRulePageBase from './SpendRulePageBase'; + +type AddSpendRulePageProps = PlatformStackScreenProps; + +function AddSpendRulePage({route}: AddSpendRulePageProps) { + return ( + + ); +} + +AddSpendRulePage.displayName = 'AddSpendRulePage'; + +export default AddSpendRulePage; diff --git a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx new file mode 100644 index 0000000000000..f9be49ebd1cd9 --- /dev/null +++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; + +type SpendRulePageBaseProps = { + policyID: string; + titleKey: TranslationPaths; + testID: string; +}; + +function SpendRulePageBase({policyID, titleKey, testID}: SpendRulePageBaseProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + return ( + + + + + + + ); +} + +SpendRulePageBase.displayName = 'SpendRulePageBase'; + +export default SpendRulePageBase; From 75d6d23b2a40a48bbbbd42524ddf2e6e3434db4b Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 30 Mar 2026 12:52:43 -0600 Subject: [PATCH 02/31] add spend rule button --- src/CONST/index.ts | 1 + src/languages/en.ts | 1 + src/pages/workspace/rules/PolicyRulesPage.tsx | 2 +- .../rules/SpendRules/SpendRulesSection.tsx | 20 +++++++++++++++++-- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 0f75a3da1071a..5edb929b39ff7 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -9126,6 +9126,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', diff --git a/src/languages/en.ts b/src/languages/en.ts index 8946f7b430853..03dd094d3e5b9 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6664,6 +6664,7 @@ 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', }, }, planTypePage: { diff --git a/src/pages/workspace/rules/PolicyRulesPage.tsx b/src/pages/workspace/rules/PolicyRulesPage.tsx index 4a0741f8e8b5d..1685065dc958e 100644 --- a/src/pages/workspace/rules/PolicyRulesPage.tsx +++ b/src/pages/workspace/rules/PolicyRulesPage.tsx @@ -56,7 +56,7 @@ function PolicyRulesPage({route}: PolicyRulesPageProps) { - {!!policy?.areExpensifyCardsEnabled && } + {!!policy?.areExpensifyCardsEnabled && } diff --git a/src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx b/src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx index eaeac4364e0ee..56406e4e755b6 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx @@ -14,14 +14,20 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import Navigation from '@libs/Navigation/Navigation'; +import ROUTES from '@src/ROUTES'; -function SpendRulesSection() { +type SpendRulesSectionProps = { + policyID: string; +}; + +function SpendRulesSection({policyID}: SpendRulesSectionProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const theme = useTheme(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lock']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lock', 'Plus']); const {showConfirmModal} = useConfirmModal(); const illustrations = useMemoizedLazyIllustrations(['ExpensifyCardProtectionIllustration']); @@ -106,6 +112,16 @@ function SpendRulesSection() { onPress={showBuiltInProtectionModal} shouldShowRightIcon /> + Navigation.navigate(ROUTES.RULES_SPEND_NEW.getRoute(policyID))} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.ADD_SPEND_RULE} + /> ); } From 3ff1fe7382718d28e6eaee692d1bcb0c7d3ae83a Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 30 Mar 2026 12:58:52 -0600 Subject: [PATCH 03/31] put button behind dev beta --- src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx b/src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx index 56406e4e755b6..af8d25edb77d4 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx @@ -11,6 +11,7 @@ import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; +import useEnvironment from '@hooks/useEnvironment'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -30,6 +31,7 @@ function SpendRulesSection({policyID}: SpendRulesSectionProps) { const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lock', 'Plus']); const {showConfirmModal} = useConfirmModal(); const illustrations = useMemoizedLazyIllustrations(['ExpensifyCardProtectionIllustration']); + const {isDevelopment} = useEnvironment(); const showBuiltInProtectionModal = () => { showConfirmModal({ @@ -112,7 +114,7 @@ function SpendRulesSection({policyID}: SpendRulesSectionProps) { onPress={showBuiltInProtectionModal} shouldShowRightIcon /> - Navigation.navigate(ROUTES.RULES_SPEND_NEW.getRoute(policyID))} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.ADD_SPEND_RULE} - /> + />} ); } From 1720652ba9f519c1e143a44650605942755f78cc Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 30 Mar 2026 12:59:20 -0600 Subject: [PATCH 04/31] unlock to staging too --- src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx b/src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx index af8d25edb77d4..b08f6ec371823 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx @@ -31,7 +31,7 @@ function SpendRulesSection({policyID}: SpendRulesSectionProps) { const expensifyIcons = useMemoizedLazyExpensifyIcons(['Lock', 'Plus']); const {showConfirmModal} = useConfirmModal(); const illustrations = useMemoizedLazyIllustrations(['ExpensifyCardProtectionIllustration']); - const {isDevelopment} = useEnvironment(); + const {isProduction} = useEnvironment(); const showBuiltInProtectionModal = () => { showConfirmModal({ @@ -114,7 +114,7 @@ function SpendRulesSection({policyID}: SpendRulesSectionProps) { onPress={showBuiltInProtectionModal} shouldShowRightIcon /> - {isDevelopment && Date: Mon, 30 Mar 2026 13:12:08 -0600 Subject: [PATCH 05/31] add save button --- src/CONST/index.ts | 9 +++++++ .../parameters/SetExpensifyCardRuleParams.ts | 7 ++++++ src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 ++ src/libs/actions/Card.ts | 6 +++++ .../rules/SpendRules/SpendRulePageBase.tsx | 25 ++++++++++++++++++- 6 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 src/libs/API/parameters/SetExpensifyCardRuleParams.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 5edb929b39ff7..226db0eb56696 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4195,6 +4195,13 @@ const CONST = { }, }, + SPEND_CARD_RULE: { + ACTION: { + ALLOW: 'allow', + BLOCK: 'block', + }, + }, + get SUBSCRIPTION_PRICES() { return { [this.PAYMENT_CARD_CURRENCY.USD]: { @@ -9136,6 +9143,8 @@ 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', }, EXPENSIFY_CARD: { ISSUE_CARD_BUTTON: 'WorkspaceExpensifyCard-IssueCardButton', diff --git a/src/libs/API/parameters/SetExpensifyCardRuleParams.ts b/src/libs/API/parameters/SetExpensifyCardRuleParams.ts new file mode 100644 index 0000000000000..ab47148ef022f --- /dev/null +++ b/src/libs/API/parameters/SetExpensifyCardRuleParams.ts @@ -0,0 +1,7 @@ +type SetExpensifyCardRuleParams = { + domainAccountID: number; + cardRuleID: string; + cardRuleValue: string; +}; + +export default SetExpensifyCardRuleParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index e0a7d276866af..8f5b1049eebb1 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -352,6 +352,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 7caae0c265fc3..ebc1c0bc398d1 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -466,6 +466,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', @@ -1071,6 +1072,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/actions/Card.ts b/src/libs/actions/Card.ts index 41ed86a7e8762..67d69e0dcbdc0 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -11,6 +11,7 @@ import type { ReportVirtualExpensifyCardFraudParams, RequestReplacementExpensifyCardParams, ResolveFraudAlertParams, + SetExpensifyCardRuleParams, RevealExpensifyCardDetailsParams, SetPersonalCardReimbursableParams, StartIssueNewCardFlowParams, @@ -1555,6 +1556,10 @@ function queueExpensifyCardForBilling(feedCountry: string, domainAccountID: numb API.write(WRITE_COMMANDS.QUEUE_EXPENSIFY_CARD_FOR_BILLING, parameters); } +function setExpensifyCardRule(parameters: SetExpensifyCardRuleParams) { + API.write(WRITE_COMMANDS.SET_EXPENSIFY_CARD_RULE, parameters); +} + /** * 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 +1661,6 @@ export { clearIssueNewCardFormData, setDraftInviteAccountID, resolveFraudAlert, + setExpensifyCardRule, }; export type {ReplacementReason}; diff --git a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx index f9be49ebd1cd9..25d8cbbfeda07 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx @@ -1,9 +1,14 @@ -import React from 'react'; +import React, {useCallback} from 'react'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import useDefaultFundID from '@hooks/useDefaultFundID'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import {setExpensifyCardRule} from '@libs/actions/Card'; +import Navigation from '@libs/Navigation/Navigation'; +import {rand64} from '@libs/NumberUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -17,6 +22,16 @@ type SpendRulePageBaseProps = { function SpendRulePageBase({policyID, titleKey, testID}: SpendRulePageBaseProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const domainAccountID = useDefaultFundID(policyID); + + const handleSaveRule = useCallback(() => { + setExpensifyCardRule({ + domainAccountID, + cardRuleID: String(rand64()), + cardRuleValue: '', + }); + Navigation.goBack(); + }, [domainAccountID]); return ( + ); From 78c90d347dc3420c3b9615aef7a514d3652a525a Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 30 Mar 2026 13:45:46 -0600 Subject: [PATCH 06/31] add card page --- src/ROUTES.ts | 4 + src/SCREENS.ts | 1 + src/languages/de.ts | 4 + src/languages/en.ts | 3 + src/languages/es.ts | 4 + src/languages/fr.ts | 4 + src/languages/it.ts | 4 + src/languages/ja.ts | 4 + src/languages/nl.ts | 4 + src/languages/pl.ts | 4 + src/languages/pt-BR.ts | 4 + src/languages/zh-hans.ts | 4 + .../ModalStackNavigators/index.tsx | 1 + .../RELATIONS/WORKSPACE_TO_RHP.ts | 1 + src/libs/Navigation/linkingConfig/config.ts | 3 + src/libs/Navigation/types.ts | 3 + .../rules/SpendRules/AddCardPage.tsx | 232 ++++++++++++++++++ .../rules/SpendRules/SpendRulePageBase.tsx | 16 +- src/types/onyx/ExpensifyCardSettings.ts | 31 ++- 19 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 src/pages/workspace/rules/SpendRules/AddCardPage.tsx diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 84934bd034b31..1e73426b0e5e4 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2947,6 +2947,10 @@ const ROUTES = { 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_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 589664c675343..fd067ba1ca4cc 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -803,6 +803,7 @@ const SCREENS = { 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_MERCHANT_MERCHANT_TO_MATCH: 'Rules_Merchant_Merchant_To_Match', RULES_MERCHANT_MATCH_TYPE: 'Rules_Merchant_Match_Type', RULES_MERCHANT_MERCHANT: 'Rules_Merchant_Merchant', diff --git a/src/languages/de.ts b/src/languages/de.ts index ce5348836347f..5a568f46ca9be 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6679,6 +6679,10 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und title: 'Expensify Karten bieten integrierten Schutz – jederzeit', description: `Expensify lehnt diese Belastungen immer ab:\n\n • Dienstleistungen für Erwachsene\n • Geldautomaten (ATMs)\n • Glücksspiel\n • Geldüberweisungen\n\nFügen Sie weitere Ausgabenregeln hinzu, um den Cashflow des Unternehmens zu schützen.`, }, + addSpendRule: 'Ausgabenregel hinzufügen', + cardPageTitle: 'Karte', + cardsSectionTitle: 'Karten', + chooseCards: 'Karten auswählen', }, }, planTypePage: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 03dd094d3e5b9..e5947e97da6e3 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6665,6 +6665,9 @@ const translations = { 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', }, }, planTypePage: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 5aa191bc1e2d6..b8997102d2902 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6579,6 +6579,10 @@ ${amount} para ${merchant} - ${date}`, title: 'Las tarjetas Expensify incluyen 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\nAñada más reglas de gastos para proteger el flujo de caja de la empresa.`, }, + addSpendRule: 'Añadir regla de gastos', + cardPageTitle: 'Tarjeta', + cardsSectionTitle: 'Tarjetas', + chooseCards: 'Elegir tarjetas', }, }, }, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 9ca15947f2777..85388ba4c1476 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6703,6 +6703,10 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip title: 'Les Cartes Expensify offrent une protection intégrée – en permanence', description: `Expensify refuse toujours ces types de dépenses:\n\n • Services pour adultes\n • DAB\n • Jeux d’argent\n • Transferts d’argent\n\nAjoutez davantage de règles de dépenses pour protéger la trésorerie de l’entreprise.`, }, + addSpendRule: 'Ajouter une règle de dépenses', + cardPageTitle: 'Carte', + cardsSectionTitle: 'Cartes', + chooseCards: 'Choisir des cartes', }, }, planTypePage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index d751f49ba1689..d78d88f801ad0 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6666,6 +6666,10 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo title: 'Le Carte Expensify offrono una protezione integrata, sempre', description: `Expensify rifiuta sempre queste spese:\n\n • Servizi per adulti\n • Bancomat\n • Gioco d’azzardo\n • Trasferimenti di denaro\n\nAggiungi altre regole di spesa per proteggere il flusso di cassa dell’azienda.`, }, + addSpendRule: 'Aggiungi regola di spesa', + cardPageTitle: 'Carta', + cardsSectionTitle: 'Carte', + chooseCards: 'Scegli le carte', }, }, planTypePage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index a760985cbb0d6..5541471aae7b2 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6592,6 +6592,10 @@ ${reportName} title: 'Expensify カードは、常に組み込みの保護機能を備えています', description: `Expensify は常に次の利用を拒否します:\n\n ・アダルトサービス\n ・ATM\n ・ギャンブル\n ・送金\n\n会社のキャッシュフローを守るために、支出ルールをさらに追加しましょう。`, }, + addSpendRule: '支出ルールを追加', + cardPageTitle: 'カード', + cardsSectionTitle: 'カード', + chooseCards: 'カードを選択', }, }, planTypePage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index c12c131155de5..c6d2a6a514b74 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6645,6 +6645,10 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar title: 'Expensify Kaarten bieden altijd ingebouwde bescherming', description: `Expensify wijst deze transacties altijd af:\n\n • Seksdiensten\n • Geldautomaten (ATM's)\n • Gokken\n • Geldoverschrijvingen\n\nVoeg meer bestedingsregels toe om de cashflow van je bedrijf te beschermen.`, }, + addSpendRule: 'Bestedingsregel toevoegen', + cardPageTitle: 'Kaart', + cardsSectionTitle: 'Kaarten', + chooseCards: 'Kaarten kiezen', }, }, planTypePage: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 7b4731fcdd9fd..af9ad88d07cf4 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6640,6 +6640,10 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i title: 'Karty Expensify zapewniają wbudowaną ochronę – zawsze', description: `Expensify zawsze odrzuca te obciążenia:\n\n • Usługi dla dorosłych\n • Bankomaty\n • Hazard\n • Przelewy pieniężne\n\nDodaj więcej zasad wydatków, żeby chronić przepływy pieniężne firmy.`, }, + addSpendRule: 'Dodaj zasadę wydatków', + cardPageTitle: 'Karta', + cardsSectionTitle: 'Karty', + chooseCards: 'Wybierz karty', }, }, planTypePage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index e978d8a9eb4da..190619afdad6c 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6644,6 +6644,10 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e title: 'Os Cartões Expensify oferecem proteção integrada — sempre', description: `A Expensify sempre recusa estas cobranças:\n\n • Serviços adultos\n • Caixas eletrônicos (ATMs)\n • Jogos de azar\n • Transferências de dinheiro\n\nAdicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, }, + addSpendRule: 'Adicionar regra de gastos', + cardPageTitle: 'Cartão', + cardsSectionTitle: 'Cartões', + chooseCards: 'Escolher cartões', }, }, planTypePage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 4e546312117db..152cdb3c8db0d 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6480,6 +6480,10 @@ ${reportName} title: 'Expensify 卡提供始终内置的保护', description: `Expensify 始终会拒绝以下消费:\n\n • 成人服务\n • 自动取款机(ATM)\n • 赌博\n • 转账汇款\n\n添加更多消费规则,保护公司的现金流。`, }, + addSpendRule: '添加支出规则', + cardPageTitle: '卡', + cardsSectionTitle: '卡', + chooseCards: '选择卡', }, }, planTypePage: { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index e2a3429d5d4b4..fe976e6fae515 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -883,6 +883,7 @@ const SettingsModalStackNavigator = createModalStackNavigator 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/AddCardPage').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 9b21e9a529a18..db9e02fd8be8d 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -293,6 +293,7 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [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_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 ad4c16878b706..cd7e3ceae2bfe 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1469,6 +1469,9 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.RULES_SPEND_NEW]: { policyID: string; }; + [SCREENS.WORKSPACE.RULES_SPEND_CARD]: { + policyID: string; + }; [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH]: { policyID: string; ruleID: string; diff --git a/src/pages/workspace/rules/SpendRules/AddCardPage.tsx b/src/pages/workspace/rules/SpendRules/AddCardPage.tsx new file mode 100644 index 0000000000000..99fee9af23e4f --- /dev/null +++ b/src/pages/workspace/rules/SpendRules/AddCardPage.tsx @@ -0,0 +1,232 @@ +import React, {useCallback, useEffect, useMemo, 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 UserListItem from '@components/SelectionList/ListItem/UserListItem'; +import type {ListItem} from '@components/SelectionList/types'; +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 usePolicy from '@hooks/usePolicy'; +import useSearchResults from '@hooks/useSearchResults'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {updateSelectedExpensifyCardFeed} from '@libs/actions/Card'; +import {filterCardsByPersonalDetails, filterInactiveCards, getCardsByCardholderName, 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 {getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils'; +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} from '@src/types/onyx'; +import type {ExpensifyCardRuleFilter} from '@src/types/onyx/ExpensifyCardSettings'; + +type ExpensifyCardListItem = ListItem & { + card: Card; +}; + +type AddCardPageProps = PlatformStackScreenProps; + +function AddCardPage({route}: AddCardPageProps) { + const {policyID} = route.params; + const styles = useThemeStyles(); + const {translate, localeCompare} = useLocalize(); + const policy = usePolicy(policyID); + 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 illustrations = useMemoizedLazyIllustrations(['Telescope']); + + const [selectedCardIDs, setSelectedCardIDs] = useState([]); + + const cardIDsWithSpendRules = useMemo(() => { + const into = new Set(); + const cardRules = expensifyCardSettings?.cardRules; + if (!cardRules) { + return into; + } + + const addIdentifiersFromRight = (right: string | number | string[]) => { + if (Array.isArray(right)) { + for (const id of right) { + into.add(String(id)); + } + return; + } + into.add(String(right)); + }; + + const collectCardIDsFromFilter = (node: ExpensifyCardRuleFilter | undefined) => { + if (!node) { + return; + } + if (typeof node.left === 'string') { + if (node.left === 'cardID' && node.operator === 'eq') { + addIdentifiersFromRight(node.right); + } + return; + } + collectCardIDsFromFilter(node.left); + collectCardIDsFromFilter(node.right); + }; + + for (const rule of Object.values(cardRules)) { + if (!rule) { + continue; + } + for (const id of rule.cardID ?? []) { + into.add(String(id)); + } + collectCardIDsFromFilter(rule.filters); + } + + return into; + }, [expensifyCardSettings?.cardRules]); + + const eligibleCards = useMemo(() => { + const policyMembersAccountIDs = Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList)); + const allCards = getCardsByCardholderName(cardsList, policyMembersAccountIDs); + return allCards.filter((card) => !cardIDsWithSpendRules.has(String(card.cardID))); + }, [cardsList, policy?.employeeList, cardIDsWithSpendRules]); + + const filterCard = useCallback((card: Card, searchInput: string) => filterCardsByPersonalDetails(card, searchInput, personalDetails), [personalDetails]); + const sortCards = useCallback((cards: Card[]) => sortCardsByCardholderName(cards, personalDetails, localeCompare), [personalDetails, localeCompare]); + + const [inputValue, setInputValue, filteredCards] = useSearchResults(eligibleCards, filterCard, sortCards); + + const listData: ExpensifyCardListItem[] = useMemo( + () => + filteredCards.map((card) => { + const accountID = card.accountID ?? CONST.DEFAULT_NUMBER_ID; + const displayName = getDisplayNameOrDefault(personalDetails?.[accountID], '', false); + const lastFour = card.lastFourPAN ?? ''; + return { + keyForList: String(card.cardID), + text: displayName, + alternateText: lastFour, + accountID, + card, + }; + }), + [filteredCards, personalDetails], + ); + + const fetchCards = useCallback(() => { + updateSelectedExpensifyCardFeed(defaultFundID, policyID); + openPolicyExpensifyCardsPage(policyID, defaultFundID); + }, [defaultFundID, policyID]); + + useEffect(() => { + fetchCards(); + }, [fetchCards]); + + useNetwork({onReconnect: fetchCards}); + + const backToSpendRule = ROUTES.RULES_SPEND_NEW.getRoute(policyID); + + const toggleCard = useCallback((item: ExpensifyCardListItem) => { + setSelectedCardIDs((prev) => { + if (prev.includes(item.keyForList)) { + return prev.filter((id) => id !== item.keyForList); + } + return [...prev, item.keyForList]; + }); + }, []); + + const toggleSelectAll = useCallback(() => { + 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); + }); + }, [listData, selectedCardIDs]); + + const handleSave = useCallback(() => { + Navigation.goBack(backToSpendRule); + }, [backToSpendRule]); + + const headerMessage = getHeaderMessage(listData.length > 0, false, inputValue, countryCode, false); + + return ( + + + Navigation.goBack(backToSpendRule)} + /> + 0 ? toggleSelectAll : undefined} + onCheckboxPress={toggleCard} + onSelectRow={toggleCard} + selectedItems={selectedCardIDs} + ListItem={UserListItem} + shouldUseDefaultRightHandSideCheckmark={false} + shouldUpdateFocusedIndex + shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} + listEmptyContent={ + + } + footerContent={ + + } + /> + + + ); +} + +AddCardPage.displayName = 'AddCardPage'; + +export default AddCardPage; diff --git a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx index 25d8cbbfeda07..f47da2c1624ae 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx @@ -1,8 +1,11 @@ import React, {useCallback} from 'react'; +import {View} from 'react-native'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItem from '@components/MenuItem'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; import useDefaultFundID from '@hooks/useDefaultFundID'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -12,6 +15,7 @@ import {rand64} from '@libs/NumberUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; +import ROUTES from '@src/ROUTES'; type SpendRulePageBaseProps = { policyID: string; @@ -45,7 +49,17 @@ function SpendRulePageBase({policyID, titleKey, testID}: SpendRulePageBaseProps) includeSafeAreaPaddingBottom > - + + + {translate('workspace.rules.spendRules.cardsSectionTitle')} + Navigation.navigate(ROUTES.RULES_SPEND_CARD.getRoute(policyID))} + shouldShowRightIcon + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.SPEND_RULE_SECTION_ITEM} + /> + + ; + + /** The right side of the filter condition (e.g., 'Snoop') */ + right: ExpensifyCardRuleFilter | string; +}; + +/** Expensify card rule data model */ +type ExpensifyCardRule = { + /** Date the rule was created */ + created: string; + + /** Filter AST evaluated for the transaction */ + filters: ExpensifyCardRuleFilter; + + /** Action to take when the rule is matched */ + action: ValueOf; +}; + /** Model of Expensify card settings for a workspace - can have nested feed types from backend */ type ExpensifyCardSettings = OnyxCommon.OnyxValueWithOfflineFeedback< ExpensifyCardSettingsBase & { @@ -85,10 +111,13 @@ type ExpensifyCardSettings = OnyxCommon.OnyxValueWithOfflineFeedback< // eslint-disable-next-line @typescript-eslint/naming-convention TRAVEL_US?: ExpensifyCardSettingsBase; + /** Spend rules for the feed */ + cardRules?: ExpensifyCardRule; + /** Whether the card settings has been loaded before */ hasOnceLoaded?: boolean; } >; export default ExpensifyCardSettings; -export type {ExpensifyCardSettingsBase}; +export type {ExpensifyCardSettingsBase, ExpensifyCardRuleFilter}; From 456102760d7eb746050b3491339b9a3c182666b2 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 30 Mar 2026 13:59:17 -0600 Subject: [PATCH 07/31] add corect menu item --- .../rules/SpendRules/SpendRulePageBase.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx index f47da2c1624ae..9c3ee42aac384 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx @@ -1,8 +1,7 @@ import React, {useCallback} from 'react'; -import {View} from 'react-native'; 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 Text from '@components/Text'; @@ -50,15 +49,16 @@ function SpendRulePageBase({policyID, titleKey, testID}: SpendRulePageBaseProps) > - - {translate('workspace.rules.spendRules.cardsSectionTitle')} - Navigation.navigate(ROUTES.RULES_SPEND_CARD.getRoute(policyID))} - shouldShowRightIcon - sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.SPEND_RULE_SECTION_ITEM} - /> - + {translate('workspace.rules.spendRules.cardsSectionTitle')} + Navigation.navigate(ROUTES.RULES_SPEND_CARD.getRoute(policyID))} + shouldShowRightIcon + title={''} + titleStyle={styles.flex1} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM} + /> Date: Mon, 30 Mar 2026 14:00:07 -0600 Subject: [PATCH 08/31] use correct translation --- src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index e5947e97da6e3..4d3db5bc59157 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6668,6 +6668,7 @@ const translations = { cardPageTitle: 'Card', cardsSectionTitle: 'Cards', chooseCards: 'Choose cards', + saveRule: 'Save rule', }, }, planTypePage: { diff --git a/src/languages/es.ts b/src/languages/es.ts index b8997102d2902..dfaeca6ed399b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6583,6 +6583,7 @@ ${amount} para ${merchant} - ${date}`, cardPageTitle: 'Tarjeta', cardsSectionTitle: 'Tarjetas', chooseCards: 'Elegir tarjetas', + saveRule: 'Guardar regla', }, }, }, diff --git a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx index 9c3ee42aac384..6d425d4a18412 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx @@ -61,7 +61,7 @@ function SpendRulePageBase({policyID, titleKey, testID}: SpendRulePageBaseProps) /> Date: Mon, 30 Mar 2026 14:29:08 -0600 Subject: [PATCH 09/31] add traverse function --- .../rules/SpendRules/AddCardPage.tsx | 78 ++++++++----------- src/types/onyx/ExpensifyCardSettings.ts | 10 +-- 2 files changed, 36 insertions(+), 52 deletions(-) diff --git a/src/pages/workspace/rules/SpendRules/AddCardPage.tsx b/src/pages/workspace/rules/SpendRules/AddCardPage.tsx index 99fee9af23e4f..cd91bba48891b 100644 --- a/src/pages/workspace/rules/SpendRules/AddCardPage.tsx +++ b/src/pages/workspace/rules/SpendRules/AddCardPage.tsx @@ -31,7 +31,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {Card} from '@src/types/onyx'; -import type {ExpensifyCardRuleFilter} from '@src/types/onyx/ExpensifyCardSettings'; +import type {ExpensifyCardRule, ExpensifyCardRuleFilter} from '@src/types/onyx/ExpensifyCardSettings'; type ExpensifyCardListItem = ListItem & { card: Card; @@ -39,6 +39,33 @@ type ExpensifyCardListItem = ListItem & { type AddCardPageProps = PlatformStackScreenProps; +function getCardIDsWithSpendRules(cardRules: Record | undefined): Set { + const cardIDs = new Set(); + if (!cardRules) { + return cardIDs; + } + + const traverseFilters = (filters: ExpensifyCardRuleFilter) => { + if ((filters.operator === CONST.SEARCH.SYNTAX_OPERATORS.AND || filters.operator === CONST.SEARCH.SYNTAX_OPERATORS.OR)) { + traverseFilters(filters.left as ExpensifyCardRuleFilter); + traverseFilters(filters.right as ExpensifyCardRuleFilter); + return; + } + + if (filters.left === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID && filters.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO && Array.isArray(filters.right)) { + for (const cardID of filters.right) { + cardIDs.add(cardID); + } + } + }; + + for (const rule of Object.values(cardRules)) { + traverseFilters(rule.filters); + } + + return cardIDs; +} + function AddCardPage({route}: AddCardPageProps) { const {policyID} = route.params; const styles = useThemeStyles(); @@ -53,55 +80,12 @@ function AddCardPage({route}: AddCardPageProps) { const [selectedCardIDs, setSelectedCardIDs] = useState([]); - const cardIDsWithSpendRules = useMemo(() => { - const into = new Set(); - const cardRules = expensifyCardSettings?.cardRules; - if (!cardRules) { - return into; - } - - const addIdentifiersFromRight = (right: string | number | string[]) => { - if (Array.isArray(right)) { - for (const id of right) { - into.add(String(id)); - } - return; - } - into.add(String(right)); - }; - - const collectCardIDsFromFilter = (node: ExpensifyCardRuleFilter | undefined) => { - if (!node) { - return; - } - if (typeof node.left === 'string') { - if (node.left === 'cardID' && node.operator === 'eq') { - addIdentifiersFromRight(node.right); - } - return; - } - collectCardIDsFromFilter(node.left); - collectCardIDsFromFilter(node.right); - }; - - for (const rule of Object.values(cardRules)) { - if (!rule) { - continue; - } - for (const id of rule.cardID ?? []) { - into.add(String(id)); - } - collectCardIDsFromFilter(rule.filters); - } - - return into; - }, [expensifyCardSettings?.cardRules]); - const eligibleCards = useMemo(() => { const policyMembersAccountIDs = Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList)); const allCards = getCardsByCardholderName(cardsList, policyMembersAccountIDs); - return allCards.filter((card) => !cardIDsWithSpendRules.has(String(card.cardID))); - }, [cardsList, policy?.employeeList, cardIDsWithSpendRules]); + const cardIDsWithSpendRules = getCardIDsWithSpendRules(expensifyCardSettings?.cardRules); + return allCards.filter((card) => !cardIDsWithSpendRules.has(card.cardID)); + }, [cardsList, policy?.employeeList, expensifyCardSettings?.cardRules]); const filterCard = useCallback((card: Card, searchInput: string) => filterCardsByPersonalDetails(card, searchInput, personalDetails), [personalDetails]); const sortCards = useCallback((cards: Card[]) => sortCardsByCardholderName(cards, personalDetails, localeCompare), [personalDetails, localeCompare]); diff --git a/src/types/onyx/ExpensifyCardSettings.ts b/src/types/onyx/ExpensifyCardSettings.ts index f9cacf8361196..a5a2ae9ec5bee 100644 --- a/src/types/onyx/ExpensifyCardSettings.ts +++ b/src/types/onyx/ExpensifyCardSettings.ts @@ -74,13 +74,13 @@ type ExpensifyCardSettingsBase = { /** Spend rule filter condition */ type ExpensifyCardRuleFilter = { /** The left side of the filter condition (e.g., 'merchant') */ - left: string; + left: ExpensifyCardRuleFilter | string; /** The operator for the filter, defined in CONST.SEARCH.SYNTAX_OPERATORS */ operator: ValueOf; /** The right side of the filter condition (e.g., 'Snoop') */ - right: ExpensifyCardRuleFilter | string; + right: ExpensifyCardRuleFilter | string[]; }; /** Expensify card rule data model */ @@ -111,8 +111,8 @@ type ExpensifyCardSettings = OnyxCommon.OnyxValueWithOfflineFeedback< // eslint-disable-next-line @typescript-eslint/naming-convention TRAVEL_US?: ExpensifyCardSettingsBase; - /** Spend rules for the feed */ - cardRules?: ExpensifyCardRule; + /** Spend rules for the feed keyed by rule ID */ + cardRules?: Record; /** Whether the card settings has been loaded before */ hasOnceLoaded?: boolean; @@ -120,4 +120,4 @@ type ExpensifyCardSettings = OnyxCommon.OnyxValueWithOfflineFeedback< >; export default ExpensifyCardSettings; -export type {ExpensifyCardSettingsBase, ExpensifyCardRuleFilter}; +export type {ExpensifyCardSettingsBase, ExpensifyCardRule, ExpensifyCardRuleFilter}; From 497ad267fec6d7816c0196ca41349f43041d003b Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 30 Mar 2026 14:55:04 -0600 Subject: [PATCH 10/31] add card selection logic --- .../rules/SpendRules/AddCardPage.tsx | 93 +++++++++---------- src/types/onyx/ExpensifyCardSettings.ts | 4 +- 2 files changed, 44 insertions(+), 53 deletions(-) diff --git a/src/pages/workspace/rules/SpendRules/AddCardPage.tsx b/src/pages/workspace/rules/SpendRules/AddCardPage.tsx index cd91bba48891b..15d2c9821edea 100644 --- a/src/pages/workspace/rules/SpendRules/AddCardPage.tsx +++ b/src/pages/workspace/rules/SpendRules/AddCardPage.tsx @@ -1,4 +1,5 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useEffect, useState} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; import BlockingView from '@components/BlockingViews/BlockingView'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -11,18 +12,15 @@ import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePolicy from '@hooks/usePolicy'; import useSearchResults from '@hooks/useSearchResults'; import useThemeStyles from '@hooks/useThemeStyles'; -import {updateSelectedExpensifyCardFeed} from '@libs/actions/Card'; -import {filterCardsByPersonalDetails, filterInactiveCards, getCardsByCardholderName, sortCardsByCardholderName} from '@libs/CardUtils'; +import {filterCardsByPersonalDetails, filterInactiveCards, 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 {getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import variables from '@styles/variables'; import {openPolicyExpensifyCardsPage} from '@userActions/Policy/Policy'; @@ -30,7 +28,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {Card} from '@src/types/onyx'; +import type {Card, ExpensifyCardSettings, WorkspaceCardsList} from '@src/types/onyx'; import type {ExpensifyCardRule, ExpensifyCardRuleFilter} from '@src/types/onyx/ExpensifyCardSettings'; type ExpensifyCardListItem = ListItem & { @@ -39,14 +37,14 @@ type ExpensifyCardListItem = ListItem & { type AddCardPageProps = PlatformStackScreenProps; -function getCardIDsWithSpendRules(cardRules: Record | undefined): Set { - const cardIDs = new Set(); +function getCardIDsWithSpendRules(cardRules: Record | undefined): Set { + const cardIDs = new Set(); if (!cardRules) { return cardIDs; } const traverseFilters = (filters: ExpensifyCardRuleFilter) => { - if ((filters.operator === CONST.SEARCH.SYNTAX_OPERATORS.AND || filters.operator === CONST.SEARCH.SYNTAX_OPERATORS.OR)) { + if (filters.operator === CONST.SEARCH.SYNTAX_OPERATORS.AND || filters.operator === CONST.SEARCH.SYNTAX_OPERATORS.OR) { traverseFilters(filters.left as ExpensifyCardRuleFilter); traverseFilters(filters.right as ExpensifyCardRuleFilter); return; @@ -66,11 +64,16 @@ function getCardIDsWithSpendRules(cardRules: Record | 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 AddCardPage({route}: AddCardPageProps) { const {policyID} = route.params; const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); - const policy = usePolicy(policyID); const defaultFundID = useDefaultFundID(policyID); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); @@ -80,58 +83,46 @@ function AddCardPage({route}: AddCardPageProps) { const [selectedCardIDs, setSelectedCardIDs] = useState([]); - const eligibleCards = useMemo(() => { - const policyMembersAccountIDs = Object.values(getMemberAccountIDsForWorkspace(policy?.employeeList)); - const allCards = getCardsByCardholderName(cardsList, policyMembersAccountIDs); - const cardIDsWithSpendRules = getCardIDsWithSpendRules(expensifyCardSettings?.cardRules); - return allCards.filter((card) => !cardIDsWithSpendRules.has(card.cardID)); - }, [cardsList, policy?.employeeList, expensifyCardSettings?.cardRules]); + const eligibleCards = getEligibleCards(cardsList, expensifyCardSettings ?? {}); - const filterCard = useCallback((card: Card, searchInput: string) => filterCardsByPersonalDetails(card, searchInput, personalDetails), [personalDetails]); - const sortCards = useCallback((cards: Card[]) => sortCardsByCardholderName(cards, personalDetails, localeCompare), [personalDetails, localeCompare]); + 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[] = useMemo( - () => - filteredCards.map((card) => { - const accountID = card.accountID ?? CONST.DEFAULT_NUMBER_ID; - const displayName = getDisplayNameOrDefault(personalDetails?.[accountID], '', false); - const lastFour = card.lastFourPAN ?? ''; - return { - keyForList: String(card.cardID), - text: displayName, - alternateText: lastFour, - accountID, - card, - }; - }), - [filteredCards, personalDetails], - ); + const listData: ExpensifyCardListItem[] = filteredCards.map((card) => { + const accountID = card.accountID ?? CONST.DEFAULT_NUMBER_ID; + const displayName = getDisplayNameOrDefault(personalDetails?.[accountID], '', false); + const lastFour = card.lastFourPAN ?? ''; + return { + keyForList: String(card.cardID), + text: displayName, + alternateText: lastFour, + accountID, + card, + }; + }); - const fetchCards = useCallback(() => { - updateSelectedExpensifyCardFeed(defaultFundID, policyID); + useEffect(() => { openPolicyExpensifyCardsPage(policyID, defaultFundID); }, [defaultFundID, policyID]); - useEffect(() => { - fetchCards(); - }, [fetchCards]); - - useNetwork({onReconnect: fetchCards}); - - const backToSpendRule = ROUTES.RULES_SPEND_NEW.getRoute(policyID); + useNetwork({ + onReconnect: () => { + openPolicyExpensifyCardsPage(policyID, defaultFundID); + }, + }); - const toggleCard = useCallback((item: ExpensifyCardListItem) => { + const toggleCard = (item: ExpensifyCardListItem) => { setSelectedCardIDs((prev) => { if (prev.includes(item.keyForList)) { return prev.filter((id) => id !== item.keyForList); } return [...prev, item.keyForList]; }); - }, []); + }; - const toggleSelectAll = useCallback(() => { + const toggleSelectAll = () => { const visibleKeys = listData.map((item) => item.keyForList); const allVisibleSelected = visibleKeys.length > 0 && visibleKeys.every((key) => selectedCardIDs.includes(key)); if (allVisibleSelected) { @@ -146,11 +137,11 @@ function AddCardPage({route}: AddCardPageProps) { } return Array.from(next); }); - }, [listData, selectedCardIDs]); + }; - const handleSave = useCallback(() => { - Navigation.goBack(backToSpendRule); - }, [backToSpendRule]); + const handleSave = () => { + Navigation.goBack(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); + }; const headerMessage = getHeaderMessage(listData.length > 0, false, inputValue, countryCode, false); @@ -168,7 +159,7 @@ function AddCardPage({route}: AddCardPageProps) { > Navigation.goBack(backToSpendRule)} + onBackButtonPress={() => Navigation.goBack(ROUTES.RULES_SPEND_NEW.getRoute(policyID))} /> ; - /** The right side of the filter condition (e.g., 'Snoop') */ - right: ExpensifyCardRuleFilter | string[]; + /** The right side of the filter condition (e.g., 1234) */ + right: ExpensifyCardRuleFilter | number[]; }; /** Expensify card rule data model */ From 52c8f363a0f79e2438cf50c1e612ee9393b2dbf9 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 30 Mar 2026 15:36:06 -0600 Subject: [PATCH 11/31] add correct card list item --- .../rules/SpendRules/AddCardPage.tsx | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/src/pages/workspace/rules/SpendRules/AddCardPage.tsx b/src/pages/workspace/rules/SpendRules/AddCardPage.tsx index 15d2c9821edea..e1c1521977f89 100644 --- a/src/pages/workspace/rules/SpendRules/AddCardPage.tsx +++ b/src/pages/workspace/rules/SpendRules/AddCardPage.tsx @@ -5,16 +5,19 @@ import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; -import UserListItem from '@components/SelectionList/ListItem/UserListItem'; +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 {filterCardsByPersonalDetails, filterInactiveCards, sortCardsByCardholderName} from '@libs/CardUtils'; +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'; @@ -31,9 +34,10 @@ import type SCREENS from '@src/SCREENS'; import type {Card, ExpensifyCardSettings, WorkspaceCardsList} from '@src/types/onyx'; import type {ExpensifyCardRule, ExpensifyCardRuleFilter} from '@src/types/onyx/ExpensifyCardSettings'; -type ExpensifyCardListItem = ListItem & { - card: Card; -}; +type ExpensifyCardListItem = ListItem & + AdditionalCardProps & { + card: Card; + }; type AddCardPageProps = PlatformStackScreenProps; @@ -80,6 +84,8 @@ function AddCardPage({route}: AddCardPageProps) { 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 illustrations = useMemoizedLazyIllustrations(['Telescope']); + const themeIllustrations = useThemeIllustrations(); + const companyCardFeedIcons = useCompanyCardFeedIcons(); const [selectedCardIDs, setSelectedCardIDs] = useState([]); @@ -92,14 +98,21 @@ function AddCardPage({route}: AddCardPageProps) { const listData: ExpensifyCardListItem[] = filteredCards.map((card) => { const accountID = card.accountID ?? CONST.DEFAULT_NUMBER_ID; - const displayName = getDisplayNameOrDefault(personalDetails?.[accountID], '', false); - const lastFour = card.lastFourPAN ?? ''; + const cardOwnerPersonalDetails = personalDetails?.[accountID] ?? undefined; + const cardName = card.nameValuePairs?.cardTitle; + const displayName = getDisplayNameOrDefault(cardOwnerPersonalDetails, '', false); return { keyForList: String(card.cardID), - text: displayName, - alternateText: lastFour, + text: displayName !== '' ? displayName : (cardName ?? ''), accountID, card, + lastFourPAN: card.lastFourPAN, + isVirtual: !!card.nameValuePairs?.isVirtual, + shouldShowOwnersAvatar: true, + cardOwnerPersonalDetails, + bankIcon: { + icon: getCardFeedIcon(card.bank, themeIllustrations, companyCardFeedIcons), + }, }; }); @@ -174,7 +187,7 @@ function AddCardPage({route}: AddCardPageProps) { onCheckboxPress={toggleCard} onSelectRow={toggleCard} selectedItems={selectedCardIDs} - ListItem={UserListItem} + ListItem={CardListItem} shouldUseDefaultRightHandSideCheckmark={false} shouldUpdateFocusedIndex shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()} From af2bf18724b8682483d06346be9f1ad168cef4ac Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 30 Mar 2026 16:01:57 -0600 Subject: [PATCH 12/31] update select all text style --- src/components/SelectionList/BaseSelectionList.tsx | 1 + src/components/SelectionList/components/ListHeader.tsx | 8 ++++++-- src/components/SelectionList/types.ts | 3 +++ src/pages/workspace/rules/SpendRules/AddCardPage.tsx | 4 ++++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 2280563fa3225..2177789961ef0 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -537,6 +537,7 @@ function BaseSelectionList({ canSelectMultiple={canSelectMultiple} onSelectAll={handleSelectAll} headerStyle={style?.listHeaderWrapperStyle} + selectAllTextStyle={style?.listHeaderSelectAllTextStyle} shouldShowSelectAllButton={!!onSelectAll} shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow} /> diff --git a/src/components/SelectionList/components/ListHeader.tsx b/src/components/SelectionList/components/ListHeader.tsx index 976746329dd97..6f3553e3ebfb0 100644 --- a/src/components/SelectionList/components/ListHeader.tsx +++ b/src/components/SelectionList/components/ListHeader.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import type {StyleProp, ViewStyle} from 'react-native'; +import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import Checkbox from '@components/Checkbox'; import {PressableWithFeedback} from '@components/Pressable'; @@ -22,6 +22,9 @@ type ListHeaderProps = { /** Styles for the list header wrapper */ headerStyle?: StyleProp; + /** Styles for the "Select all" text (merged after textStrong) */ + selectAllTextStyle?: StyleProp; + /** Function called when the select all button is pressed */ onSelectAll: () => void; @@ -38,6 +41,7 @@ function ListHeader({ canSelectMultiple, onSelectAll, headerStyle, + selectAllTextStyle, shouldShowSelectAllButton, shouldPreventDefaultFocusOnSelectRow, }: ListHeaderProps) { @@ -84,7 +88,7 @@ function ListHeader({ dataSet={{[CONST.SELECTION_SCRAPER_HIDDEN_ELEMENT]: true}} onMouseDown={handleMouseDown} > - {translate('workspace.people.selectAll')} + {translate('workspace.people.selectAll')} )} diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 8b6b8eca5300c..82a67f5d218fa 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -204,6 +204,9 @@ type SelectionListStyle = { /** Styles for the list header wrapper */ listHeaderWrapperStyle?: StyleProp; + /** Styles for the default "Select all" label in the list header (merged after textStrong) */ + listHeaderSelectAllTextStyle?: StyleProp; + /** Styles for the title container of the list item */ listItemTitleContainerStyles?: StyleProp; diff --git a/src/pages/workspace/rules/SpendRules/AddCardPage.tsx b/src/pages/workspace/rules/SpendRules/AddCardPage.tsx index e1c1521977f89..736614c3e292b 100644 --- a/src/pages/workspace/rules/SpendRules/AddCardPage.tsx +++ b/src/pages/workspace/rules/SpendRules/AddCardPage.tsx @@ -183,6 +183,10 @@ function AddCardPage({route}: AddCardPageProps) { onChangeText: setInputValue, }} data={listData} + style={{ + listHeaderWrapperStyle: [styles.pt5, styles.pb2], + listHeaderSelectAllTextStyle: [styles.textLabelSupporting], + }} onSelectAll={listData.length > 0 ? toggleSelectAll : undefined} onCheckboxPress={toggleCard} onSelectRow={toggleCard} From ad386c7b4825fd885e83904b43a2b8bd4840fc70 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 30 Mar 2026 16:26:49 -0600 Subject: [PATCH 13/31] save draft form --- src/CONST/index.ts | 6 +++ src/ONYXKEYS.ts | 3 ++ src/languages/de.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/libs/actions/Card.ts | 16 ++++++- src/libs/actions/User.ts | 17 ++++++- .../rules/SpendRules/AddCardPage.tsx | 12 ++++- .../rules/SpendRules/SpendRulePageBase.tsx | 44 ++++++++++++++++--- src/types/form/SpendRuleForm.ts | 17 +++++++ src/types/form/index.ts | 1 + 16 files changed, 114 insertions(+), 10 deletions(-) create mode 100644 src/types/form/SpendRuleForm.ts diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 226db0eb56696..20bbf965ac075 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4195,6 +4195,12 @@ const CONST = { }, }, + SPEND_RULE_FORM: { + FIELDS: { + CARD_IDS: 'cardIDs', + }, + }, + SPEND_CARD_RULE: { ACTION: { ALLOW: 'allow', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 57406bc7bc96e..6f611918ccd66 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1055,6 +1055,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', @@ -1193,6 +1195,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/languages/de.ts b/src/languages/de.ts index 5a568f46ca9be..1c43c9efb17dd 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6683,6 +6683,7 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und cardPageTitle: 'Karte', cardsSectionTitle: 'Karten', chooseCards: 'Karten auswählen', + saveRule: 'Regel speichern', }, }, planTypePage: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 85388ba4c1476..4f9aacc276b5c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6707,6 +6707,7 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip cardPageTitle: 'Carte', cardsSectionTitle: 'Cartes', chooseCards: 'Choisir des cartes', + saveRule: 'Enregistrer la règle', }, }, planTypePage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index d78d88f801ad0..a3fdb593b08d5 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6670,6 +6670,7 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo cardPageTitle: 'Carta', cardsSectionTitle: 'Carte', chooseCards: 'Scegli le carte', + saveRule: 'Salva regola', }, }, planTypePage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 5541471aae7b2..a0e9bf474fbbd 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6596,6 +6596,7 @@ ${reportName} cardPageTitle: 'カード', cardsSectionTitle: 'カード', chooseCards: 'カードを選択', + saveRule: 'ルールを保存', }, }, planTypePage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index c6d2a6a514b74..2b72a135308e7 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6649,6 +6649,7 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar cardPageTitle: 'Kaart', cardsSectionTitle: 'Kaarten', chooseCards: 'Kaarten kiezen', + saveRule: 'Regel opslaan', }, }, planTypePage: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index af9ad88d07cf4..7dbfd09d110b2 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6644,6 +6644,7 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i cardPageTitle: 'Karta', cardsSectionTitle: 'Karty', chooseCards: 'Wybierz karty', + saveRule: 'Zapisz regułę', }, }, planTypePage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 190619afdad6c..3e668407d4beb 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6648,6 +6648,7 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e cardPageTitle: 'Cartão', cardsSectionTitle: 'Cartões', chooseCards: 'Escolher cartões', + saveRule: 'Salvar regra', }, }, planTypePage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 152cdb3c8db0d..57f861b22515b 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6484,6 +6484,7 @@ ${reportName} cardPageTitle: '卡', cardsSectionTitle: '卡', chooseCards: '选择卡', + saveRule: '保存规则', }, }, planTypePage: { diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 67d69e0dcbdc0..4c3155f39ab20 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -11,8 +11,8 @@ import type { ReportVirtualExpensifyCardFraudParams, RequestReplacementExpensifyCardParams, ResolveFraudAlertParams, - SetExpensifyCardRuleParams, RevealExpensifyCardDetailsParams, + SetExpensifyCardRuleParams, SetPersonalCardReimbursableParams, StartIssueNewCardFlowParams, UnassignCardParams, @@ -1560,6 +1560,19 @@ function setExpensifyCardRule(parameters: SetExpensifyCardRuleParams) { API.write(WRITE_COMMANDS.SET_EXPENSIFY_CARD_RULE, parameters); } +function getSpendCardRuleValueJSON(cardIDStrings: string[], action: ValueOf) { + const numericIDs = cardIDStrings.map((id) => Number(id)).filter((id) => Number.isFinite(id)); + return JSON.stringify({ + created: new Date().toISOString(), + filters: { + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + right: numericIDs, + }, + action, + }); +} + /** * Resolves a fraud alert for a given card. * When the user clicks on the whisper it sets the optimistic data to the resolution and calls the API @@ -1662,5 +1675,6 @@ export { setDraftInviteAccountID, resolveFraudAlert, setExpensifyCardRule, + getSpendCardRuleValueJSON, }; export type {ReplacementReason}; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index e161fa3572414..0b1bec9825fbd 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -48,7 +48,7 @@ import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {ExpenseRuleForm, MerchantRuleForm} from '@src/types/form'; +import type {ExpenseRuleForm, MerchantRuleForm, SpendRuleForm} from '@src/types/form'; import type {AppReview, BlockedFromConcierge, CustomStatusDraft, ExpenseRule, Policy, ReportAttributesDerivedValue} from '@src/types/onyx'; import type Login from '@src/types/onyx/Login'; import type {Errors} from '@src/types/onyx/OnyxCommon'; @@ -1877,6 +1877,18 @@ function clearDraftMerchantRule() { Onyx.set(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, null); } +function setDraftSpendRule(ruleData: Partial) { + Onyx.set(ONYXKEYS.FORMS.SPEND_RULE_FORM, ruleData); +} + +function updateDraftSpendRule(ruleData: Partial) { + Onyx.merge(ONYXKEYS.FORMS.SPEND_RULE_FORM, ruleData); +} + +function clearDraftSpendRule() { + Onyx.set(ONYXKEYS.FORMS.SPEND_RULE_FORM, null); +} + export { closeAccount, setServerErrorsOnForm, @@ -1928,6 +1940,9 @@ export { setDraftMerchantRule, updateDraftMerchantRule, clearDraftMerchantRule, + setDraftSpendRule, + updateDraftSpendRule, + clearDraftSpendRule, openTroubleshootSettingsPage, openMultifactorAuthenticationRevokePage, }; diff --git a/src/pages/workspace/rules/SpendRules/AddCardPage.tsx b/src/pages/workspace/rules/SpendRules/AddCardPage.tsx index 736614c3e292b..404aac6a998f7 100644 --- a/src/pages/workspace/rules/SpendRules/AddCardPage.tsx +++ b/src/pages/workspace/rules/SpendRules/AddCardPage.tsx @@ -1,4 +1,5 @@ -import React, {useEffect, useState} from 'react'; +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'; @@ -17,6 +18,7 @@ import useOnyx from '@hooks/useOnyx'; import useSearchResults from '@hooks/useSearchResults'; import useThemeIllustrations from '@hooks/useThemeIllustrations'; import useThemeStyles from '@hooks/useThemeStyles'; +import {updateDraftSpendRule} from '@libs/actions/User'; import {filterCardsByPersonalDetails, filterInactiveCards, getCardFeedIcon, sortCardsByCardholderName} from '@libs/CardUtils'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; @@ -83,12 +85,19 @@ function AddCardPage({route}: AddCardPageProps) { const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${defaultFundID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards}); const [expensifyCardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${defaultFundID}`); + const [spendRuleForm] = useOnyx(ONYXKEYS.FORMS.SPEND_RULE_FORM); const illustrations = useMemoizedLazyIllustrations(['Telescope']); const themeIllustrations = useThemeIllustrations(); const companyCardFeedIcons = useCompanyCardFeedIcons(); const [selectedCardIDs, setSelectedCardIDs] = useState([]); + useFocusEffect( + useCallback(() => { + setSelectedCardIDs(spendRuleForm?.cardIDs ?? []); + }, [spendRuleForm?.cardIDs]), + ); + const eligibleCards = getEligibleCards(cardsList, expensifyCardSettings ?? {}); const filterCard = (card: Card, searchInput: string) => filterCardsByPersonalDetails(card, searchInput, personalDetails); @@ -153,6 +162,7 @@ function AddCardPage({route}: AddCardPageProps) { }; const handleSave = () => { + updateDraftSpendRule({cardIDs: selectedCardIDs}); Navigation.goBack(ROUTES.RULES_SPEND_NEW.getRoute(policyID)); }; diff --git a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx index 6d425d4a18412..151b2fe490fac 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx @@ -1,4 +1,4 @@ -import React, {useCallback} from 'react'; +import React, {useEffect} from 'react'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; @@ -7,13 +7,18 @@ import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; 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 {getSpendCardRuleValueJSON, setExpensifyCardRule} from '@libs/actions/Card'; +import {clearDraftSpendRule} from '@libs/actions/User'; +import {filterInactiveCards, getCardDescriptionForSearchTable, isCard} from '@libs/CardUtils'; 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'; type SpendRulePageBaseProps = { @@ -26,15 +31,40 @@ function SpendRulePageBase({policyID, titleKey, testID}: SpendRulePageBaseProps) const styles = useThemeStyles(); const {translate} = useLocalize(); 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 handleSaveRule = useCallback(() => { + useEffect(() => () => clearDraftSpendRule(), []); + + const cardIDs = spendRuleForm?.cardIDs; + + const cardsMenuTitle = !cardIDs?.length + ? '' + : cardIDs + .map((id) => { + const card = cardsList?.[id]; + if (card === undefined || !isCard(card)) { + return id; + } + const accountID = card.accountID ?? CONST.DEFAULT_NUMBER_ID; + const displayName = getDisplayNameOrDefault(personalDetails?.[accountID], '', false); + return getCardDescriptionForSearchTable(card, displayName || undefined) || id; + }) + .join(', '); + + const handleSaveRule = () => { + if (!cardIDs?.length) { + return; + } setExpensifyCardRule({ domainAccountID, cardRuleID: String(rand64()), - cardRuleValue: '', + cardRuleValue: getSpendCardRuleValueJSON(cardIDs, CONST.SPEND_CARD_RULE.ACTION.BLOCK), }); + clearDraftSpendRule(); Navigation.goBack(); - }, [domainAccountID]); + }; return ( {translate('workspace.rules.spendRules.cardsSectionTitle')} Navigation.navigate(ROUTES.RULES_SPEND_CARD.getRoute(policyID))} shouldShowRightIcon - title={''} + title={cardsMenuTitle} titleStyle={styles.flex1} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM} /> @@ -65,6 +94,7 @@ function SpendRulePageBase({policyID, titleKey, testID}: SpendRulePageBaseProps) containerStyles={[styles.m4, styles.mb5]} isAlertVisible={false} onSubmit={handleSaveRule} + isDisabled={!cardIDs?.length} enabledWhenOffline sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.SPEND_RULE_SAVE} /> diff --git a/src/types/form/SpendRuleForm.ts b/src/types/form/SpendRuleForm.ts new file mode 100644 index 0000000000000..00a0a1fba2295 --- /dev/null +++ b/src/types/form/SpendRuleForm.ts @@ -0,0 +1,17 @@ +import type {ValueOf} from 'type-fest'; +import CONST from '@src/CONST'; +import type Form from './Form'; + +const INPUT_IDS = CONST.SPEND_RULE_FORM.FIELDS; + +type InputID = ValueOf; + +type SpendRuleForm = Form< + InputID, + { + [INPUT_IDS.CARD_IDS]: string[]; + } +>; + +export type {InputID, SpendRuleForm}; +export default INPUT_IDS; diff --git a/src/types/form/index.ts b/src/types/form/index.ts index 6f24c4f910381..e85a2ed018cf2 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -111,6 +111,7 @@ export type {SplitExpenseEditDateForm} from './SplitExpenseEditDateForm'; export type {ResetDomainForm} from './ResetDomainForm'; export type {ExpenseRuleForm} from './ExpenseRuleForm'; export type {MerchantRuleForm} from './MerchantRuleForm'; +export type {SpendRuleForm} from './SpendRuleForm'; export type {AddDomainMemberForm} from './AddDomainMemberForm'; export type {WorkspaceTimeTrackingDefaultRateForm} from './WorkspaceTimeTrackingDefaultRateForm'; export type {EditExpensifyCardLimitTypeForm} from './EditExpensifyCardLimitTypeForm'; From 34754ec3705ffe234728d65b12efb55a6bf43a03 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 30 Mar 2026 16:39:49 -0600 Subject: [PATCH 14/31] add SpendRuleRestrictionTypeToggle --- src/CONST/index.ts | 2 + src/languages/en.ts | 5 ++ src/languages/es.ts | 5 ++ .../rules/SpendRules/SpendRulePageBase.tsx | 14 +++- .../SpendRuleRestrictionTypeToggle.tsx | 84 +++++++++++++++++++ src/types/form/SpendRuleForm.ts | 1 + 6 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 src/pages/workspace/rules/SpendRules/SpendRuleRestrictionTypeToggle.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 20bbf965ac075..9170860d1d30e 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -4198,6 +4198,7 @@ const CONST = { SPEND_RULE_FORM: { FIELDS: { CARD_IDS: 'cardIDs', + RESTRICTION_ACTION: 'restrictionAction', }, }, @@ -9151,6 +9152,7 @@ const CONST = { 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/languages/en.ts b/src/languages/en.ts index 4d3db5bc59157..62332b3dc9d4e 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6669,6 +6669,11 @@ const translations = { 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.', }, }, planTypePage: { diff --git a/src/languages/es.ts b/src/languages/es.ts index dfaeca6ed399b..77c0d73a2924e 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6584,6 +6584,11 @@ ${amount} para ${merchant} - ${date}`, 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.', }, }, }, diff --git a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx index 151b2fe490fac..0d68142976edd 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx @@ -1,4 +1,5 @@ import React, {useEffect} from 'react'; +import {View} from 'react-native'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; @@ -10,7 +11,7 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {getSpendCardRuleValueJSON, setExpensifyCardRule} from '@libs/actions/Card'; -import {clearDraftSpendRule} from '@libs/actions/User'; +import {clearDraftSpendRule, updateDraftSpendRule} from '@libs/actions/User'; import {filterInactiveCards, getCardDescriptionForSearchTable, isCard} from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; import {rand64} from '@libs/NumberUtils'; @@ -20,6 +21,7 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import SpendRuleRestrictionTypeToggle from './SpendRuleRestrictionTypeToggle'; type SpendRulePageBaseProps = { policyID: string; @@ -38,6 +40,7 @@ function SpendRulePageBase({policyID, titleKey, testID}: SpendRulePageBaseProps) useEffect(() => () => clearDraftSpendRule(), []); const cardIDs = spendRuleForm?.cardIDs; + const restrictionAction = spendRuleForm?.restrictionAction ?? CONST.SPEND_CARD_RULE.ACTION.ALLOW; const cardsMenuTitle = !cardIDs?.length ? '' @@ -60,7 +63,7 @@ function SpendRulePageBase({policyID, titleKey, testID}: SpendRulePageBaseProps) setExpensifyCardRule({ domainAccountID, cardRuleID: String(rand64()), - cardRuleValue: getSpendCardRuleValueJSON(cardIDs, CONST.SPEND_CARD_RULE.ACTION.BLOCK), + cardRuleValue: getSpendCardRuleValueJSON(cardIDs, restrictionAction), }); clearDraftSpendRule(); Navigation.goBack(); @@ -88,6 +91,13 @@ function SpendRulePageBase({policyID, titleKey, testID}: SpendRulePageBaseProps) titleStyle={styles.flex1} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.MERCHANT_RULE_SECTION_ITEM} /> + {translate('workspace.rules.spendRules.spendRuleSectionTitle')} + + updateDraftSpendRule({restrictionAction: action})} + /> + ; + onSelect: (action: ValueOf) => void; +}; + +function SpendRuleRestrictionTypeToggle({restrictionAction, onSelect}: SpendRuleRestrictionTypeToggleProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const segmentBaseStyle: StyleProp = [styles.ph3, styles.pv1, styles.alignItemsCenter, styles.justifyContentCenter]; + const segmentTextBase: StyleProp = [styles.textLabel, styles.textNoWrap]; + + const getAllowStyles = (isSelected: boolean): {container: StyleProp; text: StyleProp} => ({ + container: [...segmentBaseStyle, isSelected ? {backgroundColor: theme.success} : undefined, {borderRadius: variables.componentBorderRadiusSmall}], + text: [...segmentTextBase, isSelected ? {color: theme.textReversed} : {color: theme.textSupporting}], + }); + + const getBlockStyles = (isSelected: boolean): {container: StyleProp; text: StyleProp} => ({ + container: [...segmentBaseStyle, isSelected ? {backgroundColor: colors.tangerine400} : undefined, {borderRadius: variables.componentBorderRadiusSmall}], + text: [...segmentTextBase, isSelected ? {color: theme.textReversed} : {color: theme.textSupporting}], + }); + + const allowStyles = getAllowStyles(restrictionAction === CONST.SPEND_CARD_RULE.ACTION.ALLOW); + const blockStyles = getBlockStyles(restrictionAction === CONST.SPEND_CARD_RULE.ACTION.BLOCK); + + const restrictionTypeHelperText = + restrictionAction === CONST.SPEND_CARD_RULE.ACTION.ALLOW + ? translate('workspace.rules.spendRules.restrictionTypeHelpAllow') + : translate('workspace.rules.spendRules.restrictionTypeHelpBlock'); + + return ( + + {translate('workspace.rules.spendRules.restrictionType')} + onSelect(CONST.SPEND_CARD_RULE.ACTION.ALLOW)} + role={CONST.ROLE.BUTTON} + accessibilityLabel={translate('workspace.rules.spendRules.allow')} + style={[styles.flex1, allowStyles.container]} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.SPEND_RULE_RESTRICTION_TYPE} + > + {translate('workspace.rules.spendRules.allow')} + + onSelect(CONST.SPEND_CARD_RULE.ACTION.BLOCK)} + role={CONST.ROLE.BUTTON} + accessibilityLabel={translate('workspace.rules.spendRules.block')} + style={[styles.flex1, blockStyles.container]} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.SPEND_RULE_RESTRICTION_TYPE} + > + {translate('workspace.rules.spendRules.block')} + + {restrictionTypeHelperText} + + ); +} + +SpendRuleRestrictionTypeToggle.displayName = 'SpendRuleRestrictionTypeToggle'; + +export default SpendRuleRestrictionTypeToggle; +export type {SpendRuleRestrictionTypeToggleProps}; diff --git a/src/types/form/SpendRuleForm.ts b/src/types/form/SpendRuleForm.ts index 00a0a1fba2295..b488076f20332 100644 --- a/src/types/form/SpendRuleForm.ts +++ b/src/types/form/SpendRuleForm.ts @@ -10,6 +10,7 @@ type SpendRuleForm = Form< InputID, { [INPUT_IDS.CARD_IDS]: string[]; + [INPUT_IDS.RESTRICTION_ACTION]: ValueOf; } >; From 8a09f139fcc51763270c7480f8ba57af23fa8940 Mon Sep 17 00:00:00 2001 From: Carlos Martins Date: Mon, 30 Mar 2026 17:02:29 -0600 Subject: [PATCH 15/31] update styles --- .../SpendRuleRestrictionTypeToggle.tsx | 89 +++++++------------ 1 file changed, 33 insertions(+), 56 deletions(-) diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleRestrictionTypeToggle.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleRestrictionTypeToggle.tsx index 4e974568c1f5a..08621e19b4162 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRuleRestrictionTypeToggle.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRuleRestrictionTypeToggle.tsx @@ -1,14 +1,10 @@ import React from 'react'; -import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; -import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; +import Button from '@components/Button'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import colors from '@styles/theme/colors'; -import variables from '@styles/variables'; import CONST from '@src/CONST'; type SpendRuleRestrictionTypeToggleProps = { @@ -17,64 +13,45 @@ type SpendRuleRestrictionTypeToggleProps = { }; function SpendRuleRestrictionTypeToggle({restrictionAction, onSelect}: SpendRuleRestrictionTypeToggleProps) { - const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const segmentBaseStyle: StyleProp = [styles.ph3, styles.pv1, styles.alignItemsCenter, styles.justifyContentCenter]; - const segmentTextBase: StyleProp = [styles.textLabel, styles.textNoWrap]; + const isAllowSelected = restrictionAction === CONST.SPEND_CARD_RULE.ACTION.ALLOW; + const isBlockSelected = restrictionAction === CONST.SPEND_CARD_RULE.ACTION.BLOCK; - const getAllowStyles = (isSelected: boolean): {container: StyleProp; text: StyleProp} => ({ - container: [...segmentBaseStyle, isSelected ? {backgroundColor: theme.success} : undefined, {borderRadius: variables.componentBorderRadiusSmall}], - text: [...segmentTextBase, isSelected ? {color: theme.textReversed} : {color: theme.textSupporting}], - }); - - const getBlockStyles = (isSelected: boolean): {container: StyleProp; text: StyleProp} => ({ - container: [...segmentBaseStyle, isSelected ? {backgroundColor: colors.tangerine400} : undefined, {borderRadius: variables.componentBorderRadiusSmall}], - text: [...segmentTextBase, isSelected ? {color: theme.textReversed} : {color: theme.textSupporting}], - }); - - const allowStyles = getAllowStyles(restrictionAction === CONST.SPEND_CARD_RULE.ACTION.ALLOW); - const blockStyles = getBlockStyles(restrictionAction === CONST.SPEND_CARD_RULE.ACTION.BLOCK); - - const restrictionTypeHelperText = - restrictionAction === CONST.SPEND_CARD_RULE.ACTION.ALLOW - ? translate('workspace.rules.spendRules.restrictionTypeHelpAllow') - : translate('workspace.rules.spendRules.restrictionTypeHelpBlock'); + const restrictionTypeHelperText = isAllowSelected ? translate('workspace.rules.spendRules.restrictionTypeHelpAllow') : translate('workspace.rules.spendRules.restrictionTypeHelpBlock'); return ( - - {translate('workspace.rules.spendRules.restrictionType')} - onSelect(CONST.SPEND_CARD_RULE.ACTION.ALLOW)} - role={CONST.ROLE.BUTTON} - accessibilityLabel={translate('workspace.rules.spendRules.allow')} - style={[styles.flex1, allowStyles.container]} - sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.SPEND_RULE_RESTRICTION_TYPE} - > - {translate('workspace.rules.spendRules.allow')} - - onSelect(CONST.SPEND_CARD_RULE.ACTION.BLOCK)} - role={CONST.ROLE.BUTTON} - accessibilityLabel={translate('workspace.rules.spendRules.block')} - style={[styles.flex1, blockStyles.container]} - sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.SPEND_RULE_RESTRICTION_TYPE} - > - {translate('workspace.rules.spendRules.block')} - + <> + + {translate('workspace.rules.spendRules.restrictionType')} + +