diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 412d0f86092d..fb6e068d6d88 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -643,6 +643,10 @@ const ROUTES = { route: 'settings/wallet/card/:cardID?', getRoute: (cardID: string) => `settings/wallet/card/${cardID}` as const, }, + SETTINGS_WALLET_EXPENSIFY_CARD_SPEND_RULES: { + route: 'settings/wallet/expensify-card/spend-rules/:policyID/:ruleID', + getRoute: (policyID: string, ruleID?: string) => `settings/wallet/expensify-card/spend-rules/${policyID}/${ruleID ?? 'new'}` as const, + }, SETTINGS_WALLET_PERSONAL_CARD_DETAILS: { route: 'settings/wallet/personal-card/:cardID', getRoute: (cardID: string | undefined) => { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index ea46d7a16684..064b67d613fc 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -225,6 +225,7 @@ const SCREENS = { PERSONAL_CARD_ADD_NEW: 'Settings_Wallet_PersonalCard_New', PERSONAL_CARD_WARNING: 'Settings_Wallet_PersonalCard_Warning', PERSONAL_CARD_UPGRADE: 'Settings_Wallet_PersonalCard_Upgrade', + EXPENSIFY_CARD_SPEND_RULES: 'Settings_Wallet_ExpensifyCard_SpendRules', }, EXIT_SURVEY: { diff --git a/src/languages/de.ts b/src/languages/de.ts index 556584e269a5..ebe8afd46300 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -2533,6 +2533,8 @@ ${amount} für ${merchant} – ${date}`, frozenByAdminNeedsUnfreezePrefix: 'Diese Karte wurde von ', frozenByAdminNeedsUnfreezeSuffix: ' gesperrt. Bitte kontaktiere einen Admin, um sie zu entsperren.', frozenByAdminNeedsUnfreeze: ({person}: {person: string}) => `Diese Karte wurde von ${person} gesperrt. Bitte kontaktiere einen Admin, um sie zu entsperren.`, + spendRules: 'Ausgaberichtlinien', + editSpendRules: 'Ausgaberegeln bearbeiten', }, workflowsPage: { workflowTitle: 'Ausgaben', @@ -6891,7 +6893,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc currencyMismatchTitle: 'Währungsinkonsistenz', currencyMismatchPrompt: 'Um einen Höchstbetrag festzulegen, wählen Sie Karten aus, die in derselben Währung abgerechnet werden.', reviewSelectedCards: 'Ausgewählte Karten prüfen', - summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} weitere`, + summaryMoreCount: ({summary, count}: {summary: string; count: number}) => (count > 0 ? `${summary}, +${count} weitere` : summary), confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Wenden Sie mindestens eine Ausgabenregel auf eine Karte an', confirmErrorCardRequired: 'Feld „Karte“ ist erforderlich', confirmErrorApplyAtLeastOneSpendRule: 'Wenden Sie mindestens eine Ausgabenregel an', @@ -6926,6 +6928,30 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc editRuleTitle: 'Regel bearbeiten', deleteRule: 'Regel löschen', deleteRuleConfirmation: 'Sind Sie sicher, dass Sie diese Regel löschen möchten?', + summaryMerchants: ({ + merchants, + hiddenCount, + shownCount, + action, + }: { + merchants: string; + hiddenCount: number; + shownCount: number; + action: ValueOf; + }) => + `${action === CONST.SPEND_RULES.ACTION.BLOCK ? 'Blockiert' : 'Erlaubt'} ${shownCount > 1 ? 'Händler' : 'Händler'}: ${merchants}${hiddenCount > 0 ? `, +${hiddenCount} weitere` : ''}`, + summaryCategories: ({ + categories, + hiddenCount, + shownCount, + action, + }: { + categories: string; + hiddenCount: number; + shownCount: number; + action: ValueOf; + }) => + `${action === CONST.SPEND_RULES.ACTION.BLOCK ? 'Blockiert' : 'Erlaubt'} ${shownCount > 1 ? 'Kategorien' : 'Kategorie'}: ${categories}${hiddenCount > 0 ? `, +${hiddenCount} weitere` : ''}`, }, }, planTypePage: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 88729b626d1c..29ccf12d9323 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2528,6 +2528,8 @@ const translations = { getPhysicalCard: 'Get physical card', reportFraud: 'Report virtual card fraud', reportTravelFraud: 'Report travel card fraud', + spendRules: 'Spend rules', + editSpendRules: 'Edit spend rules', reviewTransaction: 'Review transaction', suspiciousBannerTitle: 'Suspicious transaction', suspiciousBannerDescription: 'We noticed suspicious transactions on your card. Tap below to review.', @@ -6901,7 +6903,31 @@ const translations = { currencyMismatchTitle: 'Currency mismatch', currencyMismatchPrompt: 'To set a max amount, select cards that settle in the same currency.', reviewSelectedCards: 'Review selected cards', - summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} more`, + summaryMoreCount: ({summary, count}: {summary: string; count: number}) => (count > 0 ? `${summary}, +${count} more` : summary), + summaryMerchants: ({ + merchants, + hiddenCount, + shownCount, + action, + }: { + merchants: string; + hiddenCount: number; + shownCount: number; + action: ValueOf; + }) => + `${action === CONST.SPEND_RULES.ACTION.BLOCK ? 'Blocked' : 'Allowed'} ${shownCount > 1 ? 'merchants' : 'merchant'}: ${merchants}${hiddenCount > 0 ? `, +${hiddenCount} more` : ''}`, + summaryCategories: ({ + categories, + hiddenCount, + shownCount, + action, + }: { + categories: string; + hiddenCount: number; + shownCount: number; + action: ValueOf; + }) => + `${action === CONST.SPEND_RULES.ACTION.BLOCK ? 'Blocked' : 'Allowed'} ${shownCount > 1 ? 'categories' : 'category'}: ${categories}${hiddenCount > 0 ? `, +${hiddenCount} more` : ''}`, confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Apply at least one spend rule to one card', confirmErrorCardRequired: 'Card is a required field', confirmErrorApplyAtLeastOneSpendRule: 'Apply at least one spend rule', diff --git a/src/languages/es.ts b/src/languages/es.ts index 8dac66c2ee5a..e02971dd5b5a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2343,6 +2343,8 @@ const translations: TranslationDeepObject = { getPhysicalCard: 'Obtener tarjeta física', reportFraud: 'Reportar fraude con la tarjeta virtual', reportTravelFraud: 'Reportar fraude con la tarjeta de viaje', + spendRules: 'Reglas de gasto', + editSpendRules: 'Editar reglas de gasto', reviewTransaction: 'Revisar transacción', suspiciousBannerTitle: 'Transacción sospechosa', suspiciousBannerDescription: 'Hemos detectado una transacción sospechosa en la tarjeta. Haz click abajo para revisarla.', @@ -6775,7 +6777,11 @@ ${amount} para ${merchant} - ${date}`, currencyMismatchTitle: 'Moneda no coincide', currencyMismatchPrompt: 'Para establecer un importe máximo, selecciona tarjetas que se liquiden en la misma moneda.', reviewSelectedCards: 'Revisar tarjetas seleccionadas', - summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} más`, + summaryMoreCount: ({summary, count}) => (count > 0 ? `${summary}, +${count} más` : summary), + summaryMerchants: ({merchants, hiddenCount, shownCount, action}) => + `${action === CONST.SPEND_RULES.ACTION.BLOCK ? 'Bloqueados' : 'Permitidos'} ${shownCount > 1 ? 'comerciantes' : 'comerciante'}: ${merchants}${hiddenCount > 0 ? `, +${hiddenCount} más` : ''}`, + summaryCategories: ({categories, hiddenCount, shownCount, action}) => + `${action === CONST.SPEND_RULES.ACTION.BLOCK ? 'Bloqueados' : 'Permitidos'} ${shownCount > 1 ? 'categorías' : 'categoría'}: ${categories}${hiddenCount > 0 ? `, +${hiddenCount} más` : ''}`, confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Aplica al menos una regla de gasto a una tarjeta', confirmErrorCardRequired: 'La tarjeta es un campo obligatorio', confirmErrorApplyAtLeastOneSpendRule: 'Aplica al menos una regla de gasto', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 3d31221a6015..8f10751804ef 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -2539,6 +2539,8 @@ ${amount} pour ${merchant} - ${date}`, frozenByAdminNeedsUnfreezePrefix: 'Cette carte a été gelée par ', frozenByAdminNeedsUnfreezeSuffix: '. Veuillez contacter un administrateur pour la dégeler.', frozenByAdminNeedsUnfreeze: ({person}: {person: string}) => `Cette carte a été gelée par ${person}. Veuillez contacter un administrateur pour la dégeler.`, + spendRules: 'Règles de dépense', + editSpendRules: 'Modifier les règles de dépenses', }, workflowsPage: { workflowTitle: 'Dépense', @@ -6913,7 +6915,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e currencyMismatchTitle: 'Incompatibilité de devise', currencyMismatchPrompt: 'Pour définir un montant maximal, sélectionnez des cartes qui sont réglées dans la même devise.', reviewSelectedCards: 'Examiner les cartes sélectionnées', - summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} de plus`, + summaryMoreCount: ({summary, count}: {summary: string; count: number}) => (count > 0 ? `${summary}, +${count} de plus` : summary), confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Appliquez au moins une règle de dépense à une carte', confirmErrorCardRequired: 'La carte est un champ obligatoire', confirmErrorApplyAtLeastOneSpendRule: 'Appliquez au moins une règle de dépense', @@ -6948,6 +6950,30 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e editRuleTitle: 'Modifier la règle', deleteRule: 'Supprimer la règle', deleteRuleConfirmation: 'Êtes-vous sûr de vouloir supprimer cette règle ?', + summaryMerchants: ({ + merchants, + hiddenCount, + shownCount, + action, + }: { + merchants: string; + hiddenCount: number; + shownCount: number; + action: ValueOf; + }) => + `${action === CONST.SPEND_RULES.ACTION.BLOCK ? 'Bloqué' : 'Autorisé'} ${shownCount > 1 ? 'commerçants' : 'commerçant'}: ${merchants}${hiddenCount > 0 ? `, +${hiddenCount} de plus` : ''}`, + summaryCategories: ({ + categories, + hiddenCount, + shownCount, + action, + }: { + categories: string; + hiddenCount: number; + shownCount: number; + action: ValueOf; + }) => + `${action === CONST.SPEND_RULES.ACTION.BLOCK ? 'Bloqué' : 'Autorisé'} ${shownCount > 1 ? 'catégories' : 'catégorie'}: ${categories}${hiddenCount > 0 ? `, +${hiddenCount} de plus` : ''}`, }, }, planTypePage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index df21e1b7987c..df1d735bbb73 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -2528,6 +2528,8 @@ ${amount} per ${merchant} - ${date}`, frozenByAdminNeedsUnfreezePrefix: 'Questa carta è stata bloccata da ', frozenByAdminNeedsUnfreezeSuffix: '. Contatta un amministratore per sbloccarla.', frozenByAdminNeedsUnfreeze: ({person}: {person: string}) => `Questa carta è stata bloccata da ${person}. Contatta un amministratore per sbloccarla.`, + spendRules: 'Regole di spesa', + editSpendRules: 'Modifica regole di spesa', }, workflowsPage: { workflowTitle: 'Spesa', @@ -6876,7 +6878,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, currencyMismatchTitle: 'Valuta non corrispondente', currencyMismatchPrompt: 'Per impostare un importo massimo, seleziona carte che si regolano nella stessa valuta.', reviewSelectedCards: 'Controlla le carte selezionate', - summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} altro`, + summaryMoreCount: ({summary, count}: {summary: string; count: number}) => (count > 0 ? `${summary}, +${count} altri` : summary), confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Applica almeno una regola di spesa a una carta', confirmErrorCardRequired: 'Il campo Carta è obbligatorio', confirmErrorApplyAtLeastOneSpendRule: 'Applica almeno una regola di spesa', @@ -6911,6 +6913,30 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, editRuleTitle: 'Modifica regola', deleteRule: 'Elimina regola', deleteRuleConfirmation: 'Sei sicuro di voler eliminare questa regola?', + summaryMerchants: ({ + merchants, + hiddenCount, + shownCount, + action, + }: { + merchants: string; + hiddenCount: number; + shownCount: number; + action: ValueOf; + }) => + `${action === CONST.SPEND_RULES.ACTION.BLOCK ? 'Bloccato' : 'Consentito'} ${shownCount > 1 ? 'esercenti' : 'esercente'}: ${merchants}${hiddenCount > 0 ? `, +${hiddenCount} in più` : ''}`, + summaryCategories: ({ + categories, + hiddenCount, + shownCount, + action, + }: { + categories: string; + hiddenCount: number; + shownCount: number; + action: ValueOf; + }) => + `${action === CONST.SPEND_RULES.ACTION.BLOCK ? 'Bloccato' : 'Consentito'} ${shownCount > 1 ? 'categorie' : 'categoria'}: ${categories}${hiddenCount > 0 ? `, +${hiddenCount} in più` : ''}`, }, }, planTypePage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 116ce791d7d8..126528a137ab 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -2503,6 +2503,8 @@ ${date} の ${merchant} への ${amount}`, frozenByAdminNeedsUnfreezePrefix: 'このカードは', frozenByAdminNeedsUnfreezeSuffix: 'によって一時停止されました。解除するには管理者に連絡してください。', frozenByAdminNeedsUnfreeze: ({person}: {person: string}) => `このカードは${person}によって一時停止されました。解除するには管理者に連絡してください。`, + spendRules: '支出ルール', + editSpendRules: '支出ルールを編集', }, workflowsPage: { workflowTitle: '支出', @@ -6800,7 +6802,7 @@ ${reportName} currencyMismatchTitle: '通貨の不一致', currencyMismatchPrompt: '上限金額を設定するには、同じ通貨で清算されるカードを選択してください。', reviewSelectedCards: '選択したカードを確認', - summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}、ほか +${count} 件`, + summaryMoreCount: ({summary, count}: {summary: string; count: number}) => (count > 0 ? `${summary}、ほか+${count}件` : summary), confirmErrorApplyAtLeastOneSpendRuleToOneCard: '少なくとも1つの支出ルールを1枚のカードに適用してください', confirmErrorCardRequired: 'カードは必須項目です', confirmErrorApplyAtLeastOneSpendRule: '少なくとも 1 つの支出ルールを適用してください', @@ -6835,6 +6837,30 @@ ${reportName} editRuleTitle: 'ルールを編集', deleteRule: 'ルールを削除', deleteRuleConfirmation: 'このルールを削除してもよろしいですか?', + summaryMerchants: ({ + merchants, + hiddenCount, + shownCount, + action, + }: { + merchants: string; + hiddenCount: number; + shownCount: number; + action: ValueOf; + }) => + `${action === CONST.SPEND_RULES.ACTION.BLOCK ? 'ブロック済み' : '許可されています'} ${shownCount > 1 ? '加盟店' : '加盟店'}: ${merchants}${hiddenCount > 0 ? `、ほか +${hiddenCount} 件` : ''}`, + summaryCategories: ({ + categories, + hiddenCount, + shownCount, + action, + }: { + categories: string; + hiddenCount: number; + shownCount: number; + action: ValueOf; + }) => + `${action === CONST.SPEND_RULES.ACTION.BLOCK ? 'ブロック済み' : '許可されています'} ${shownCount > 1 ? 'カテゴリ' : 'カテゴリ'}: ${categories}${hiddenCount > 0 ? `、ほか +${hiddenCount} 件` : ''}`, }, }, planTypePage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 4b2aac222008..d5454a187231 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -2526,6 +2526,8 @@ ${amount} voor ${merchant} - ${date}`, frozenByAdminNeedsUnfreezePrefix: 'Deze kaart is bevroren door ', frozenByAdminNeedsUnfreezeSuffix: '. Neem contact op met een beheerder om deze te deblokkeren.', frozenByAdminNeedsUnfreeze: ({person}: {person: string}) => `Deze kaart is bevroren door ${person}. Neem contact op met een beheerder om deze te deblokkeren.`, + spendRules: 'Bestedingsregels', + editSpendRules: 'Uitgavenregels bewerken', }, workflowsPage: { workflowTitle: 'Uitgaven', @@ -6856,7 +6858,7 @@ Voeg meer bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, currencyMismatchTitle: 'Valutamismatch', currencyMismatchPrompt: 'Om een maximumbedrag in te stellen, selecteer je kaarten die in dezelfde valuta worden vereffend.', reviewSelectedCards: 'Geselecteerde kaarten bekijken', - summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} meer`, + summaryMoreCount: ({summary, count}: {summary: string; count: number}) => (count > 0 ? `${summary}, +${count} meer` : summary), confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Pas minstens één bestedingsregel toe op één kaart', confirmErrorCardRequired: 'Kaart is een verplicht veld', confirmErrorApplyAtLeastOneSpendRule: 'Pas minstens één bestedingsregel toe', @@ -6891,6 +6893,30 @@ Voeg meer bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, editRuleTitle: 'Regel bewerken', deleteRule: 'Regel verwijderen', deleteRuleConfirmation: 'Weet je zeker dat je deze regel wilt verwijderen?', + summaryMerchants: ({ + merchants, + hiddenCount, + shownCount, + action, + }: { + merchants: string; + hiddenCount: number; + shownCount: number; + action: ValueOf; + }) => + `${action === CONST.SPEND_RULES.ACTION.BLOCK ? 'Geblokkeerd' : 'Toegestaan'} ${shownCount > 1 ? 'handelaars' : 'handelaar'}: ${merchants}${hiddenCount > 0 ? `, +${hiddenCount} meer` : ''}`, + summaryCategories: ({ + categories, + hiddenCount, + shownCount, + action, + }: { + categories: string; + hiddenCount: number; + shownCount: number; + action: ValueOf; + }) => + `${action === CONST.SPEND_RULES.ACTION.BLOCK ? 'Geblokkeerd' : 'Toegestaan'} ${shownCount > 1 ? 'categorieën' : 'categorie'}: ${categories}${hiddenCount > 0 ? `, +${hiddenCount} meer` : ''}`, }, }, planTypePage: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index cc063c6f03b4..52eaaeada561 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -2521,6 +2521,8 @@ ${amount} dla ${merchant} - ${date}`, frozenByAdminNeedsUnfreezePrefix: 'Ta karta została zamrożona przez ', frozenByAdminNeedsUnfreezeSuffix: '. Skontaktuj się z administratorem, aby ją odmrozić.', frozenByAdminNeedsUnfreeze: ({person}: {person: string}) => `Ta karta została zamrożona przez ${person}. Skontaktuj się z administratorem, aby ją odmrozić.`, + spendRules: 'Zasady wydatków', + editSpendRules: 'Edytuj zasady wydatków', }, workflowsPage: { workflowTitle: 'Wydatki', @@ -6848,7 +6850,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, currencyMismatchTitle: 'Niezgodność waluty', currencyMismatchPrompt: 'Aby ustawić maksymalną kwotę, wybierz karty rozliczane w tej samej walucie.', reviewSelectedCards: 'Przejrzyj wybrane karty', - summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} więcej`, + summaryMoreCount: ({summary, count}: {summary: string; count: number}) => (count > 0 ? `${summary}, +${count} więcej` : summary), confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Zastosuj co najmniej jedną regułę wydatków do jednej karty', confirmErrorCardRequired: 'Pole „Karta” jest wymagane', confirmErrorApplyAtLeastOneSpendRule: 'Zastosuj co najmniej jedną regułę wydatków', @@ -6883,6 +6885,30 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, editRuleTitle: 'Edytuj regułę', deleteRule: 'Usuń regułę', deleteRuleConfirmation: 'Na pewno chcesz usunąć tę regułę?', + summaryMerchants: ({ + merchants, + hiddenCount, + shownCount, + action, + }: { + merchants: string; + hiddenCount: number; + shownCount: number; + action: ValueOf; + }) => + `${action === CONST.SPEND_RULES.ACTION.BLOCK ? 'Zablokowane' : 'Dozwolone'} ${shownCount > 1 ? 'sprzedawcy' : 'sprzedawca'}: ${merchants}${hiddenCount > 0 ? `, +${hiddenCount} więcej` : ''}`, + summaryCategories: ({ + categories, + hiddenCount, + shownCount, + action, + }: { + categories: string; + hiddenCount: number; + shownCount: number; + action: ValueOf; + }) => + `${action === CONST.SPEND_RULES.ACTION.BLOCK ? 'Zablokowane' : 'Dozwolone'} ${shownCount > 1 ? 'kategorie' : 'kategoria'}: ${categories}${hiddenCount > 0 ? `, +${hiddenCount} więcej` : ''}`, }, }, planTypePage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 0ef4db5f9d8d..f0ff49819e6d 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -2519,6 +2519,8 @@ ${amount} para ${merchant} - ${date}`, frozenByAdminNeedsUnfreezePrefix: 'Este cartão foi bloqueado por ', frozenByAdminNeedsUnfreezeSuffix: '. Entre em contato com um administrador para desbloqueá-lo.', frozenByAdminNeedsUnfreeze: ({person}: {person: string}) => `Este cartão foi bloqueado por ${person}. Entre em contato com um administrador para desbloqueá-lo.`, + spendRules: 'Regras de gasto', + editSpendRules: 'Editar regras de gastos', }, workflowsPage: { workflowTitle: 'Gastos', @@ -6854,7 +6856,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, currencyMismatchTitle: 'Incompatibilidade de moeda', currencyMismatchPrompt: 'Para definir um valor máximo, selecione cartões que liquidem na mesma moeda.', reviewSelectedCards: 'Revisar cartões selecionados', - summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary}, +${count} mais`, + summaryMoreCount: ({summary, count}: {summary: string; count: number}) => (count > 0 ? `${summary}, +${count} mais` : summary), confirmErrorApplyAtLeastOneSpendRuleToOneCard: 'Aplicar pelo menos uma regra de gasto a um cartão', confirmErrorCardRequired: 'O campo Cartão é obrigatório', confirmErrorApplyAtLeastOneSpendRule: 'Aplicar pelo menos uma regra de gasto', @@ -6889,6 +6891,30 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, editRuleTitle: 'Editar regra', deleteRule: 'Excluir regra', deleteRuleConfirmation: 'Tem certeza de que quer excluir esta regra?', + summaryMerchants: ({ + merchants, + hiddenCount, + shownCount, + action, + }: { + merchants: string; + hiddenCount: number; + shownCount: number; + action: ValueOf; + }) => + `${action === CONST.SPEND_RULES.ACTION.BLOCK ? 'Bloqueado' : 'Permitido'} ${shownCount > 1 ? 'comerciantes' : 'estabelecimento'}: ${merchants}${hiddenCount > 0 ? `, +${hiddenCount} mais` : ''}`, + summaryCategories: ({ + categories, + hiddenCount, + shownCount, + action, + }: { + categories: string; + hiddenCount: number; + shownCount: number; + action: ValueOf; + }) => + `${action === CONST.SPEND_RULES.ACTION.BLOCK ? 'Bloqueado' : 'Permitido'} ${shownCount > 1 ? 'categorias' : 'categoria'}: ${categories}${hiddenCount > 0 ? `, +${hiddenCount} mais` : ''}`, }, }, planTypePage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 19fdd96411ea..5d1a4bf9e972 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -2458,6 +2458,8 @@ ${amount},商户:${merchant} - 日期:${date}`, frozenByAdminNeedsUnfreezePrefix: '此卡已被', frozenByAdminNeedsUnfreezeSuffix: '冻结。请联系管理员解冻。', frozenByAdminNeedsUnfreeze: ({person}: {person: string}) => `此卡已被${person}冻结。请联系管理员解冻。`, + spendRules: '支出规则', + editSpendRules: '编辑支出规则', }, workflowsPage: { workflowTitle: '支出', @@ -6682,7 +6684,7 @@ ${reportName} currencyMismatchTitle: '货币不匹配', currencyMismatchPrompt: '若要设置最高金额,请选择以相同货币结算的卡片。', reviewSelectedCards: '检查所选卡片', - summaryMoreCount: ({summary, count}: {summary: string; count: number}) => `${summary},还有 +${count} 个`, + summaryMoreCount: ({summary, count}: {summary: string; count: number}) => (count > 0 ? `${summary},还有 +${count} 项` : summary), confirmErrorApplyAtLeastOneSpendRuleToOneCard: '至少将一条支出规则应用到一张卡上', confirmErrorCardRequired: '“卡”是必填字段', confirmErrorApplyAtLeastOneSpendRule: '至少应用一条支出规则', @@ -6717,6 +6719,30 @@ ${reportName} editRuleTitle: '编辑规则', deleteRule: '删除规则', deleteRuleConfirmation: '确定要删除此规则吗?', + summaryMerchants: ({ + merchants, + hiddenCount, + shownCount, + action, + }: { + merchants: string; + hiddenCount: number; + shownCount: number; + action: ValueOf; + }) => + `${action === CONST.SPEND_RULES.ACTION.BLOCK ? '已屏蔽' : '已允许'} ${shownCount > 1 ? '商户' : '商家'}: ${merchants}${hiddenCount > 0 ? `,还有 +${hiddenCount} 个` : ''}`, + summaryCategories: ({ + categories, + hiddenCount, + shownCount, + action, + }: { + categories: string; + hiddenCount: number; + shownCount: number; + action: ValueOf; + }) => + `${action === CONST.SPEND_RULES.ACTION.BLOCK ? '已屏蔽' : '已允许'} ${shownCount > 1 ? '类别' : '类别'}: ${categories}${hiddenCount > 0 ? `,还有 +${hiddenCount} 个` : ''}`, }, }, planTypePage: { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 3bf930caba0a..c4d817fc6715 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -416,6 +416,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/AppDownloadLinks').default, [SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: () => require('../../../../pages/settings/Profile/PersonalDetails/PersonalAddressPage').default, [SCREENS.SETTINGS.WALLET.DOMAIN_CARD]: () => require('../../../../pages/settings/Wallet/ExpensifyCardPage/index').default, + [SCREENS.SETTINGS.WALLET.EXPENSIFY_CARD_SPEND_RULES]: () => require('../../../../pages/settings/Wallet/WalletExpensifyCardSpendRulesPage').default, [SCREENS.SETTINGS.WALLET.DOMAIN_CARD_CONFIRM_MAGIC_CODE]: () => require('../../../../pages/settings/Wallet/ExpensifyCardPage/ExpensifyCardVerifyAccountPage').default, [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_DETAILS]: () => require('../../../../pages/settings/Wallet/PersonalCardDetailsPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts index f697b3d8ad4c..63fd2b170b6a 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts @@ -32,6 +32,7 @@ const SETTINGS_TO_RHP: Partial['config'] = { path: ROUTES.SETTINGS_WALLET_DOMAIN_CARD.route, exact: true, }, + [SCREENS.SETTINGS.WALLET.EXPENSIFY_CARD_SPEND_RULES]: { + path: ROUTES.SETTINGS_WALLET_EXPENSIFY_CARD_SPEND_RULES.route, + exact: true, + }, [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_DETAILS]: { path: ROUTES.SETTINGS_WALLET_PERSONAL_CARD_DETAILS.route, exact: true, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 79c3e91bf687..856b6c3f1108 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -195,6 +195,10 @@ type SettingsNavigatorParamList = { /** cardID of selected card */ cardID: string; }; + [SCREENS.SETTINGS.WALLET.EXPENSIFY_CARD_SPEND_RULES]: { + policyID: string; + ruleID: string; + }; [SCREENS.WORKSPACE.WORKFLOWS_PAYER]: { policyID: string; }; diff --git a/src/libs/SpendRulesUtils.tsx b/src/libs/SpendRulesUtils.tsx new file mode 100644 index 000000000000..852966e01bd4 --- /dev/null +++ b/src/libs/SpendRulesUtils.tsx @@ -0,0 +1,292 @@ +import type {OnyxCollection} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import type {CurrencyListActionsContextType} from '@components/CurrencyListContextProvider'; +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import CONST from '@src/CONST'; +import type {SpendRuleForm} from '@src/types/form'; +import {isSpendRuleCategory} from '@src/types/form/SpendRuleForm'; +import type {ExpensifyCardSettings} from '@src/types/onyx'; +import type {ExpensifyCardRule, ExpensifyCardRuleFilter} from '@src/types/onyx/ExpensifyCardSettings'; +import {convertToBackendAmount} from './CurrencyUtils'; +import DateUtils from './DateUtils'; + +function isSpendRuleASTNode(value: unknown): value is ExpensifyCardRuleFilter { + return !!value && typeof value === 'object' && 'left' in value && 'operator' in value && 'right' in value; +} + +function combineSpendRuleASTNodes(nodes: ExpensifyCardRuleFilter[], operator: ValueOf): ExpensifyCardRuleFilter | undefined { + const [firstNode, ...remainingNodes] = nodes; + if (!firstNode) { + return undefined; + } + + return remainingNodes.reduce((accumulator, node) => ({left: accumulator, operator, right: node}), firstNode); +} + +function buildSpendRuleAST(spendRuleValues: SpendRuleForm, existingCreated?: string): ExpensifyCardRule | undefined { + const cardIDs = spendRuleValues.cardIDs ?? []; + if (cardIDs.length === 0) { + return undefined; + } + + const merchantNames = (spendRuleValues.merchantNames ?? []).map((merchant) => merchant.trim()).filter((merchant) => merchant !== ''); + const merchantMatchTypes = spendRuleValues.merchantMatchTypes ?? []; + const categories = (spendRuleValues.categories ?? []).map((category) => category.trim()).filter((category) => category !== ''); + const maxAmount = spendRuleValues.maxAmount?.trim() ?? ''; + + const cardNode: ExpensifyCardRuleFilter = { + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + right: cardIDs, + }; + + const exactMerchantNames = merchantNames.filter((_, index) => merchantMatchTypes.at(index) === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); + const containsMerchantNames = merchantNames.filter((_, index) => merchantMatchTypes.at(index) !== CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); + const merchantNodes: ExpensifyCardRuleFilter[] = []; + + if (exactMerchantNames.length > 0) { + merchantNodes.push({ + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + right: exactMerchantNames, + }); + } + + if (containsMerchantNames.length > 0) { + merchantNodes.push({ + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, + operator: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + right: containsMerchantNames, + }); + } + + const merchantNode = combineSpendRuleASTNodes(merchantNodes, CONST.SEARCH.SYNTAX_OPERATORS.OR); + const categoryNode = + categories.length > 0 + ? { + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, + operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + right: categories, + } + : undefined; + + const criteriaNode = combineSpendRuleASTNodes([merchantNode, categoryNode].filter(Boolean) as ExpensifyCardRuleFilter[], CONST.SEARCH.SYNTAX_OPERATORS.OR); + const amountNode = + maxAmount !== '' + ? { + left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, + operator: + spendRuleValues.restrictionAction === CONST.SPEND_RULES.ACTION.BLOCK + ? CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN + : CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO, + right: [maxAmount], + } + : undefined; + + const ruleNode = combineSpendRuleASTNodes( + [amountNode, criteriaNode].filter(Boolean) as ExpensifyCardRuleFilter[], + spendRuleValues.restrictionAction === CONST.SPEND_RULES.ACTION.BLOCK ? CONST.SEARCH.SYNTAX_OPERATORS.OR : CONST.SEARCH.SYNTAX_OPERATORS.AND, + ); + const filters = combineSpendRuleASTNodes([cardNode, ruleNode].filter(Boolean) as ExpensifyCardRuleFilter[], CONST.SEARCH.SYNTAX_OPERATORS.AND); + + if (!filters) { + return undefined; + } + + return { + created: existingCreated ?? DateUtils.getDBTime(), + action: spendRuleValues.restrictionAction ?? CONST.SPEND_RULES.ACTION.ALLOW, + filters, + }; +} + +function getSpendRuleFormValuesFromCardRule(cardRule: ExpensifyCardRule): SpendRuleForm | undefined { + if (!cardRule || typeof cardRule !== 'object' || !('filters' in cardRule) || !('action' in cardRule)) { + return undefined; + } + + if (!isSpendRuleASTNode(cardRule.filters)) { + return undefined; + } + + const formValues: SpendRuleForm = { + cardIDs: [], + restrictionAction: cardRule.action, + merchantNames: [], + merchantMatchTypes: [], + categories: [], + maxAmount: '', + }; + + const traverseFilters = (filterNode: ExpensifyCardRuleFilter) => { + const {left, operator, right} = filterNode; + + if (isSpendRuleASTNode(left)) { + traverseFilters(left); + } + + if (isSpendRuleASTNode(right)) { + traverseFilters(right); + return; + } + + if (typeof left !== 'string' || !Array.isArray(right)) { + return; + } + + if (left === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) { + formValues.cardIDs = right; + return; + } + + if (left === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { + formValues.maxAmount = typeof right === 'string' ? right : (right.at(0) ?? ''); + return; + } + + if (left === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY) { + formValues.categories = right.filter(isSpendRuleCategory); + return; + } + + if (left === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT) { + formValues.merchantNames = [...formValues.merchantNames, ...right]; + formValues.merchantMatchTypes = [...formValues.merchantMatchTypes, ...right.map(() => operator)]; + } + }; + + traverseFilters(cardRule.filters); + + return formValues; +} + +type SpendRuleByCardID = { + ruleID: string; + formValues: SpendRuleForm; +}; + +/** At most one custom spend rule should reference a card; returns rule id + form values, or undefined. */ +function getSpendRuleByCardID(expensifyCardSettingsCollection: OnyxCollection | undefined, cardID: string): SpendRuleByCardID | undefined { + for (const settings of Object.values(expensifyCardSettingsCollection ?? {})) { + const cardRules = settings?.cardRules; + if (!cardRules) { + continue; + } + for (const [ruleID, rule] of Object.entries(cardRules)) { + const formValues = getSpendRuleFormValuesFromCardRule(rule); + if (formValues?.cardIDs?.includes(cardID)) { + return {ruleID, formValues}; + } + } + } + return undefined; +} + +const MAX_SUMMARY_CHARS = 66; + +type MoreCountFormatter = (summary: string, hiddenCount: number, shownCount: number) => string; +type SpendRuleSummaryPart = { + badgeLabel: string; + text: string; + isNeutral?: boolean; +}; + +function getTruncatedSpendRuleSummary(values: string[] | undefined, formatMoreCount: MoreCountFormatter): string { + const normalizedValues = (values ?? []).map((value) => value.trim()).filter((value) => value !== ''); + + if (!normalizedValues.length) { + return ''; + } + + let text = ''; + let shownCount = 0; + + for (const value of normalizedValues) { + const nextText = text ? `${text}, ${value}` : value; + if (nextText.length > MAX_SUMMARY_CHARS) { + continue; + } + text = nextText; + shownCount++; + } + + const hiddenCount = Math.max(normalizedValues.length - shownCount, 0); + return text ? formatMoreCount(text, hiddenCount, shownCount) : ''; +} + +function getSpendRuleSummaryParts( + formValues: SpendRuleForm, + selectedCurrency: string | undefined, + actionLabel: string, + translate: LocalizedTranslate, + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString'], +): SpendRuleSummaryPart[] { + const summaryParts: SpendRuleSummaryPart[] = []; + const merchantNames = getTruncatedSpendRuleSummary(formValues.merchantNames, (summary, count) => translate('workspace.rules.spendRules.summaryMoreCount', {summary, count})); + const categories = getTruncatedSpendRuleSummary( + formValues.categories.map((category) => translate(`workspace.rules.spendRules.categoryOptions.${category}`)), + (summary, count) => translate('workspace.rules.spendRules.summaryMoreCount', {summary, count}), + ); + const maxAmount = formValues.maxAmount.trim(); + + if (merchantNames) { + summaryParts.push({badgeLabel: actionLabel, text: `${translate('workspace.rules.spendRules.merchants')}: ${merchantNames}`}); + } + + if (categories) { + summaryParts.push({badgeLabel: actionLabel, text: `${translate('workspace.rules.spendRules.categories')}: ${categories}`}); + } + + if (maxAmount) { + summaryParts.push({ + badgeLabel: translate('workspace.rules.spendRules.max'), + text: `${translate('iou.amount')}: ${convertToDisplayString(convertToBackendAmount(Number.parseFloat(maxAmount)), selectedCurrency ?? CONST.CURRENCY.USD)}`, + isNeutral: true, + }); + } + + return summaryParts; +} + +function getSpendRuleSummaryText( + formValues: SpendRuleForm, + cardCurrency: string | undefined, + translate: LocalizedTranslate, + convertToDisplayString: CurrencyListActionsContextType['convertToDisplayString'], +) { + const action = formValues.restrictionAction; + const merchantSummary = formValues.merchantNames + ? getTruncatedSpendRuleSummary(formValues.merchantNames, (merchants, hiddenCount, shownCount) => + translate('workspace.rules.spendRules.summaryMerchants', {merchants, hiddenCount, shownCount, action}), + ) + : undefined; + const categoryNames = formValues.categories.map((category) => translate(`workspace.rules.spendRules.categoryOptions.${category}`)); + const categorySummary = + categoryNames.length > 0 + ? getTruncatedSpendRuleSummary(categoryNames, (categories, hiddenCount, shownCount) => + translate('workspace.rules.spendRules.summaryCategories', {categories, hiddenCount, shownCount, action}), + ) + : undefined; + const amountSummary = + formValues.maxAmount.trim() !== '' + ? `${translate('workspace.rules.spendRules.maxAmount')}: ${convertToDisplayString(convertToBackendAmount(Number.parseFloat(formValues.maxAmount)), cardCurrency ?? CONST.CURRENCY.USD)}` + : undefined; + + const summaryArray = []; + if (merchantSummary) { + summaryArray.push(merchantSummary); + } + + if (categorySummary) { + summaryArray.push(categorySummary); + } + + if (amountSummary) { + summaryArray.push(amountSummary); + } + + return summaryArray; +} + +export {buildSpendRuleAST, getSpendRuleByCardID, getSpendRuleFormValuesFromCardRule, getSpendRuleSummaryParts, getSpendRuleSummaryText, getTruncatedSpendRuleSummary}; +export type {SpendRuleByCardID, SpendRuleSummaryPart}; diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 72094dce42b4..34974db0d991 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -28,13 +28,13 @@ import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; import Log from '@libs/Log'; import {isReportOpenOrUnsubmitted} from '@libs/ReportUtils'; +import {buildSpendRuleAST} from '@libs/SpendRulesUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {SpendRuleForm} from '@src/types/form'; -import {isSpendRuleCategory} from '@src/types/form/SpendRuleForm'; import type {Card, CompanyCardFeedWithDomainID, Report, Transaction} from '@src/types/onyx'; import type {CardLimitType, ExpensifyCardDetails, IssueNewCardData, IssueNewCardStep} from '@src/types/onyx/Card'; -import type {ExpensifyCardRule, ExpensifyCardRuleFilter} from '@src/types/onyx/ExpensifyCardSettings'; +import type {ExpensifyCardRule} from '@src/types/onyx/ExpensifyCardSettings'; import type {SelectedTimezone} from '@src/types/onyx/PersonalDetails'; import type {ConnectionName} from '@src/types/onyx/Policy'; import type {SavedCSVColumnLayoutData} from '@src/types/onyx/SavedCSVColumnLayout'; @@ -1558,156 +1558,6 @@ function queueExpensifyCardForBilling(feedCountry: string, domainAccountID: numb API.write(WRITE_COMMANDS.QUEUE_EXPENSIFY_CARD_FOR_BILLING, parameters); } -function isSpendRuleASTNode(value: unknown): value is ExpensifyCardRuleFilter { - return !!value && typeof value === 'object' && 'left' in value && 'operator' in value && 'right' in value; -} - -function combineSpendRuleASTNodes(nodes: ExpensifyCardRuleFilter[], operator: ValueOf): ExpensifyCardRuleFilter | undefined { - const [firstNode, ...remainingNodes] = nodes; - if (!firstNode) { - return undefined; - } - - return remainingNodes.reduce((accumulator, node) => ({left: accumulator, operator, right: node}), firstNode); -} - -function buildSpendRuleAST(spendRuleValues: SpendRuleForm, existingCreated?: string): ExpensifyCardRule | undefined { - const cardIDs = spendRuleValues.cardIDs ?? []; - if (cardIDs.length === 0) { - return undefined; - } - - const merchantNames = (spendRuleValues.merchantNames ?? []).map((merchant) => merchant.trim()).filter((merchant) => merchant !== ''); - const merchantMatchTypes = spendRuleValues.merchantMatchTypes ?? []; - const categories = (spendRuleValues.categories ?? []).map((category) => category.trim()).filter((category) => category !== ''); - const maxAmount = spendRuleValues.maxAmount?.trim() ?? ''; - - const cardNode: ExpensifyCardRuleFilter = { - left: CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID, - operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, - right: cardIDs, - }; - - const exactMerchantNames = merchantNames.filter((_, index) => merchantMatchTypes.at(index) === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); - const containsMerchantNames = merchantNames.filter((_, index) => merchantMatchTypes.at(index) !== CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO); - const merchantNodes: ExpensifyCardRuleFilter[] = []; - - if (exactMerchantNames.length > 0) { - merchantNodes.push({ - left: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, - operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, - right: exactMerchantNames, - }); - } - - if (containsMerchantNames.length > 0) { - merchantNodes.push({ - left: CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT, - operator: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, - right: containsMerchantNames, - }); - } - - const merchantNode = combineSpendRuleASTNodes(merchantNodes, CONST.SEARCH.SYNTAX_OPERATORS.OR); - const categoryNode = - categories.length > 0 - ? { - left: CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY, - operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, - right: categories, - } - : undefined; - - const criteriaNode = combineSpendRuleASTNodes([merchantNode, categoryNode].filter(Boolean) as ExpensifyCardRuleFilter[], CONST.SEARCH.SYNTAX_OPERATORS.OR); - const amountNode = - maxAmount !== '' - ? { - left: CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT, - operator: - spendRuleValues.restrictionAction === CONST.SPEND_RULES.ACTION.BLOCK - ? CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN - : CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO, - right: [maxAmount], - } - : undefined; - - const ruleNode = combineSpendRuleASTNodes( - [amountNode, criteriaNode].filter(Boolean) as ExpensifyCardRuleFilter[], - spendRuleValues.restrictionAction === CONST.SPEND_RULES.ACTION.BLOCK ? CONST.SEARCH.SYNTAX_OPERATORS.OR : CONST.SEARCH.SYNTAX_OPERATORS.AND, - ); - const filters = combineSpendRuleASTNodes([cardNode, ruleNode].filter(Boolean) as ExpensifyCardRuleFilter[], CONST.SEARCH.SYNTAX_OPERATORS.AND); - - if (!filters) { - return undefined; - } - - return { - created: existingCreated ?? DateUtils.getDBTime(), - action: spendRuleValues.restrictionAction ?? CONST.SPEND_RULES.ACTION.ALLOW, - filters, - }; -} - -function getSpendRuleFormValuesFromCardRule(cardRule: ExpensifyCardRule): SpendRuleForm | undefined { - if (!cardRule || typeof cardRule !== 'object' || !('filters' in cardRule) || !('action' in cardRule)) { - return undefined; - } - - if (!isSpendRuleASTNode(cardRule.filters)) { - return undefined; - } - - const formValues: SpendRuleForm = { - cardIDs: [], - restrictionAction: cardRule.action, - merchantNames: [], - merchantMatchTypes: [], - categories: [], - maxAmount: '', - }; - - const traverseFilters = (filterNode: ExpensifyCardRuleFilter) => { - const {left, operator, right} = filterNode; - - if (isSpendRuleASTNode(left)) { - traverseFilters(left); - } - - if (isSpendRuleASTNode(right)) { - traverseFilters(right); - return; - } - - if (typeof left !== 'string' || !Array.isArray(right)) { - return; - } - - if (left === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) { - formValues.cardIDs = right; - return; - } - - if (left === CONST.SEARCH.SYNTAX_FILTER_KEYS.AMOUNT) { - formValues.maxAmount = typeof right === 'string' ? right : (right.at(0) ?? ''); - return; - } - - if (left === CONST.SEARCH.SYNTAX_FILTER_KEYS.CATEGORY) { - formValues.categories = right.filter(isSpendRuleCategory); - return; - } - - if (left === CONST.SEARCH.SYNTAX_FILTER_KEYS.MERCHANT) { - formValues.merchantNames = [...formValues.merchantNames, ...right]; - formValues.merchantMatchTypes = [...formValues.merchantMatchTypes, ...right.map(() => operator)]; - } - }; - - traverseFilters(cardRule.filters); - - return formValues; -} - function setExpensifyCardRule(domainAccountID: number, cardRuleID: string, spendRuleValues: SpendRuleForm, existingRule?: ExpensifyCardRule) { const ruleID = cardRuleID; const ruleAST = buildSpendRuleAST(spendRuleValues, existingRule?.created); @@ -1918,6 +1768,5 @@ export { resolveFraudAlert, deleteExpensifyCardRule, setExpensifyCardRule, - getSpendRuleFormValuesFromCardRule, }; export type {ReplacementReason}; diff --git a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx index 6f6675bde684..dfd7199c7000 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx +++ b/src/pages/settings/Wallet/ExpensifyCardPage/index.tsx @@ -19,6 +19,7 @@ import {useMultifactorAuthentication} from '@components/MultifactorAuthenticatio import {usePersonalDetails, useSession} from '@components/OnyxListItemProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -48,6 +49,7 @@ import type {DomainCardNavigatorParamList, SettingsNavigatorParamList} from '@li import {isPolicyAdmin} from '@libs/PolicyUtils'; import {getPolicyExpenseChat} from '@libs/ReportUtils'; import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; +import {getSpendRuleByCardID, getSpendRuleSummaryText} from '@libs/SpendRulesUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import RedDotCardSection from '@pages/settings/Wallet/RedDotCardSection'; import CardDetails from '@pages/settings/Wallet/WalletPage/CardDetails'; @@ -101,7 +103,7 @@ function ExpensifyCardPage({route}: ExpensifyCardPageProps) { const pageTitle = shouldDisplayCardDomain ? expensifyCardTitle : (cardList?.[cardID]?.nameValuePairs?.cardTitle ?? expensifyCardTitle); const {displayName} = useCurrentUserPersonalDetails(); const personalDetails = usePersonalDetails(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['Flag', 'MoneySearch', 'FreezeCard', 'Key', 'Eye']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Flag', 'MoneySearch', 'FreezeCard', 'Key', 'Eye', 'CreditCardLock']); const cardsToShow = useMemo(() => { if (shouldDisplayCardDomain) { @@ -173,6 +175,35 @@ function ExpensifyCardPage({route}: ExpensifyCardPageProps) { const isWorkspaceAdmin = isPolicyAdmin(policyForCurrentCard, session?.email); const canUnfreezeCard = canManageCardFreeze && (frozenByAccountID === session?.accountID || isWorkspaceAdmin); + const spendRule = useMemo(() => getSpendRuleByCardID(cardSettings ? {privateExpensifyCardSettings: cardSettings} : undefined, cardID), [cardSettings, cardID]); + const spendRulesSummary = useMemo( + () => (spendRule ? getSpendRuleSummaryText(spendRule.formValues, currency, translate, convertToDisplayString) : []), + [currency, spendRule, translate, convertToDisplayString], + ); + + const navigateToSpendRulesPage = useCallback(() => { + if (!policyIDForCurrentCard) { + return; + } + Navigation.navigate(ROUTES.SETTINGS_WALLET_EXPENSIFY_CARD_SPEND_RULES.getRoute(policyIDForCurrentCard, spendRule?.ruleID)); + }, [policyIDForCurrentCard, spendRule?.ruleID]); + + const spendRulesTitleComponent = useMemo( + () => ( + + {spendRulesSummary.map((summary) => ( + + {summary} + + ))} + + ), + [spendRulesSummary], + ); + const scarfOverlayStyle = useMemo( () => ({ top: 0, @@ -498,6 +529,17 @@ function ExpensifyCardPage({route}: ExpensifyCardPageProps) { /> )} + + {isWorkspaceAdmin && spendRulesSummary.length > 0 && ( + + )} + + {isWorkspaceAdmin && ( + + )} {canManageCardFreeze && !isCardFrozen(currentCard) && ( ; + +function WalletExpensifyCardSpendRulesPage({route}: WalletExpensifyCardSpendRulesPageProps) { + const {policyID, ruleID} = route.params; + const isNewRule = ruleID === ROUTES.NEW; + + return ( + + ); +} + +WalletExpensifyCardSpendRulesPage.displayName = 'WalletExpensifyCardSpendRulesPage'; + +export default WalletExpensifyCardSpendRulesPage; diff --git a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardDetailsPage.tsx b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardDetailsPage.tsx index 09b24853bfde..8e9cecbcc80c 100644 --- a/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardDetailsPage.tsx +++ b/src/pages/workspace/expensifyCard/WorkspaceExpensifyCardDetailsPage.tsx @@ -1,3 +1,5 @@ +import type {NavigationProp} from '@react-navigation/native'; +import {useNavigation} from '@react-navigation/native'; import {cardByIdSelector} from '@selectors/Card'; import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; @@ -14,6 +16,7 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; import useCardFeeds from '@hooks/useCardFeeds'; import useCurrencyForExpensifyCard from '@hooks/useCurrencyForExpensifyCard'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; @@ -26,12 +29,14 @@ import useOnyx from '@hooks/useOnyx'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import {openPolicyExpensifyCardsPage} from '@libs/actions/Policy/Policy'; import {getAllCardsForWorkspace, getCardHintText, getTranslationKeyForLimitType, isCardFrozen, maskCard} from '@libs/CardUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {isPolicyAdmin} from '@libs/PolicyUtils'; import {buildCannedSearchQuery} from '@libs/SearchQueryUtils'; +import {getSpendRuleByCardID, getSpendRuleSummaryText} from '@libs/SpendRulesUtils'; import Navigation from '@navigation/Navigation'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; @@ -49,6 +54,7 @@ type WorkspaceExpensifyCardDetailsPageProps = PlatformStackScreenProps< >; function WorkspaceExpensifyCardDetailsPage({route}: WorkspaceExpensifyCardDetailsPageProps) { + const navigation = useNavigation>(); const {policyID, cardID, backTo} = route.params; const {convertToDisplayString} = useCurrencyListActions(); const defaultFundID = useDefaultFundID(policyID); @@ -58,7 +64,7 @@ function WorkspaceExpensifyCardDetailsPage({route}: WorkspaceExpensifyCardDetail const [isFreezeModalVisible, setIsFreezeModalVisible] = useState(false); const [isUnfreezeModalVisible, setIsUnfreezeModalVisible] = useState(false); const {translate} = useLocalize(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['FallbackAvatar', 'FreezeCard', 'Hourglass', 'MoneySearch', 'Trashcan']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['FallbackAvatar', 'FreezeCard', 'Hourglass', 'MoneySearch', 'Trashcan', 'CreditCardLock']); const illustrations = useMemoizedLazyIllustrations(['ExpensifyCardImage']); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to use the correct modal type for the decision modal // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth @@ -72,6 +78,7 @@ function WorkspaceExpensifyCardDetailsPage({route}: WorkspaceExpensifyCardDetail const [cardFromCardList] = useOnyx(ONYXKEYS.CARD_LIST, {selector: cardByIdSelector(cardID)}); const [cardFeeds] = useCardFeeds(policyID); const expensifyCardSettings = useExpensifyCardFeeds(policyID); + const [fundCardSettings] = useOnyx(`${ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS}${defaultFundID}`); const [allFeedsCards, allFeedsCardsResult] = useOnyx(ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST); const workspaceCards = getAllCardsForWorkspace(defaultFundID, allFeedsCards, cardFeeds, expensifyCardSettings); @@ -86,7 +93,6 @@ function WorkspaceExpensifyCardDetailsPage({route}: WorkspaceExpensifyCardDetail const displayName = getDisplayNameOrDefault(cardholder); const translationForLimitType = getTranslationKeyForLimitType(card?.nameValuePairs?.limitType); const isAdmin = isPolicyAdmin(policy, session?.email); - const shouldGoBack = useRef(false); const fetchCardDetails = useCallback(() => { @@ -94,9 +100,25 @@ function WorkspaceExpensifyCardDetailsPage({route}: WorkspaceExpensifyCardDetail }, [cardID]); const {isOffline} = useNetwork({onReconnect: fetchCardDetails}); - useEffect(() => fetchCardDetails(), [fetchCardDetails]); + useEffect(() => { + if (!isAdmin) { + return; + } + if (!defaultFundID || defaultFundID === CONST.DEFAULT_NUMBER_ID) { + return; + } + if (fundCardSettings?.isLoading) { + return; + } + if (fundCardSettings?.hasOnceLoaded) { + return; + } + + openPolicyExpensifyCardsPage(policyID, defaultFundID); + }, [defaultFundID, fundCardSettings?.hasOnceLoaded, fundCardSettings?.isLoading, isAdmin, policyID]); + const deactivateCard = () => { setIsDeactivateModalVisible(false); shouldGoBack.current = true; @@ -130,6 +152,34 @@ function WorkspaceExpensifyCardDetailsPage({route}: WorkspaceExpensifyCardDetail setIsUnfreezeModalVisible(false); }; + const spendRule = getSpendRuleByCardID(expensifyCardSettings, cardID); + const spendRulesSummary = spendRule ? getSpendRuleSummaryText(spendRule.formValues, currency, translate, convertToDisplayString) : []; + + const navigateToSpendRules = () => { + if (!spendRule) { + navigation.navigate(SCREENS.WORKSPACE.RULES_SPEND_NEW, {policyID}); + return; + } + + navigation.navigate(SCREENS.WORKSPACE.RULES_SPEND_EDIT, {policyID, ruleID: spendRule.ruleID}); + }; + + const spendRulesTitleComponent = useMemo( + () => ( + + {spendRulesSummary.map((summary) => ( + + {summary} + + ))} + + ), + [spendRulesSummary], + ); + const canManageCardFreeze = isAdmin && !!card; const scarfOverlayStyle = useMemo( () => ({ @@ -282,6 +332,15 @@ function WorkspaceExpensifyCardDetailsPage({route}: WorkspaceExpensifyCardDetail } /> + {spendRulesSummary.length > 0 && ( + + )} + {isAdmin && ( + + )} {canManageCardFreeze && !isCardFrozen(card) && ( , expensifyCar } function SpendRuleCardPage({route}: SpendRuleCardPageProps) { + const navigation = useNavigation(); const {policyID, ruleID} = route.params; const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); @@ -97,7 +96,7 @@ function SpendRuleCardPage({route}: SpendRuleCardPageProps) { }, [spendRuleForm?.cardIDs]), ); - const parentRoute = getParentRoute(policyID, ruleID); + const goBack = () => navigation.goBack(); const {isOffline} = useNetwork({ onReconnect: () => { @@ -182,7 +181,7 @@ function SpendRuleCardPage({route}: SpendRuleCardPageProps) { } updateDraftSpendRule({cardIDs: validSelectedCardIDs}); - Navigation.goBack(parentRoute); + goBack(); }; const hasEligibleCards = eligibleCards.length > 0; @@ -212,7 +211,7 @@ function SpendRuleCardPage({route}: SpendRuleCardPageProps) { > Navigation.goBack(parentRoute)} + onBackButtonPress={goBack} /> ; - -function SpendRuleCategoryPage({route}: SpendRuleCategoryPageProps) { - const {policyID, ruleID} = route.params; +function SpendRuleCategoryPage() { + const navigation = useNavigation(); const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const illustrations = useMemoizedLazyIllustrations(['Telescope']); - const parentRoute = getParentRoute(policyID, ruleID); + const goBack = () => navigation.goBack(); const [spendRuleForm] = useOnyx(ONYXKEYS.FORMS.SPEND_RULE_FORM); const [selectedCategories, setSelectedCategories] = useState([]); - useFocusEffect( - useCallback(() => { - setSelectedCategories(spendRuleForm?.categories ?? []); - }, [spendRuleForm?.categories]), - ); + useFocusEffect(() => setSelectedCategories(spendRuleForm?.categories ?? [])); const categoryItems: CategoryListItem[] = SPEND_RULE_CATEGORIES.map((category) => ({ keyForList: category, @@ -92,7 +81,7 @@ function SpendRuleCategoryPage({route}: SpendRuleCategoryPageProps) { const handleSave = () => { updateDraftSpendRule({categories: selectedCategories}); - Navigation.goBack(parentRoute); + goBack(); }; return ( @@ -104,7 +93,7 @@ function SpendRuleCategoryPage({route}: SpendRuleCategoryPageProps) { > Navigation.goBack(parentRoute)} + onBackButtonPress={goBack} /> ; function SpendRuleMaxAmountPage({route}: SpendRuleMaxAmountPageProps) { - const {policyID, ruleID} = route.params; + const navigation = useNavigation(); + const {policyID} = route.params; const styles = useThemeStyles(); const {translate} = useLocalize(); const {inputCallbackRef} = useAutoFocusInput(); const domainAccountID = useDefaultFundID(policyID); - const parentRoute = getParentRoute(policyID, ruleID); + const goBack = () => navigation.goBack(); const [spendRuleForm] = useOnyx(ONYXKEYS.FORMS.SPEND_RULE_FORM); const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${domainAccountID}_${CONST.EXPENSIFY_CARD.BANK}`, {selector: filterInactiveCards}); @@ -52,14 +52,14 @@ function SpendRuleMaxAmountPage({route}: SpendRuleMaxAmountPageProps) { > Navigation.goBack(parentRoute)} + onBackButtonPress={goBack} /> { updateDraftSpendRule({maxAmount: maxAmount.trim()}); - Navigation.goBack(parentRoute); + goBack(); }} submitButtonText={translate('common.save')} enabledWhenOffline diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleMerchantEditPage.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleMerchantEditPage.tsx index 666f321dd1f2..873068487011 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRuleMerchantEditPage.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRuleMerchantEditPage.tsx @@ -1,4 +1,5 @@ -import React, {useState} from 'react'; +import {useNavigation} from '@react-navigation/native'; +import React, {useCallback, useState} from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import FormProvider from '@components/Form/FormProvider'; @@ -15,7 +16,6 @@ import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {updateDraftSpendRule} from '@libs/actions/User'; -import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; @@ -32,7 +32,8 @@ type MatchTypeItem = ListItem & { }; function SpendRuleMerchantEditPage({route}: SpendRuleMerchantEditPageProps) { - const {policyID, ruleID, merchantIndex} = route.params; + const navigation = useNavigation(); + const {policyID, merchantIndex} = route.params; const {translate} = useLocalize(); const styles = useThemeStyles(); const {inputCallbackRef} = useAutoFocusInput(); @@ -48,9 +49,7 @@ function SpendRuleMerchantEditPage({route}: SpendRuleMerchantEditPageProps) { const [merchantName, setMerchantName] = useState(existingMerchantName ?? ''); const [matchType, setMatchType] = useState>(existingMerchantMatchType ?? CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS); - const goBack = () => { - Navigation.goBack(ROUTES.RULES_SPEND_MERCHANTS.getRoute(policyID, ruleID)); - }; + const goBack = useCallback(() => navigation.goBack(), [navigation]); const submit = () => { const trimmedMerchantName = merchantName.trim(); diff --git a/src/pages/workspace/rules/SpendRules/SpendRuleMerchantsPage.tsx b/src/pages/workspace/rules/SpendRules/SpendRuleMerchantsPage.tsx index 9bd091310b37..29c6ea502826 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRuleMerchantsPage.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRuleMerchantsPage.tsx @@ -1,3 +1,5 @@ +import type {NavigationProp} from '@react-navigation/native'; +import {useNavigation} from '@react-navigation/native'; import React from 'react'; import BlockingView from '@components/BlockingViews/BlockingView'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; @@ -10,20 +12,19 @@ import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hook import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; -import {getParentRoute} from './SpendRulesUtils'; +import SCREENS from '@src/SCREENS'; type SpendRuleMerchantsPageProps = PlatformStackScreenProps; function SpendRuleMerchantsPage({route}: SpendRuleMerchantsPageProps) { const {policyID, ruleID} = route.params; + const navigation = useNavigation>(); const {translate} = useLocalize(); const styles = useThemeStyles(); const [spendRuleForm] = useOnyx(ONYXKEYS.FORMS.SPEND_RULE_FORM); @@ -42,10 +43,14 @@ function SpendRuleMerchantsPage({route}: SpendRuleMerchantsPageProps) { ? translate('workspace.rules.spendRules.addMerchantToBlockSpend') : translate('workspace.rules.spendRules.addMerchantToAllowSpend'); - const goBack = () => Navigation.goBack(getParentRoute(policyID, ruleID)); + const goBack = () => navigation.goBack(); + + const navigateToMerchantEdit = (merchantIndex: string) => { + navigation.navigate(SCREENS.WORKSPACE.RULES_SPEND_MERCHANT_EDIT, {policyID, ruleID, merchantIndex}); + }; const addMerchant = () => { - Navigation.navigate(ROUTES.RULES_SPEND_MERCHANT_EDIT.getRoute(policyID, ruleID, ROUTES.NEW)); + navigateToMerchantEdit(ROUTES.NEW); }; return ( @@ -83,7 +88,7 @@ function SpendRuleMerchantsPage({route}: SpendRuleMerchantsPageProps) { ? translate('workspace.rules.spendRules.merchantExactlyMatches') : translate('workspace.rules.spendRules.merchantContains') } - onPress={() => Navigation.navigate(ROUTES.RULES_SPEND_MERCHANT_EDIT.getRoute(policyID, ruleID, String(index)))} + onPress={() => navigateToMerchantEdit(String(index))} shouldShowRightIcon title={merchantName} titleStyle={styles.flex1} diff --git a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx index 826a40931ca3..12b51c449ff3 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulePageBase.tsx @@ -1,3 +1,5 @@ +import type {NavigationProp} from '@react-navigation/native'; +import {useNavigation} from '@react-navigation/native'; import React, {useEffect, useState} from 'react'; import {View} from 'react-native'; import Button from '@components/Button'; @@ -14,22 +16,23 @@ import useDefaultFundID from '@hooks/useDefaultFundID'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {deleteExpensifyCardRule, getSpendRuleFormValuesFromCardRule, setExpensifyCardRule} from '@libs/actions/Card'; +import {deleteExpensifyCardRule, setExpensifyCardRule} from '@libs/actions/Card'; import {clearDraftSpendRule, setDraftSpendRule, updateDraftSpendRule} from '@libs/actions/User'; import {filterInactiveCards, getCardDescriptionForSearchTable, getSelectedCardsSharedCurrency} from '@libs/CardUtils'; import {convertToBackendAmount} from '@libs/CurrencyUtils'; -import Navigation from '@libs/Navigation/Navigation'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import {rand64} from '@libs/NumberUtils'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; +import {getSpendRuleFormValuesFromCardRule, getTruncatedSpendRuleSummary} from '@libs/SpendRulesUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; import type {SpendRuleCategory} from '@src/types/form/SpendRuleForm'; import SpendRuleRestrictionTypeToggle from './SpendRuleRestrictionTypeToggle'; -import {getTruncatedSpendRuleSummary} from './SpendRulesUtils'; type SpendRulePageBaseProps = { policyID: string; @@ -52,6 +55,7 @@ function getErrorMessage(hasSelectedCards: boolean, hasAnyRuleApplied: boolean, } function SpendRulePageBase({policyID, ruleID, titleKey, testID}: SpendRulePageBaseProps) { + const navigation = useNavigation>(); const {convertToDisplayString} = useCurrencyListActions(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -105,7 +109,7 @@ function SpendRulePageBase({policyID, ruleID, titleKey, testID}: SpendRulePageBa if (result.action !== ModalActions.CONFIRM) { return; } - Navigation.navigate(ROUTES.RULES_SPEND_CARD.getRoute(policyID, currentRuleID)); + navigation.navigate(SCREENS.WORKSPACE.RULES_SPEND_CARD, {policyID, ruleID: currentRuleID}); }; function getCardsMenuTitle(cardIDsToSummarize: string[] | undefined): string { @@ -158,7 +162,7 @@ function SpendRulePageBase({policyID, ruleID, titleKey, testID}: SpendRulePageBa clearError(); setExpensifyCardRule(domainAccountID, isEditing ? currentRuleID : rand64(), spendRuleForm, existingRule); clearDraftSpendRule(); - Navigation.goBack(); + navigation.goBack(); }; const handleDeleteRule = () => { @@ -179,7 +183,7 @@ function SpendRulePageBase({policyID, ruleID, titleKey, testID}: SpendRulePageBa deleteExpensifyCardRule(domainAccountID, currentRuleID, existingRule); clearDraftSpendRule(); - Navigation.goBack(); + navigation.goBack(); }); }; @@ -205,7 +209,7 @@ function SpendRulePageBase({policyID, ruleID, titleKey, testID}: SpendRulePageBa description={translate('workspace.rules.spendRules.chooseCards')} onPress={() => { clearError(); - Navigation.navigate(ROUTES.RULES_SPEND_CARD.getRoute(policyID, currentRuleID)); + navigation.navigate(SCREENS.WORKSPACE.RULES_SPEND_CARD, {policyID, ruleID: currentRuleID}); }} shouldShowRightIcon title={cardsMenuTitle} @@ -227,7 +231,7 @@ function SpendRulePageBase({policyID, ruleID, titleKey, testID}: SpendRulePageBa description={translate('common.merchant')} onPress={() => { clearError(); - Navigation.navigate(ROUTES.RULES_SPEND_MERCHANTS.getRoute(policyID, currentRuleID)); + navigation.navigate(SCREENS.WORKSPACE.RULES_SPEND_MERCHANTS, {policyID, ruleID: currentRuleID}); }} shouldShowRightIcon title={getMerchantMenuTitle(spendRuleForm?.merchantNames)} @@ -239,7 +243,7 @@ function SpendRulePageBase({policyID, ruleID, titleKey, testID}: SpendRulePageBa description={translate('workspace.rules.spendRules.spendCategory')} onPress={() => { clearError(); - Navigation.navigate(ROUTES.RULES_SPEND_CATEGORY.getRoute(policyID, currentRuleID)); + navigation.navigate(SCREENS.WORKSPACE.RULES_SPEND_CATEGORY, {policyID, ruleID: currentRuleID}); }} shouldShowRightIcon title={categoriesMenuTitle} @@ -255,7 +259,7 @@ function SpendRulePageBase({policyID, ruleID, titleKey, testID}: SpendRulePageBa openCurrencyMismatchModal(); return; } - Navigation.navigate(ROUTES.RULES_SPEND_MAX_AMOUNT.getRoute(policyID, currentRuleID)); + navigation.navigate(SCREENS.WORKSPACE.RULES_SPEND_MAX_AMOUNT, {policyID, ruleID: currentRuleID}); }} shouldShowRightIcon title={maxAmountMenuTitle} diff --git a/src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx b/src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx index 99b17494544e..e589e25be767 100644 --- a/src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx +++ b/src/pages/workspace/rules/SpendRules/SpendRulesSection.tsx @@ -19,64 +19,21 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getSpendRuleFormValuesFromCardRule} from '@libs/actions/Card'; import {openPolicyExpensifyCardsPage} from '@libs/actions/Policy/Policy'; import {filterInactiveCards, getCardDescriptionForSearchTable, getSelectedCardsSharedCurrency} from '@libs/CardUtils'; -import {convertToBackendAmount} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; +import {getSpendRuleFormValuesFromCardRule, getSpendRuleSummaryParts, getTruncatedSpendRuleSummary} from '@libs/SpendRulesUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {SpendRuleForm} from '@src/types/form'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; -import {getTruncatedSpendRuleSummary} from './SpendRulesUtils'; type SpendRulesSectionProps = { policyID: string; }; -type SpendRuleSummaryPart = { - badgeLabel: string; - text: string; - isNeutral?: boolean; -}; - -function getSpendRuleSummaryParts( - formValues: SpendRuleForm, - selectedCurrency: string | undefined, - actionLabel: string, - translate: ReturnType['translate'], - convertToDisplayString: ReturnType['convertToDisplayString'], -): SpendRuleSummaryPart[] { - const summaryParts: SpendRuleSummaryPart[] = []; - const merchantNames = getTruncatedSpendRuleSummary(formValues.merchantNames, (summary, count) => translate('workspace.rules.spendRules.summaryMoreCount', {summary, count})); - const categories = getTruncatedSpendRuleSummary( - formValues.categories.map((category) => translate(`workspace.rules.spendRules.categoryOptions.${category}`)), - (summary, count) => translate('workspace.rules.spendRules.summaryMoreCount', {summary, count}), - ); - const maxAmount = formValues.maxAmount.trim(); - - if (merchantNames) { - summaryParts.push({badgeLabel: actionLabel, text: `${translate('workspace.rules.spendRules.merchants')}: ${merchantNames}`}); - } - - if (categories) { - summaryParts.push({badgeLabel: actionLabel, text: `${translate('workspace.rules.spendRules.categories')}: ${categories}`}); - } - - if (maxAmount) { - summaryParts.push({ - badgeLabel: translate('workspace.rules.spendRules.max'), - text: `${translate('iou.amount')}: ${convertToDisplayString(convertToBackendAmount(Number.parseFloat(maxAmount)), selectedCurrency ?? CONST.CURRENCY.USD)}`, - isNeutral: true, - }); - } - - return summaryParts; -} - function SpendRulesSection({policyID}: SpendRulesSectionProps) { const {convertToDisplayString} = useCurrencyListActions(); const {translate, localeCompare} = useLocalize(); @@ -95,8 +52,18 @@ function SpendRulesSection({policyID}: SpendRulesSectionProps) { const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); useEffect(() => { + if (!defaultFundID || defaultFundID === CONST.DEFAULT_NUMBER_ID) { + return; + } + if (expensifyCardSettings?.isLoading) { + return; + } + if (expensifyCardSettings?.hasOnceLoaded) { + return; + } + openPolicyExpensifyCardsPage(policyID, defaultFundID); - }, [policyID, defaultFundID]); + }, [defaultFundID, expensifyCardSettings?.hasOnceLoaded, expensifyCardSettings?.isLoading, policyID]); const isSpendRulesListLoading = !isOffline && (isLoadingOnyxValue(cardsListResult) || !expensifyCardSettings || expensifyCardSettings.isLoading) && !expensifyCardSettings?.hasOnceLoaded; diff --git a/src/pages/workspace/rules/SpendRules/SpendRulesUtils.ts b/src/pages/workspace/rules/SpendRules/SpendRulesUtils.ts deleted file mode 100644 index c746ed29dd41..000000000000 --- a/src/pages/workspace/rules/SpendRules/SpendRulesUtils.ts +++ /dev/null @@ -1,35 +0,0 @@ -import ROUTES from '@src/ROUTES'; -import type {Route} from '@src/ROUTES'; - -const MAX_SUMMARY_CHARS = 74; - -type MoreCountFormatter = (summary: string, count: number) => string; - -function getTruncatedSpendRuleSummary(values: string[] | undefined, formatMoreCount: MoreCountFormatter): string { - const normalizedValues = (values ?? []).map((value) => value.trim()).filter((value) => value !== ''); - - if (!normalizedValues.length) { - return ''; - } - - let text = ''; - let shownCount = 0; - - for (const value of normalizedValues) { - const nextText = text ? `${text}, ${value}` : value; - if (nextText.length > MAX_SUMMARY_CHARS) { - continue; - } - text = nextText; - shownCount++; - } - - const hiddenCount = Math.max(normalizedValues.length - shownCount, 0); - return text && hiddenCount > 0 ? formatMoreCount(text, hiddenCount) : text; -} - -function getParentRoute(policyID: string, ruleID: string): Route { - return ruleID === ROUTES.NEW ? ROUTES.RULES_SPEND_NEW.getRoute(policyID) : ROUTES.RULES_SPEND_EDIT.getRoute(policyID, ruleID); -} - -export {getTruncatedSpendRuleSummary, getParentRoute};