diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 8e1b003553bb..6d87fcf85523 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3782,6 +3782,7 @@ const CONST = { BILLABLE: 'billable', CATEGORY: 'category', DESCRIPTION: 'comment', + MATCH_TYPE: 'matchType', MERCHANT_TO_MATCH: 'merchantToMatch', MERCHANT: 'merchant', REIMBURSABLE: 'reimbursable', @@ -7138,6 +7139,7 @@ const CONST = { AND: 'and', OR: 'or', EQUAL_TO: 'eq', + CONTAINS: 'contains', NOT_EQUAL_TO: 'neq', GREATER_THAN: 'gt', GREATER_THAN_OR_EQUAL_TO: 'gte', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 1d46e291aac4..55a066f38705 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2680,6 +2680,10 @@ const ROUTES = { 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, }, + RULES_MERCHANT_MATCH_TYPE: { + route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/merchant-to-match/type', + getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/merchant-to-match/type` as const, + }, RULES_MERCHANT_MERCHANT: { route: 'workspaces/:policyID/rules/merchant-rules/:ruleID/merchant', getRoute: (policyID: string, ruleID?: string) => `workspaces/${policyID}/rules/merchant-rules/${ruleID ?? 'new'}/merchant` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 2475a3b0238c..1d0fd82f5344 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -761,6 +761,7 @@ const SCREENS = { RULES_PROHIBITED_DEFAULT: 'Rules_Prohibited_Default', RULES_MERCHANT_NEW: 'Rules_Merchant_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', RULES_MERCHANT_CATEGORY: 'Rules_Merchant_Category', RULES_MERCHANT_TAG: 'Rules_Merchant_Tag', diff --git a/src/languages/de.ts b/src/languages/de.ts index 561f07e87617..eabba2b966c8 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -6382,7 +6382,7 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard title: 'Händler', subtitle: 'Legen Sie Händlerregeln fest, damit Ausgaben korrekt codiert ankommen und weniger Nachbearbeitung erfordern.', addRule: 'Händlerregel hinzufügen', - ruleSummaryTitle: (merchantName: string) => `Wenn Händler „${merchantName}“ enthält`, + ruleSummaryTitle: (merchantName: string, isExactMatch: boolean) => `Wenn Händler ${isExactMatch ? 'stimmt genau überein' : 'enthält'} „${merchantName}“`, ruleSummarySubtitleMerchant: (merchantName: string) => `Händler in „${merchantName}“ umbenennen`, ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Aktualisiere ${fieldName} zu „${fieldValue}“`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Als "${reimbursable ? 'erstattungsfähig' : 'nicht erstattungsfähig'}" markieren`, @@ -6390,7 +6390,6 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard addRuleTitle: 'Regel hinzufügen', expensesWith: 'Für Ausgaben mit:', applyUpdates: 'Diese Updates anwenden:', - merchantHint: 'Einem Händlernamen mit groß-/kleinschreibungsunabhängiger „Enthält“-Übereinstimmung zuordnen', saveRule: 'Regel speichern', confirmError: 'Geben Sie den Händler ein und nehmen Sie mindestens eine Änderung vor', confirmErrorMerchant: 'Bitte geben Sie den Händler ein', @@ -6398,6 +6397,10 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard editRuleTitle: 'Regel bearbeiten', deleteRule: 'Regel löschen', deleteRuleConfirmation: 'Sind Sie sicher, dass Sie diese Regel löschen möchten?', + matchType: 'Abgleichstyp', + matchTypeContains: 'Enthält', + matchTypeExact: 'Exakte Übereinstimmung', + expensesExactlyMatching: 'Für Ausgaben mit genau folgender Übereinstimmung:', }, }, planTypePage: { @@ -7000,7 +7003,7 @@ Fordere Spesendetails wie Belege und Beschreibungen an, lege Limits und Standard [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'Auszahlungs-ID', [CONST.SEARCH.GROUP_BY.CATEGORY]: 'Kategorie', [CONST.SEARCH.GROUP_BY.MERCHANT]: 'Händler', - [CONST.SEARCH.GROUP_BY.TAG]: 'Stichwort', + [CONST.SEARCH.GROUP_BY.TAG]: 'Tag', [CONST.SEARCH.GROUP_BY.MONTH]: 'Monat', }, feed: 'Feed', diff --git a/src/languages/en.ts b/src/languages/en.ts index 1f161ba21494..614ea2671298 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6242,19 +6242,22 @@ const translations = { addRuleTitle: 'Add rule', editRuleTitle: 'Edit rule', expensesWith: 'For expenses with:', + expensesExactlyMatching: 'For expenses exactly matching:', applyUpdates: 'Apply these updates:', - merchantHint: 'Match a merchant name with case-insensitive "contains" matching', saveRule: 'Save rule', confirmError: 'Enter merchant and apply at least one update', confirmErrorMerchant: 'Please enter merchant', confirmErrorUpdate: 'Please apply at least one update', deleteRule: 'Delete rule', deleteRuleConfirmation: 'Are you sure you want to delete this rule?', - ruleSummaryTitle: (merchantName: string) => `If merchant contains "${merchantName}"`, + ruleSummaryTitle: (merchantName: string, isExactMatch: boolean) => `If merchant ${isExactMatch ? 'exactly matches' : 'contains'} "${merchantName}"`, ruleSummarySubtitleMerchant: (merchantName: string) => `Rename merchant to "${merchantName}"`, ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Update ${fieldName} to "${fieldValue}"`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Mark as "${reimbursable ? 'reimbursable' : 'non-reimbursable'}"`, ruleSummarySubtitleBillable: (billable: boolean) => `Mark as "${billable ? 'billable' : 'non-billable'}"`, + matchType: 'Match type', + matchTypeContains: 'Contains', + matchTypeExact: 'Exactly matches', }, categoryRules: { title: 'Category rules', diff --git a/src/languages/es.ts b/src/languages/es.ts index 6d16174837f3..14439b936205 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6020,19 +6020,22 @@ ${amount} para ${merchant} - ${date}`, addRuleTitle: 'Añadir regla', editRuleTitle: 'Editar regla', expensesWith: 'Para gastos con:', + expensesExactlyMatching: 'Para gastos que coincidan exactamente con:', applyUpdates: 'Aplicar estas actualizaciones:', - merchantHint: 'Coincide con un nombre de comerciante con coincidencia "contiene" sin distinción de mayúsculas y minúsculas', saveRule: 'Guardar regla', confirmError: 'Ingresa comerciante y aplica al menos una actualización', confirmErrorMerchant: 'Por favor ingresa comerciante', confirmErrorUpdate: 'Por favor aplica al menos una actualización', deleteRule: 'Eliminar regla', deleteRuleConfirmation: '¿Estás seguro de que quieres eliminar esta regla?', - ruleSummaryTitle: (merchantName: string) => `Si el comerciante contiene "${merchantName}"`, + ruleSummaryTitle: (merchantName: string, isExactMatch: boolean) => `Si el comerciante ${isExactMatch ? 'coincide exactamente con' : 'contiene'} "${merchantName}"`, ruleSummarySubtitleMerchant: (merchantName: string) => `Renombrar comerciante a "${merchantName}"`, ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Actualizar ${fieldName} a "${fieldValue}"`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Marcar como "${reimbursable ? 'reembolsable' : 'no reembolsable'}"`, ruleSummarySubtitleBillable: (billable: boolean) => `Marcar como "${billable ? 'facturable' : 'no facturable'}"`, + matchType: 'Tipo de coincidencia', + matchTypeContains: 'Contiene', + matchTypeExact: 'Coincide exactamente', }, categoryRules: { title: 'Reglas de categoría', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 055f0aeac441..b7c6e7534a9d 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6393,7 +6393,7 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin title: 'Commerçant', subtitle: 'Définissez les règles de marchand afin que les dépenses arrivent correctement codées et nécessitent moins de nettoyage.', addRule: 'Ajouter une règle de commerçant', - ruleSummaryTitle: (merchantName: string) => `Si le commerçant contient « ${merchantName} »`, + ruleSummaryTitle: (merchantName: string, isExactMatch: boolean) => `Si le commerçant ${isExactMatch ? 'correspond exactement' : 'contient'} « ${merchantName} »`, ruleSummarySubtitleMerchant: (merchantName: string) => `Renommer le marchand en « ${merchantName} »`, ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Mettre à jour ${fieldName} sur « ${fieldValue} »`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Marquer comme « ${reimbursable ? 'remboursable' : 'non remboursable'} »`, @@ -6401,7 +6401,6 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin addRuleTitle: 'Ajouter une règle', expensesWith: 'Pour les dépenses avec :', applyUpdates: 'Appliquer ces mises à jour :', - merchantHint: 'Faire correspondre un nom de commerçant avec une correspondance « contient » insensible à la casse', saveRule: 'Enregistrer la règle', confirmError: 'Saisissez un marchand et appliquez au moins une mise à jour', confirmErrorMerchant: 'Veuillez saisir le commerçant', @@ -6409,6 +6408,10 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin editRuleTitle: 'Modifier la règle', deleteRule: 'Supprimer la règle', deleteRuleConfirmation: 'Voulez-vous vraiment supprimer cette règle ?', + matchType: 'Type de correspondance', + matchTypeContains: 'Contient', + matchTypeExact: 'Correspond exactement', + expensesExactlyMatching: 'Pour les dépenses correspondant exactement :', }, }, planTypePage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 16547da5e7c8..3100a3eadfb9 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6364,7 +6364,7 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori title: 'Esercente', subtitle: 'Imposta le regole per gli esercenti in modo che le spese arrivino già codificate correttamente e richiedano meno correzioni.', addRule: 'Aggiungi regola esercente', - ruleSummaryTitle: (merchantName: string) => `Se l’esercente contiene "${merchantName}"`, + ruleSummaryTitle: (merchantName: string, isExactMatch: boolean) => `Se l'esercente ${isExactMatch ? 'corrisponde esattamente' : 'contiene'} "${merchantName}"`, ruleSummarySubtitleMerchant: (merchantName: string) => `Rinomina esercente in "${merchantName}"`, ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Aggiorna ${fieldName} a "${fieldValue}"`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Segna come "${reimbursable ? 'rimborsabile' : 'non rimborsabile'}"`, @@ -6372,7 +6372,6 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori addRuleTitle: 'Aggiungi regola', expensesWith: 'Per spese con:', applyUpdates: 'Applica questi aggiornamenti:', - merchantHint: 'Abbina un nome commerciante con una corrispondenza "contiene" che non distingue tra maiuscole e minuscole', saveRule: 'Salva regola', confirmError: 'Inserisci l’esercente e applica almeno un aggiornamento', confirmErrorMerchant: 'Per favore inserisci l’esercente', @@ -6380,6 +6379,10 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori editRuleTitle: 'Modifica regola', deleteRule: 'Elimina regola', deleteRuleConfirmation: 'Sei sicuro di voler eliminare questa regola?', + matchType: 'Tipo di corrispondenza', + matchTypeContains: 'Contiene', + matchTypeExact: 'Corrisponde esattamente', + expensesExactlyMatching: 'Per le spese che corrispondono esattamente:', }, }, planTypePage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 0df97f64098c..b36cda31a929 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6320,7 +6320,7 @@ ${reportName} title: '加盟店', subtitle: '取引先ルールを設定して、経費が正しくコード化された状態で届くようにし、後処理を最小限に抑えましょう。', addRule: '店舗ルールを追加', - ruleSummaryTitle: (merchantName: string) => `もし取引先に「${merchantName}」が含まれている場合`, + ruleSummaryTitle: (merchantName: string, isExactMatch: boolean) => `もし加盟店 ${isExactMatch ? '完全一致' : '含む'} 「${merchantName}」`, ruleSummarySubtitleMerchant: (merchantName: string) => `支払先名を「${merchantName}」に変更`, ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `${fieldName} を「${fieldValue}」に更新`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `「${reimbursable ? '払い戻し対象' : '精算対象外'}」としてマーク`, @@ -6328,7 +6328,6 @@ ${reportName} addRuleTitle: 'ルールを追加', expensesWith: '次の条件の経費について:', applyUpdates: 'これらの更新を適用:', - merchantHint: '大文字小文字を区別しない「含む」一致で支払先名を照合する', saveRule: 'ルールを保存', confirmError: '支払先を入力し、少なくとも 1 つの更新を適用してください', confirmErrorMerchant: '商人を入力してください', @@ -6336,6 +6335,10 @@ ${reportName} editRuleTitle: 'ルールを編集', deleteRule: 'ルールを削除', deleteRuleConfirmation: 'このルールを削除してもよろしいですか?', + matchType: '一致タイプ', + matchTypeContains: '含む', + matchTypeExact: '完全一致', + expensesExactlyMatching: '以下と完全一致する経費について:', }, }, planTypePage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 38b746fe7ab6..9badcb5f15ee 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6352,7 +6352,7 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten title: 'Handelaar', subtitle: 'Stel de merchantregels zo in dat onkosten met de juiste codering binnenkomen en er minder nabewerking nodig is.', addRule: 'Merchantregel toevoegen', - ruleSummaryTitle: (merchantName: string) => `Als handelaar "${merchantName}" bevat`, + ruleSummaryTitle: (merchantName: string, isExactMatch: boolean) => `Als handelaar ${isExactMatch ? 'komt exact overeen' : 'bevat'} "${merchantName}"`, ruleSummarySubtitleMerchant: (merchantName: string) => `Naam handelaar wijzigen in "${merchantName}"`, ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Werk ${fieldName} bij naar "${fieldValue}"`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Markeren als "${reimbursable ? 'Vergoedbaar' : 'niet-vergoedbaar'}"`, @@ -6360,7 +6360,6 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten addRuleTitle: 'Regel toevoegen', expensesWith: 'Voor uitgaven met:', applyUpdates: 'Deze updates toepassen:', - merchantHint: 'Een handelsnaam koppelen met hoofdletterongevoelige "bevat"-overeenkomst', saveRule: 'Regel opslaan', confirmError: 'Voer een leverancier in en pas ten minste één wijziging toe', confirmErrorMerchant: 'Voer handelaar in', @@ -6368,6 +6367,10 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten editRuleTitle: 'Regel bewerken', deleteRule: 'Regel verwijderen', deleteRuleConfirmation: 'Weet je zeker dat je deze regel wilt verwijderen?', + matchType: 'Type overeenkomen', + matchTypeContains: 'Bevat', + matchTypeExact: 'Komt exact overeen', + expensesExactlyMatching: 'Voor uitgaven die exact overeenkomen met:', }, }, planTypePage: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 5737ca23ed73..6c1f64e9da16 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6345,7 +6345,7 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i title: 'Sprzedawca', subtitle: 'Skonfiguruj reguły dla sprzedawców, aby wydatki trafiały z poprawnym kodowaniem i wymagały mniej poprawek.', addRule: 'Dodaj regułę sprzedawcy', - ruleSummaryTitle: (merchantName: string) => `Jeśli sprzedawca zawiera „${merchantName}”`, + ruleSummaryTitle: (merchantName: string, isExactMatch: boolean) => `Jeśli sprzedawca ${isExactMatch ? 'dokładnie pasuje' : 'Zawiera'} „${merchantName}”`, ruleSummarySubtitleMerchant: (merchantName: string) => `Zmień sprzedawcę na „${merchantName}”`, ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Zaktualizuj ${fieldName} na „${fieldValue}”`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Oznacz jako "${reimbursable ? 'kwalifikujący się do zwrotu kosztów' : 'niepodlegający zwrotowi'}"`, @@ -6353,7 +6353,6 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i addRuleTitle: 'Dodaj regułę', expensesWith: 'Dla wydatków z:', applyUpdates: 'Zastosuj te aktualizacje:', - merchantHint: 'Dopasuj nazwę sprzedawcy przy użyciu nieczułego na wielkość liter dopasowania typu „zawiera”', saveRule: 'Zapisz regułę', confirmError: 'Wprowadź sprzedawcę i zastosuj co najmniej jedną aktualizację', confirmErrorMerchant: 'Wprowadź sprzedawcę', @@ -6361,6 +6360,10 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i editRuleTitle: 'Edytuj regułę', deleteRule: 'Usuń regułę', deleteRuleConfirmation: 'Czy na pewno chcesz usunąć tę regułę?', + matchType: 'Typ dopasowania', + matchTypeContains: 'Zawiera', + matchTypeExact: 'Dokładne dopasowanie', + expensesExactlyMatching: 'Dla wydatków dokładnie pasujących:', }, }, planTypePage: { @@ -6961,7 +6964,7 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'ID wypłaty', [CONST.SEARCH.GROUP_BY.CATEGORY]: 'Kategoria', [CONST.SEARCH.GROUP_BY.MERCHANT]: 'Sprzedawca', - [CONST.SEARCH.GROUP_BY.TAG]: 'Etykieta', + [CONST.SEARCH.GROUP_BY.TAG]: 'Tag', [CONST.SEARCH.GROUP_BY.MONTH]: 'Miesiąc', }, feed: 'Kanał', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 52b530607f5e..415a160d19d9 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6345,7 +6345,7 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe title: 'Estabelecimento', subtitle: 'Defina as regras de comerciante para que as despesas cheguem corretamente categorizadas e exijam menos retrabalho.', addRule: 'Adicionar regra de estabelecimento', - ruleSummaryTitle: (merchantName: string) => `Se o comerciante contiver "${merchantName}"`, + ruleSummaryTitle: (merchantName: string, isExactMatch: boolean) => `Se o comerciante ${isExactMatch ? 'corresponde exatamente' : 'contém'} "${merchantName}"`, ruleSummarySubtitleMerchant: (merchantName: string) => `Renomear comerciante para "${merchantName}"`, ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Atualizar ${fieldName} para "${fieldValue}"`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Marcar como "${reimbursable ? 'reembolsável' : 'não reembolsável'}"`, @@ -6353,7 +6353,6 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe addRuleTitle: 'Adicionar regra', expensesWith: 'Para despesas com:', applyUpdates: 'Aplicar estas atualizações:', - merchantHint: 'Corresponder um nome de comerciante com correspondência "contém" sem diferenciação entre maiúsculas e minúsculas', saveRule: 'Salvar regra', confirmError: 'Insira o estabelecimento e aplique pelo menos uma atualização', confirmErrorMerchant: 'Insira o comerciante', @@ -6361,6 +6360,10 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe editRuleTitle: 'Editar regra', deleteRule: 'Excluir regra', deleteRuleConfirmation: 'Tem certeza de que deseja excluir esta regra?', + matchType: 'Tipo de correspondência', + matchTypeContains: 'Contém', + matchTypeExact: 'Corresponde exatamente', + expensesExactlyMatching: 'Para despesas que correspondem exatamente:', }, }, planTypePage: { @@ -6959,7 +6962,7 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe groupBy: { [CONST.SEARCH.GROUP_BY.FROM]: 'De', [CONST.SEARCH.GROUP_BY.CARD]: 'Cartão', - [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'ID da retirada', + [CONST.SEARCH.GROUP_BY.WITHDRAWAL_ID]: 'ID de saque', [CONST.SEARCH.GROUP_BY.CATEGORY]: 'Categoria', [CONST.SEARCH.GROUP_BY.MERCHANT]: 'Estabelecimento', [CONST.SEARCH.GROUP_BY.TAG]: 'Etiqueta', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 8acb459dffd1..687d564aca16 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6210,7 +6210,7 @@ ${reportName} title: '商家', subtitle: '设置商家规则,让报销费用自动按正确科目归类,减少后期清理工作。', addRule: '添加商家规则', - ruleSummaryTitle: (merchantName: string) => `如果商户包含“${merchantName}”`, + ruleSummaryTitle: (merchantName: string, isExactMatch: boolean) => `如果商家 ${isExactMatch ? '完全匹配' : '包含'}「${merchantName}」`, ruleSummarySubtitleMerchant: (merchantName: string) => `将商家重命名为 “${merchantName}”`, ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `将 ${fieldName} 更新为“${fieldValue}”`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `标记为“${reimbursable ? '可报销' : '不予报销'}”`, @@ -6218,7 +6218,6 @@ ${reportName} addRuleTitle: '添加规则', expensesWith: '对于以下费用:', applyUpdates: '应用这些更新:', - merchantHint: '使用不区分大小写的“包含”匹配来匹配商户名称', saveRule: '保存规则', confirmError: '输入商家并至少应用一项更新', confirmErrorMerchant: '请输入商家', @@ -6226,6 +6225,10 @@ ${reportName} editRuleTitle: '编辑规则', deleteRule: '删除规则', deleteRuleConfirmation: '您确定要删除此规则吗?', + matchType: '匹配类型', + matchTypeContains: '包含', + matchTypeExact: '完全匹配', + expensesExactlyMatching: '对于完全匹配以下条件的报销:', }, }, planTypePage: { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 46d2c5ea9c67..6d7854bf8088 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -824,6 +824,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_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, [SCREENS.WORKSPACE.RULES_MERCHANT_CATEGORY]: () => require('../../../../pages/workspace/rules/MerchantRules/AddCategoryPage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_TAG]: () => require('../../../../pages/workspace/rules/MerchantRules/AddTagPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 3f211f62349b..872118eb179b 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -283,6 +283,7 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH]: { path: ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.route, }, + [SCREENS.WORKSPACE.RULES_MERCHANT_MATCH_TYPE]: { + path: ROUTES.RULES_MERCHANT_MATCH_TYPE.route, + }, [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT]: { path: ROUTES.RULES_MERCHANT_MERCHANT.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index e4c462fb8d2a..c94193d3a749 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1401,6 +1401,10 @@ type SettingsNavigatorParamList = { policyID: string; ruleID: string; }; + [SCREENS.WORKSPACE.RULES_MERCHANT_MATCH_TYPE]: { + policyID: string; + ruleID: string; + }; [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT]: { policyID: string; ruleID: string; diff --git a/src/libs/SearchQueryUtils.ts b/src/libs/SearchQueryUtils.ts index 0392115d6aad..7b41d3a21783 100644 --- a/src/libs/SearchQueryUtils.ts +++ b/src/libs/SearchQueryUtils.ts @@ -55,6 +55,7 @@ type FilterKeys = keyof typeof CONST.SEARCH.SYNTAX_FILTER_KEYS; // This map contains chars that match each operator const operatorToCharMap = { [CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO]: ':' as const, + [CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS]: '*:' as const, [CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN]: '<' as const, [CONST.SEARCH.SYNTAX_OPERATORS.LOWER_THAN_OR_EQUAL_TO]: '<=' as const, [CONST.SEARCH.SYNTAX_OPERATORS.GREATER_THAN]: '>' as const, diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index c294b24f6407..cf7356dce8fb 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -36,20 +36,54 @@ function buildTaxObject(taxKey: string | undefined, policy: Policy | undefined): } /** - * Maps form fields to rule properties + * Maps form fields to rule properties with null for empty values. + * Used for Onyx to properly remove cleared fields during merge. */ -function mapFormFieldsToRule(form: MerchantRuleForm, policy: Policy | undefined) { +function mapFormFieldsToRuleForOnyx(form: MerchantRuleForm, policy: Policy | undefined) { return { - merchant: form.merchant || undefined, - category: form.category || undefined, - tag: form.tag || undefined, - tax: buildTaxObject(form.tax, policy), - comment: form.comment || undefined, - reimbursable: form.reimbursable, - billable: form.billable, + merchant: form.merchant || null, + category: form.category || null, + tag: form.tag || null, + tax: buildTaxObject(form.tax, policy) ?? null, + comment: form.comment || null, + reimbursable: form.reimbursable ?? null, + billable: form.billable ?? null, }; } +/** + * Maps form fields to rule properties, omitting empty values. + * Used for API to avoid sending null values. + */ +function mapFormFieldsToRuleForAPI(form: MerchantRuleForm, policy: Policy | undefined): Partial { + const rule: Partial = {}; + + if (form.merchant) { + rule.merchant = form.merchant; + } + if (form.category) { + rule.category = form.category; + } + if (form.tag) { + rule.tag = form.tag; + } + const tax = buildTaxObject(form.tax, policy); + if (tax) { + rule.tax = tax; + } + if (form.comment) { + rule.comment = form.comment; + } + if (form.reimbursable !== undefined) { + rule.reimbursable = form.reimbursable; + } + if (form.billable !== undefined) { + rule.billable = form.billable; + } + + return rule; +} + /** * Fetches policy rules data when the rules page is opened. * @param policyID - The ID of the policy to fetch rules for @@ -80,30 +114,38 @@ function setPolicyCodingRule(policyID: string, form: MerchantRuleForm, policy: P const isEditing = !!ruleID; const existingRule = isEditing ? policy?.rules?.codingRules?.[ruleID] : undefined; - const ruleFields = mapFormFieldsToRule(form, policy); - // When editing, use the existing rule and merge updated fields; when adding, create a new rule + // Build rule with nulls for Onyx (to remove cleared fields) and without nulls for API + const ruleFieldsForOnyx = mapFormFieldsToRuleForOnyx(form, policy); + const ruleFieldsForAPI = mapFormFieldsToRuleForAPI(form, policy); + const targetRuleID = ruleID ?? NumberUtils.rand64(); - const ruleForOptimisticUpdate: CodingRule = - isEditing && existingRule - ? { - ...existingRule, - ...ruleFields, - filters: { - ...existingRule.filters, - right: form.merchantToMatch, - }, - } - : { - ruleID: targetRuleID, - filters: { - left: 'merchant', - operator: 'eq', - right: form.merchantToMatch, - }, - ...ruleFields, - created: new Date().toISOString(), - }; + const operator = form.matchType ?? CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS; + const created = existingRule?.created ?? new Date().toISOString(); + + // Rule for Onyx optimistic update (includes null values to remove cleared fields) + const ruleForOnyx = { + ruleID: targetRuleID, + filters: { + left: 'merchant', + operator, + right: form.merchantToMatch, + }, + ...ruleFieldsForOnyx, + created, + }; + + // Rule for API (excludes null values) + const ruleForAPI: Partial = { + ruleID: targetRuleID, + filters: { + left: 'merchant', + operator, + right: form.merchantToMatch, + }, + ...ruleFieldsForAPI, + created, + }; const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; const pendingAction = isEditing ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD; @@ -119,7 +161,7 @@ function setPolicyCodingRule(policyID: string, form: MerchantRuleForm, policy: P value: { rules: { codingRules: { - [targetRuleID]: ruleForOptimisticUpdate, + [targetRuleID]: ruleForOnyx, }, }, pendingFields: { @@ -166,7 +208,7 @@ function setPolicyCodingRule(policyID: string, form: MerchantRuleForm, policy: P const parameters: SetPolicyCodingRuleParams = { policyID, codingRuleID: targetRuleID, - codingRuleValue: JSON.stringify(ruleForOptimisticUpdate), + codingRuleValue: JSON.stringify(ruleForAPI), shouldUpdateMatchingTransactions, }; diff --git a/src/pages/workspace/rules/MerchantRules/AddMatchTypePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMatchTypePage.tsx new file mode 100644 index 000000000000..43dbad8d6f3d --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddMatchTypePage.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import {View} from 'react-native'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; +import type {ListItem} from '@components/SelectionList/ListItem/types'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {updateDraftMerchantRule} 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 CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type AddMatchTypePageProps = PlatformStackScreenProps; + +type MatchTypeItem = ListItem & { + value: string; +}; + +function AddMatchTypePage({route}: AddMatchTypePageProps) { + const {policyID, ruleID} = route.params; + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const isEditing = ruleID !== ROUTES.NEW; + + const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); + + const selectedValue = form?.matchType ?? CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS; + + const goBack = () => { + Navigation.goBack(ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.getRoute(policyID, isEditing ? ruleID : undefined)); + }; + + const items: MatchTypeItem[] = [ + { + value: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + keyForList: CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + text: translate('workspace.rules.merchantRules.matchTypeContains'), + isSelected: selectedValue === CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + }, + { + value: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + keyForList: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + text: translate('workspace.rules.merchantRules.matchTypeExact'), + isSelected: selectedValue === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, + }, + ]; + + const onSelectItem = (item: MatchTypeItem) => { + updateDraftMerchantRule({matchType: item.value}); + goBack(); + }; + + return ( + + + + + + + ); +} + +export default AddMatchTypePage; diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx index 74780b91cd7e..3040e886104c 100644 --- a/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx @@ -1,10 +1,21 @@ import React from 'react'; -import type {FormOnyxValues} from '@components/Form/types'; -import RuleTextBase from '@components/Rule/RuleTextBase'; +import {View} from 'react-native'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import ScreenWrapper from '@components/ScreenWrapper'; +import TextInput from '@components/TextInput'; +import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; import {updateDraftMerchantRule} 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 {isRequiredFulfilled, isValidInputLength} from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -14,8 +25,23 @@ type AddMerchantToMatchPageProps = PlatformStackScreenProps { + if (matchType === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO) { + return translate('workspace.rules.merchantRules.matchTypeExact'); + } + return translate('workspace.rules.merchantRules.matchTypeContains'); + }; + const goBack = () => { const backRoute = isEditing ? ROUTES.RULES_MERCHANT_EDIT.getRoute(policyID, ruleID) : ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); Navigation.goBack(backRoute); @@ -26,18 +52,71 @@ function AddMerchantToMatchPage({route}: AddMerchantToMatchPageProps) { goBack(); }; + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const fieldID = CONST.MERCHANT_RULES.FIELDS.MERCHANT_TO_MATCH; + const fieldValue = values[fieldID] ?? ''; + + if (typeof fieldValue !== 'string') { + return errors; + } + + const trimmedValue = fieldValue.trim(); + + if (!isRequiredFulfilled(fieldValue)) { + errors[fieldID] = translate('common.error.fieldRequired'); + } else { + const {isValid, byteLength} = isValidInputLength(trimmedValue, CONST.MERCHANT_NAME_MAX_BYTES); + + if (!isValid) { + errors[fieldID] = translate('common.error.characterLimitExceedCounter', byteLength, CONST.MERCHANT_NAME_MAX_BYTES); + } + } + + return errors; + }; + return ( - + offlineIndicatorStyle={styles.mtAuto} + includeSafeAreaPaddingBottom + shouldEnableMaxHeight + > + + + + + + Navigation.navigate(ROUTES.RULES_MERCHANT_MATCH_TYPE.getRoute(policyID, isEditing ? ruleID : undefined))} + /> + + ); } diff --git a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx index 78ab9063b270..bec660d7ef75 100644 --- a/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx +++ b/src/pages/workspace/rules/MerchantRules/MerchantRulePageBase.tsx @@ -53,8 +53,16 @@ const getBooleanTitle = (value: boolean | undefined, translate: LocalizedTransla }; const getErrorMessage = (translate: LocalizedTranslate, form?: MerchantRuleForm) => { - const merchantToMatchField = CONST.MERCHANT_RULES.FIELDS.MERCHANT_TO_MATCH; - const hasAtLeastOneUpdate = Object.entries(form ?? {}).some(([key, value]) => key !== merchantToMatchField && value !== undefined); + const matchingCriteriaFields = new Set([CONST.MERCHANT_RULES.FIELDS.MERCHANT_TO_MATCH, CONST.MERCHANT_RULES.FIELDS.MATCH_TYPE]); + const hasAtLeastOneUpdate = Object.entries(form ?? {}).some(([key, value]) => { + if (matchingCriteriaFields.has(key)) { + return false; + } + if (typeof value === 'boolean') { + return true; + } + return value !== undefined && value !== ''; + }); if (form?.merchantToMatch && hasAtLeastOneUpdate) { return ''; } @@ -88,8 +96,12 @@ function MerchantRulePageBase({policyID, ruleID, titleKey, testID}: MerchantRule if (!isEditing || !existingRule) { return; } + // Convert the operator to matchType for the form + // 'eq' = exact match, 'contains' = contains match + const matchType = existingRule.filters?.operator; setDraftMerchantRule({ merchantToMatch: existingRule.filters?.right, + matchType, merchant: existingRule.merchant, category: existingRule.category, tag: existingRule.tag, diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index 7273cd1682f2..679cef31c928 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -14,6 +14,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getCleanedTagName} from '@libs/PolicyUtils'; +import CONST from '@src/CONST'; import ROUTES from '@src/ROUTES'; import type {CodingRule} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -125,7 +126,8 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { {sortedRules.map((rule) => { const merchantName = rule.filters?.right ?? ''; - const matchDescription = translate('workspace.rules.merchantRules.ruleSummaryTitle', merchantName); + const isExactMatch = rule.filters?.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO; + const matchDescription = translate('workspace.rules.merchantRules.ruleSummaryTitle', merchantName, isExactMatch); const ruleDescription = getRuleDescription(rule, translate, fieldLabels); return ( diff --git a/src/types/form/MerchantRuleForm.ts b/src/types/form/MerchantRuleForm.ts index 43e3e87c1135..3458af10bf7f 100644 --- a/src/types/form/MerchantRuleForm.ts +++ b/src/types/form/MerchantRuleForm.ts @@ -12,6 +12,7 @@ type MerchantRuleForm = Form< [INPUT_IDS.BILLABLE]: boolean; [INPUT_IDS.CATEGORY]: string; [INPUT_IDS.DESCRIPTION]: string; + [INPUT_IDS.MATCH_TYPE]: string; [INPUT_IDS.MERCHANT]: string; [INPUT_IDS.MERCHANT_TO_MATCH]: string; [INPUT_IDS.REIMBURSABLE]: boolean;