diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 933ccdd2bcb3..80f9cfdd93e2 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -3758,6 +3758,18 @@ const CONST = { DELETE: 'delete', }, }, + MERCHANT_RULES: { + FIELDS: { + BILLABLE: 'billable', + CATEGORY: 'category', + DESCRIPTION: 'comment', + MERCHANT_TO_MATCH: 'merchantToMatch', + MERCHANT: 'merchant', + REIMBURSABLE: 'reimbursable', + TAG: 'tag', + TAX: 'tax', + }, + }, get SUBSCRIPTION_PRICES() { return { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 6ed6bf06f807..836b321d7018 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -980,6 +980,8 @@ const ONYXKEYS = { SPLIT_EXPENSE_EDIT_DATES_DRAFT: 'splitExpenseEditDatesDraft', EXPENSE_RULE_FORM: 'expenseRuleForm', EXPENSE_RULE_FORM_DRAFT: 'expenseRuleFormDraft', + MERCHANT_RULE_FORM: 'merchantRuleForm', + MERCHANT_RULE_FORM_DRAFT: 'merchantRuleFormDraft', ADD_DOMAIN_MEMBER_FORM: 'addDomainMemberForm', ADD_DOMAIN_MEMBER_FORM_DRAFT: 'addDomainMemberFormDraft', }, @@ -1103,6 +1105,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.CREATE_DOMAIN_FORM]: FormTypes.CreateDomainForm; [ONYXKEYS.FORMS.SPLIT_EXPENSE_EDIT_DATES]: FormTypes.SplitExpenseEditDateForm; [ONYXKEYS.FORMS.EXPENSE_RULE_FORM]: FormTypes.ExpenseRuleForm; + [ONYXKEYS.FORMS.MERCHANT_RULE_FORM]: FormTypes.MerchantRuleForm; [ONYXKEYS.FORMS.ADD_DOMAIN_MEMBER_FORM]: FormTypes.AddDomainMemberForm; }; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 83db556a2b2e..6075bedddfb8 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2670,6 +2670,42 @@ const ROUTES = { route: 'workspaces/:policyID/overview/policy', getRoute: (policyID: string) => `workspaces/${policyID}/overview/policy` as const, }, + RULES_MERCHANT_NEW: { + route: 'workspaces/:policyID/rules/merchant-rules/new', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new` as const, + }, + RULES_MERCHANT_MERCHANT_TO_MATCH: { + route: 'workspaces/:policyID/rules/merchant-rules/new/merchant-to-match', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new/merchant-to-match` as const, + }, + RULES_MERCHANT_MERCHANT: { + route: 'workspaces/:policyID/rules/merchant-rules/new/merchant', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new/merchant` as const, + }, + RULES_MERCHANT_CATEGORY: { + route: 'workspaces/:policyID/rules/merchant-rules/new/category', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new/category` as const, + }, + RULES_MERCHANT_TAG: { + route: 'workspaces/:policyID/rules/merchant-rules/new/tag', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new/tag` as const, + }, + RULES_MERCHANT_TAX: { + route: 'workspaces/:policyID/rules/merchant-rules/new/tax', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new/tax` as const, + }, + RULES_MERCHANT_DESCRIPTION: { + route: 'workspaces/:policyID/rules/merchant-rules/new/description', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new/description` as const, + }, + RULES_MERCHANT_REIMBURSABLE: { + route: 'workspaces/:policyID/rules/merchant-rules/new/reimbursable', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new/reimbursable` as const, + }, + RULES_MERCHANT_BILLABLE: { + route: 'workspaces/:policyID/rules/merchant-rules/new/billable', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new/billable` as const, + }, // Referral program promotion REFERRAL_DETAILS_MODAL: { route: 'referral/:contentType', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 05e77b043b87..df293f8acbcb 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -758,6 +758,15 @@ const SCREENS = { RULES_REIMBURSABLE_DEFAULT: 'Rules_Reimbursable_Default', RULES_CUSTOM: 'Rules_Custom', RULES_PROHIBITED_DEFAULT: 'Rules_Prohibited_Default', + RULES_MERCHANT_NEW: 'Rules_Merchant_New', + RULES_MERCHANT_MERCHANT_TO_MATCH: 'Rules_Merchant_Merchant_To_Match', + RULES_MERCHANT_MERCHANT: 'Rules_Merchant_Merchant', + RULES_MERCHANT_CATEGORY: 'Rules_Merchant_Category', + RULES_MERCHANT_TAG: 'Rules_Merchant_Tag', + RULES_MERCHANT_TAX: 'Rules_Merchant_Tax', + RULES_MERCHANT_DESCRIPTION: 'Rules_Merchant_Description', + RULES_MERCHANT_REIMBURSABLE: 'Rules_Merchant_Reimbursable', + RULES_MERCHANT_BILLABLE: 'Rules_Merchant_Billable', PER_DIEM: 'Per_Diem', PER_DIEM_IMPORT: 'Per_Diem_Import', PER_DIEM_IMPORTED: 'Per_Diem_Imported', diff --git a/src/components/Rule/RuleBooleanBase.tsx b/src/components/Rule/RuleBooleanBase.tsx index 1e06c4fa3137..7800c80e486d 100644 --- a/src/components/Rule/RuleBooleanBase.tsx +++ b/src/components/Rule/RuleBooleanBase.tsx @@ -9,45 +9,54 @@ import type {ListItem} from '@components/SelectionList/ListItem/types'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; -import {updateDraftRule} from '@libs/actions/User'; -import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type {InputID} from '@src/types/form/ExpenseRuleForm'; +import type {OnyxFormKey} from '@src/ONYXKEYS'; import RuleNotFoundPageWrapper from './RuleNotFoundPageWrapper'; type BooleanFilterItem = ListItem & { value: ValueOf; }; -type RuleBooleanBasePageProps = { - /** The key from boolean-based InputID */ - fieldID: InputID; +type RuleBooleanBaseProps = { + /** The field ID from the form */ + fieldID: string; /** The translation key for the page title */ titleKey: TranslationPaths; - /** The rule identifier */ + /** The form ID to read from Onyx */ + formID: OnyxFormKey; + + /** Callback when a value is selected - receives boolean for merchant rules, string for personal rules */ + onSelect: (fieldID: string, value: boolean | 'true' | 'false' | null) => void; + + /** Callback to go back */ + onBack: () => void; + + /** Optional hash for rule not found validation */ hash?: string; + + /** Whether to use string values ('true'/'false') instead of boolean values (for ExpenseRuleForm compatibility) */ + useStringValues?: boolean; }; const booleanValues = Object.values(CONST.SEARCH.BOOLEAN); -function RuleBooleanBasePage({fieldID, titleKey, hash}: RuleBooleanBasePageProps) { +function RuleBooleanBase({fieldID, titleKey, formID, onSelect, onBack, hash, useStringValues = false}: RuleBooleanBaseProps) { const {translate} = useLocalize(); - const [form] = useOnyx(ONYXKEYS.FORMS.EXPENSE_RULE_FORM, {canBeMissing: true}); + const [form] = useOnyx(formID, {canBeMissing: true}); const styles = useThemeStyles(); - const selectedItem = - booleanValues.find((value) => { - if (!form?.[fieldID]) { - return false; - } - const booleanValue = form[fieldID] === 'true' ? CONST.SEARCH.BOOLEAN.YES : CONST.SEARCH.BOOLEAN.NO; - return booleanValue === value; - }) ?? null; + const formValue = (form as Record)?.[fieldID]; + + let selectedItem = null; + if (formValue !== undefined) { + // Handle both string ('true'/'false') and boolean (true/false) values + const isTruthy = useStringValues ? formValue === 'true' : formValue === true; + const booleanValue = isTruthy ? CONST.SEARCH.BOOLEAN.YES : CONST.SEARCH.BOOLEAN.NO; + selectedItem = booleanValues.find((value) => booleanValue === value) ?? null; + } const items = booleanValues.map((value) => ({ value, @@ -56,26 +65,26 @@ function RuleBooleanBasePage({fieldID, titleKey, hash}: RuleBooleanBasePageProps isSelected: selectedItem === value, })); - const goBack = () => { - Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); - }; - const onSelectItem = (selectedValue: BooleanFilterItem) => { - const newValue = selectedValue.isSelected ? null : selectedValue.value; - let value = ''; - if (newValue === CONST.SEARCH.BOOLEAN.YES) { - value = 'true'; - } else if (newValue === CONST.SEARCH.BOOLEAN.NO) { - value = 'false'; + // If clicking on already-selected item, unselect it (set to undefined) + if (selectedValue.isSelected) { + onSelect(fieldID, null); + return; + } + const isYes = selectedValue.value === CONST.SEARCH.BOOLEAN.YES; + let value: boolean | 'true' | 'false'; + if (useStringValues) { + value = isYes ? 'true' : 'false'; + } else { + value = isYes; } - updateDraftRule({[fieldID]: value}); - goBack(); + onSelect(fieldID, value); }; return ( void; + + /** Callback to go back */ + onBack: () => void; + + /** The route to navigate back to */ + backToRoute: Route; + + /** Optional hash for rule not found validation */ + hash?: string; +}; + +function RuleSelectionBase({titleKey, testID, selectedItem, items, onSave, onBack, backToRoute, hash}: RuleSelectionBaseProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + return ( + + + + + + + + + ); +} + +export default RuleSelectionBase; diff --git a/src/components/Rule/RuleTextBase.tsx b/src/components/Rule/RuleTextBase.tsx index 9775aa2027c2..98ed7f2c8476 100644 --- a/src/components/Rule/RuleTextBase.tsx +++ b/src/components/Rule/RuleTextBase.tsx @@ -4,20 +4,14 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import {updateDraftRule} from '@libs/actions/User'; -import Navigation from '@libs/Navigation/Navigation'; -import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import type ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type {InputID} from '@src/types/form/ExpenseRuleForm'; +import type {OnyxFormKey} from '@src/ONYXKEYS'; import RuleNotFoundPageWrapper from './RuleNotFoundPageWrapper'; import TextBase from './TextBase'; -// Text-based field IDs that accept string input -type RuleTextBaseProps = { +type RuleTextBaseProps = { /** The key from text-based InputID */ - fieldID: InputID; + fieldID: string; /** The translation key for the page title and input label if labelKey is missing */ titleKey: TranslationPaths; @@ -37,23 +31,23 @@ type RuleTextBaseProps = { /** The character limit for the input */ characterLimit?: number; - /** The rule identifier */ + /** The form ID to read from Onyx */ + formID: TFormID; + + /** Callback when the form is saved */ + onSave: (values: FormOnyxValues) => void; + + /** Callback to go back */ + onBack: () => void; + + /** Optional hash for rule not found validation */ hash?: string; }; -function RuleTextBase({fieldID, hintKey, isRequired, titleKey, labelKey, testID, hash, characterLimit = CONST.MERCHANT_NAME_MAX_BYTES}: RuleTextBaseProps) { +function RuleTextBase({fieldID, hintKey, isRequired, titleKey, labelKey, testID, characterLimit, formID, onSave, onBack, hash}: RuleTextBaseProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); - const goBack = () => { - Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); - }; - - const onSave = (values: FormOnyxValues) => { - updateDraftRule(values); - goBack(); - }; - return ( = { + fieldID: string; hint?: string; isRequired?: boolean; title: string; label: string; characterLimit?: number; - onSubmit: (values: FormOnyxValues) => void; + formID: TFormID; + onSubmit: (values: FormOnyxValues) => void; }; -function TextBase({fieldID, hint, isRequired, title, label, onSubmit, characterLimit = CONST.MERCHANT_NAME_MAX_BYTES}: TextBaseProps) { +function TextBase({fieldID, hint, isRequired, title, label, onSubmit, formID, characterLimit = CONST.MERCHANT_NAME_MAX_BYTES}: TextBaseProps) { const {translate} = useLocalize(); - const [form] = useOnyx(ONYXKEYS.FORMS.EXPENSE_RULE_FORM, {canBeMissing: true}); + const [form] = useOnyx(formID, {canBeMissing: true}); const styles = useThemeStyles(); - const currentValue = form?.[fieldID] ?? ''; + const currentValue = (form as Record)?.[fieldID] ?? ''; const {inputCallbackRef} = useAutoFocusInput(); - const validate = (values: FormOnyxValues) => { - const errors: FormInputErrors = {}; - const fieldValue = values[fieldID] ?? ''; + const validate = (values: FormOnyxValues) => { + const errors: FormInputErrors = {}; + const fieldValue = values[fieldID as keyof FormOnyxValues] ?? ''; if (typeof fieldValue !== 'string') { return errors; @@ -42,12 +42,12 @@ function TextBase({fieldID, hint, isRequired, title, label, onSubmit, characterL const trimmedValue = fieldValue.trim(); if (isRequired && !isRequiredFulfilled(fieldValue)) { - errors[fieldID] = translate('common.error.fieldRequired'); + (errors as Record)[fieldID] = translate('common.error.fieldRequired'); } else { const {isValid, byteLength} = isValidInputLength(trimmedValue, characterLimit); if (!isValid) { - errors[fieldID] = translate('common.error.characterLimitExceedCounter', byteLength, characterLimit); + (errors as Record)[fieldID] = translate('common.error.characterLimitExceedCounter', byteLength, characterLimit); } } @@ -57,7 +57,7 @@ function TextBase({fieldID, hint, isRequired, title, label, onSubmit, characterL return ( `Aktualisiere ${fieldName} zu „${fieldValue}“`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Als "${reimbursable ? 'erstattungsfähig' : 'nicht erstattungsfähig'}" markieren`, ruleSummarySubtitleBillable: (billable: boolean) => `Als „${billable ? 'Abrechenbar' : 'nicht abrechenbar'}“ markieren`, + 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', + confirmErrorUpdate: 'Bitte wenden Sie mindestens eine Aktualisierung an', }, }, planTypePage: { diff --git a/src/languages/en.ts b/src/languages/en.ts index f724200f67cd..207531411cde 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -6179,6 +6179,14 @@ const translations = { title: 'Merchant', subtitle: 'Set the merchant rules so expenses arrive correctly coded and require less cleanup.', addRule: 'Add merchant rule', + addRuleTitle: 'Add rule', + expensesWith: 'For expenses with:', + 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', ruleSummaryTitle: (merchantName: string) => `If merchant contains "${merchantName}"`, ruleSummarySubtitleMerchant: (merchantName: string) => `Rename merchant to "${merchantName}"`, ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Update ${fieldName} to "${fieldValue}"`, diff --git a/src/languages/es.ts b/src/languages/es.ts index a2d73c055d3f..c8aa01f4f053 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5954,6 +5954,14 @@ ${amount} para ${merchant} - ${date}`, title: 'Comerciante', subtitle: 'Configura las reglas de comerciante para que los gastos lleguen correctamente codificados y requieran menos limpieza.', addRule: 'Añadir regla de comerciante', + addRuleTitle: 'Añadir regla', + expensesWith: 'Para gastos 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', ruleSummaryTitle: (merchantName: string) => `Si el comerciante contiene "${merchantName}"`, ruleSummarySubtitleMerchant: (merchantName: string) => `Renombrar comerciante a "${merchantName}"`, ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Actualizar ${fieldName} a "${fieldValue}"`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index bc59f040587d..29bf7994b61b 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -6352,6 +6352,14 @@ Exigez des informations de dépense comme les reçus et les descriptions, défin ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Mettre à jour ${fieldName} sur « ${fieldValue} »`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Marquer comme « ${reimbursable ? 'remboursable' : 'non remboursable'} »`, ruleSummarySubtitleBillable: (billable: boolean) => `Marquer comme « ${billable ? 'facturable' : 'non facturable'} »`, + 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', + confirmErrorUpdate: 'Veuillez appliquer au moins une mise à jour', }, }, planTypePage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 4c28dfb3d207..bb3f17368f47 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -6325,6 +6325,14 @@ Richiedi dettagli di spesa come ricevute e descrizioni, imposta limiti e valori ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Aggiorna ${fieldName} a "${fieldValue}"`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Segna come "${reimbursable ? 'rimborsabile' : 'non rimborsabile'}"`, ruleSummarySubtitleBillable: (billable: boolean) => `Contrassegna come "${billable ? 'fatturabile' : 'non fatturabile'}"`, + 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', + confirmErrorUpdate: 'Applica almeno un aggiornamento', }, }, planTypePage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 799872b3e035..1985d6f31242 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6282,6 +6282,14 @@ ${reportName} ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `${fieldName} を「${fieldValue}」に更新`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `「${reimbursable ? '払い戻し対象' : '精算対象外'}」としてマーク`, ruleSummarySubtitleBillable: (billable: boolean) => `「${billable ? '請求可能' : '請求対象外'}」としてマーク`, + addRuleTitle: 'ルールを追加', + expensesWith: '次の条件の経費について:', + applyUpdates: 'これらの更新を適用:', + merchantHint: '大文字小文字を区別しない「含む」一致で支払先名を照合する', + saveRule: 'ルールを保存', + confirmError: '支払先を入力し、少なくとも 1 つの更新を適用してください', + confirmErrorMerchant: '商人を入力してください', + confirmErrorUpdate: '少なくとも 1 件の更新を適用してください', }, }, planTypePage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index d86ae80b8bd5..404105b7bf08 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -6312,6 +6312,14 @@ Vraag verplichte uitgavedetails zoals bonnetjes en beschrijvingen, stel limieten ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Werk ${fieldName} bij naar "${fieldValue}"`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Markeren als "${reimbursable ? 'Vergoedbaar' : 'niet-vergoedbaar'}"`, ruleSummarySubtitleBillable: (billable: boolean) => `Markeren als "${billable ? 'factureerbaar' : 'niet-factureerbaar'}"`, + 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', + confirmErrorUpdate: 'Breng ten minste één wijziging aan alstublieft', }, }, planTypePage: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index ad343fba20a7..e6a6c14fa91c 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6305,6 +6305,14 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i 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'}"`, ruleSummarySubtitleBillable: (billable: boolean) => `Oznacz jako „${billable ? 'fakturowalne' : 'poza fakturą'}”`, + 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ę', + confirmErrorUpdate: 'Proszę wprowadzić co najmniej jedną zmianę', }, }, planTypePage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 28391858cf31..f809a6167bec 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6306,6 +6306,14 @@ Exija detalhes de despesas como recibos e descrições, defina limites e padrõe ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `Atualizar ${fieldName} para "${fieldValue}"`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `Marcar como "${reimbursable ? 'reembolsável' : 'não reembolsável'}"`, ruleSummarySubtitleBillable: (billable: boolean) => `Marcar como "${billable ? 'faturável' : 'não faturável'}"`, + 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', + confirmErrorUpdate: 'Por favor, aplique pelo menos uma atualização', }, }, planTypePage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 68624fc409d0..ad686ee3baaf 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6172,6 +6172,14 @@ ${reportName} ruleSummarySubtitleUpdateField: (fieldName: string, fieldValue: string) => `将 ${fieldName} 更新为“${fieldValue}”`, ruleSummarySubtitleReimbursable: (reimbursable: boolean) => `标记为“${reimbursable ? '可报销' : '不予报销'}”`, ruleSummarySubtitleBillable: (billable: boolean) => `标记为“${billable ? '可计费' : '不可计费'}”`, + addRuleTitle: '添加规则', + expensesWith: '对于以下费用:', + applyUpdates: '应用这些更新:', + merchantHint: '使用不区分大小写的“包含”匹配来匹配商户名称', + saveRule: '保存规则', + confirmError: '输入商家并至少应用一项更新', + confirmErrorMerchant: '请输入商家', + confirmErrorUpdate: '请至少进行一次更新', }, }, planTypePage: { diff --git a/src/libs/API/parameters/SetPolicyCodingRuleParams.ts b/src/libs/API/parameters/SetPolicyCodingRuleParams.ts new file mode 100644 index 000000000000..d0f8f5ac9c1a --- /dev/null +++ b/src/libs/API/parameters/SetPolicyCodingRuleParams.ts @@ -0,0 +1,15 @@ +type SetPolicyCodingRuleParams = { + /** The policy ID that the rule will be created or updated for */ + policyID: string; + + /** The existing ruleID, or an optimistic one to create the rule */ + ruleID: string; + + /** The JSON value of the merchant rule, stringified */ + value: string; + + /** Whether to update the transactions that match the rule */ + shouldUpdateMatchingTransactions: boolean; +}; + +export default SetPolicyCodingRuleParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 01c8110626bb..b409eb168ffe 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -467,6 +467,7 @@ export type {default as ToggleConsolidatedDomainBillingParams} from './ToggleCon export type {default as RemoveDomainAdminParams} from './RemoveDomainAdminParams'; export type {default as DeleteDomainParams} from './DeleteDomainParams'; export type {default as GetDuplicateTransactionDetailsParams} from './GetDuplicateTransactionDetailsParams'; +export type {default as SetPolicyCodingRuleParams} from './SetPolicyCodingRuleParams'; export type {default as RegisterAuthenticationKeyParams} from './RegisterAuthenticationKeyParams'; export type {default as TroubleshootMultifactorAuthenticationParams} from './TroubleshootMultifactorAuthenticationParams'; export type {default as RequestAuthenticationChallengeParams} from './RequestAuthenticationChallengeParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 8ff474cef14f..510ee28f01cd 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -254,6 +254,7 @@ const WRITE_COMMANDS = { ENABLE_POLICY_INVOICING: 'EnablePolicyInvoicing', ENABLE_POLICY_TIME_TRACKING: 'EnablePolicyTimeTracking', SET_POLICY_RULES_ENABLED: 'SetPolicyRulesEnabled', + SET_POLICY_CODING_RULE: 'SetPolicyCodingRule', SET_POLICY_EXPENSE_MAX_AMOUNT_NO_RECEIPT: 'SetPolicyExpenseMaxAmountNoReceipt', SET_POLICY_EXPENSE_MAX_AMOUNT_NO_ITEMIZED_RECEIPT: 'SetPolicyExpenseMaxAmountNoItemizedReceipt', SET_POLICY_EXPENSE_MAX_AMOUNT: 'SetPolicyExpenseMaxAmount', @@ -799,6 +800,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.ENABLE_POLICY_INVOICING]: Parameters.EnablePolicyInvoicingParams; [WRITE_COMMANDS.ENABLE_POLICY_TIME_TRACKING]: Parameters.EnablePolicyTimeTrackingParams; [WRITE_COMMANDS.SET_POLICY_RULES_ENABLED]: Parameters.SetPolicyRulesEnabledParams; + [WRITE_COMMANDS.SET_POLICY_CODING_RULE]: Parameters.SetPolicyCodingRuleParams; [WRITE_COMMANDS.SET_POLICY_REQUIRE_COMPANY_CARDS_ENABLED]: Parameters.SetPolicyRequireCompanyCardsEnabledParams; [WRITE_COMMANDS.SET_POLICY_CATEGORY_DESCRIPTION_REQUIRED]: Parameters.SetPolicyCategoryDescriptionRequiredParams; [WRITE_COMMANDS.SET_POLICY_CATEGORY_ATTENDEES_REQUIRED]: Parameters.SetPolicyCategoryAttendeesRequiredParams; @@ -837,6 +839,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.CLEAR_OUTSTANDING_BALANCE]: null; [WRITE_COMMANDS.CANCEL_BILLING_SUBSCRIPTION]: Parameters.CancelBillingSubscriptionParams; [WRITE_COMMANDS.SET_POLICY_RULES_ENABLED]: Parameters.SetPolicyRulesEnabledParams; + [WRITE_COMMANDS.SET_POLICY_CODING_RULE]: Parameters.SetPolicyCodingRuleParams; [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AMOUNT_NO_RECEIPT]: Parameters.SetPolicyExpenseMaxAmountNoReceipt; [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AMOUNT_NO_ITEMIZED_RECEIPT]: Parameters.SetPolicyExpenseMaxAmountNoItemizedReceipt; [WRITE_COMMANDS.SET_POLICY_EXPENSE_MAX_AMOUNT]: Parameters.SetPolicyExpenseMaxAmount; diff --git a/src/libs/ExpenseRuleUtils.ts b/src/libs/ExpenseRuleUtils.ts index 918d649c4c9d..ef106bb07dd9 100644 --- a/src/libs/ExpenseRuleUtils.ts +++ b/src/libs/ExpenseRuleUtils.ts @@ -54,7 +54,7 @@ function formatExpenseRuleChanges(rule: ExpenseRule, translate: LocaleContextPro if (rule.tag) { addChange('tag', getCleanedTagName(rule.tag)); } - if (rule.tax?.field_id_TAX) { + if (rule.tax?.field_id_TAX?.value) { addChange('tax', rule.tax.field_id_TAX.value); } if (rule.report) { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 30850882e9aa..319c3cc07bd9 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -822,6 +822,15 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/RulesReimbursableDefaultPage').default, [SCREENS.WORKSPACE.RULES_CUSTOM]: () => require('../../../../pages/workspace/rules/RulesCustomPage').default, [SCREENS.WORKSPACE.RULES_PROHIBITED_DEFAULT]: () => require('../../../../pages/workspace/rules/RulesProhibitedDefaultPage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantRulePage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantToMatchPage').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, + [SCREENS.WORKSPACE.RULES_MERCHANT_TAX]: () => require('../../../../pages/workspace/rules/MerchantRules/AddTaxPage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_DESCRIPTION]: () => require('../../../../pages/workspace/rules/MerchantRules/AddDescriptionPage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_REIMBURSABLE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddReimbursablePage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_BILLABLE]: () => require('../../../../pages/workspace/rules/MerchantRules/AddBillablePage').default, [SCREENS.WORKSPACE.PER_DIEM_IMPORT]: () => require('../../../../pages/workspace/perDiem/ImportPerDiemPage').default, [SCREENS.WORKSPACE.PER_DIEM_IMPORTED]: () => require('../../../../pages/workspace/perDiem/ImportedPerDiemPage').default, [SCREENS.WORKSPACE.PER_DIEM_SETTINGS]: () => require('../../../../pages/workspace/perDiem/WorkspacePerDiemSettingsPage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index d6d510acce16..42d165fde9b4 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -281,6 +281,15 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.RULES_PROHIBITED_DEFAULT]: { path: ROUTES.RULES_PROHIBITED_DEFAULT.route, }, + [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { + path: ROUTES.RULES_MERCHANT_NEW.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH]: { + path: ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT]: { + path: ROUTES.RULES_MERCHANT_MERCHANT.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_CATEGORY]: { + path: ROUTES.RULES_MERCHANT_CATEGORY.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_TAG]: { + path: ROUTES.RULES_MERCHANT_TAG.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_TAX]: { + path: ROUTES.RULES_MERCHANT_TAX.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_DESCRIPTION]: { + path: ROUTES.RULES_MERCHANT_DESCRIPTION.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_REIMBURSABLE]: { + path: ROUTES.RULES_MERCHANT_REIMBURSABLE.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_BILLABLE]: { + path: ROUTES.RULES_MERCHANT_BILLABLE.route, + }, [SCREENS.WORKSPACE.PER_DIEM_IMPORT]: { path: ROUTES.WORKSPACE_PER_DIEM_IMPORT.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 1cee192f433b..1e21b4c3f8b7 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1394,6 +1394,33 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.RULES_CUSTOM]: { policyID: string; }; + [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT_TO_MATCH]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_MERCHANT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_CATEGORY]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_TAG]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_TAX]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_DESCRIPTION]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_REIMBURSABLE]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_BILLABLE]: { + policyID: string; + }; [SCREENS.WORKSPACE.PER_DIEM_IMPORT]: { policyID: string; }; diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index db75f1082bf0..8a368d346bbf 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -1,7 +1,54 @@ +import Onyx from 'react-native-onyx'; import * as API from '@libs/API'; import type OpenPolicyRulesPageParams from '@libs/API/parameters/OpenPolicyRulesPageParams'; -import {READ_COMMANDS} from '@libs/API/types'; +import type SetPolicyCodingRuleParams from '@libs/API/parameters/SetPolicyCodingRuleParams'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import * as ErrorUtils from '@libs/ErrorUtils'; import Log from '@libs/Log'; +import * as NumberUtils from '@libs/NumberUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {MerchantRuleForm} from '@src/types/form'; +import type Policy from '@src/types/onyx/Policy'; +import type {CodingRule, CodingRuleTax} from '@src/types/onyx/Policy'; + +/** + * Builds the tax object from a tax key and policy + */ +function buildTaxObject(taxKey: string | undefined, policy: Policy | undefined): CodingRuleTax | undefined { + if (!taxKey || !policy?.taxRates?.taxes) { + return undefined; + } + + const tax = policy.taxRates.taxes[taxKey]; + if (!tax) { + return undefined; + } + + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + field_id_TAX: { + externalID: taxKey, + value: tax.value, + name: tax.name, + }, + }; +} + +/** + * Maps form fields to rule properties + */ +function mapFormFieldsToRule(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, + }; +} /** * Fetches policy rules data when the rules page is opened. @@ -17,5 +64,96 @@ function openPolicyRulesPage(policyID: string | undefined) { API.read(READ_COMMANDS.OPEN_POLICY_RULES_PAGE, params); } -// eslint-disable-next-line import/prefer-default-export -export {openPolicyRulesPage}; +/** + * Creates or updates a coding rule for the given policy + * @param policyID - The ID of the policy to create/update the rule for + * @param form - The form data for the merchant rule + * @param policy - The policy object (needed to build tax data) + * @param ruleID - Optional existing rule ID for updates + * @param shouldUpdateMatchingTransactions - Whether to update transactions that match the rule + */ +function setPolicyCodingRule(policyID: string, form: MerchantRuleForm, policy: Policy | undefined, ruleID?: string, shouldUpdateMatchingTransactions = false) { + if (!policyID || !form.merchantToMatch) { + Log.warn('Invalid params for setPolicyCodingRule', {policyID, merchantToMatch: form.merchantToMatch}); + return; + } + + const optimisticRuleID = ruleID ?? NumberUtils.rand64(); + const ruleFields = mapFormFieldsToRule(form, policy); + + const optimisticRule: CodingRule = { + ruleID: optimisticRuleID, + filters: { + left: 'merchant', + operator: 'eq', + right: form.merchantToMatch, + }, + ...ruleFields, + created: new Date().toISOString(), + }; + + const policyKey = `${ONYXKEYS.COLLECTION.POLICY}${policyID}` as const; + + const onyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + rules: { + codingRules: { + [optimisticRuleID]: optimisticRule, + }, + }, + pendingFields: { + rules: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + pendingFields: { + rules: null, + }, + errorFields: { + rules: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: policyKey, + value: { + rules: { + codingRules: { + [optimisticRuleID]: null, + }, + }, + pendingFields: { + rules: null, + }, + errorFields: { + rules: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ], + }; + + const parameters: SetPolicyCodingRuleParams = { + policyID, + ruleID: optimisticRuleID, + value: JSON.stringify(optimisticRule), + shouldUpdateMatchingTransactions, + }; + + API.write(WRITE_COMMANDS.SET_POLICY_CODING_RULE, parameters, onyxData); +} + +export {openPolicyRulesPage, setPolicyCodingRule}; diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts index a0e14ff5cbc9..993272f45b1f 100644 --- a/src/libs/actions/User.ts +++ b/src/libs/actions/User.ts @@ -48,7 +48,7 @@ import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {ExpenseRuleForm} from '@src/types/form'; +import type {ExpenseRuleForm, MerchantRuleForm} from '@src/types/form'; import type {AppReview, BlockedFromConcierge, CustomStatusDraft, LoginList, Policy} from '@src/types/onyx'; import type Login from '@src/types/onyx/Login'; import type {Errors} from '@src/types/onyx/OnyxCommon'; @@ -1682,6 +1682,14 @@ function clearDraftRule() { Onyx.set(ONYXKEYS.FORMS.EXPENSE_RULE_FORM, null); } +function updateDraftMerchantRule(ruleData: Partial) { + Onyx.merge(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, ruleData); +} + +function clearDraftMerchantRule() { + Onyx.set(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, null); +} + export { closeAccount, setServerErrorsOnForm, @@ -1728,4 +1736,6 @@ export { setDraftRule, updateDraftRule, clearDraftRule, + updateDraftMerchantRule, + clearDraftMerchantRule, }; diff --git a/src/pages/settings/Rules/Fields/AddBillablePage.tsx b/src/pages/settings/Rules/Fields/AddBillablePage.tsx index 81ba9c07df5c..42a64acc9ac1 100644 --- a/src/pages/settings/Rules/Fields/AddBillablePage.tsx +++ b/src/pages/settings/Rules/Fields/AddBillablePage.tsx @@ -1,18 +1,37 @@ import React from 'react'; -import RuleBooleanBasePage from '@components/Rule/RuleBooleanBase'; +import RuleBooleanBase from '@components/Rule/RuleBooleanBase'; +import {updateDraftRule} 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 AddBillablePageProps = PlatformStackScreenProps; function AddBillablePage({route}: AddBillablePageProps) { + const hash = route.params?.hash; + + const goBack = () => { + Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); + }; + + const onSelect = (fieldID: string, value: boolean | 'true' | 'false' | null) => { + updateDraftRule({[fieldID]: value}); + goBack(); + }; + return ( - ); } diff --git a/src/pages/settings/Rules/Fields/AddCategoryPage.tsx b/src/pages/settings/Rules/Fields/AddCategoryPage.tsx index 4d9a50507179..47e2cd93fe45 100644 --- a/src/pages/settings/Rules/Fields/AddCategoryPage.tsx +++ b/src/pages/settings/Rules/Fields/AddCategoryPage.tsx @@ -1,13 +1,7 @@ -import React, {useCallback, useMemo} from 'react'; -import {View} from 'react-native'; +import React, {useCallback} from 'react'; import type {OnyxCollection} from 'react-native-onyx'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SearchSingleSelectionPicker from '@components/Search/SearchSingleSelectionPicker'; -import useLocalize from '@hooks/useLocalize'; +import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; import {updateDraftRule} from '@libs/actions/User'; import {getAvailableNonPersonalPolicyCategories, getDecodedCategoryName} from '@libs/CategoryUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -22,9 +16,6 @@ import {getEmptyObject} from '@src/types/utils/EmptyObject'; type AddCategoryPageProps = PlatformStackScreenProps; function AddCategoryPage({route}: AddCategoryPageProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const [form] = useOnyx(ONYXKEYS.FORMS.EXPENSE_RULE_FORM, {canBeMissing: true}); const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID, {canBeMissing: true}); const availableNonPersonalPolicyCategoriesSelector = useCallback( @@ -38,7 +29,7 @@ function AddCategoryPage({route}: AddCategoryPageProps) { const selectedCategoryItem = form?.category ? {name: getDecodedCategoryName(form.category), value: form.category} : undefined; - const categoryItems = useMemo(() => { + const categoryItems = () => { const uniqueCategoryNames = new Set(); const categories = Object.values(allPolicyCategories ?? {}).flatMap((policyCategories) => Object.values(policyCategories ?? {})); @@ -52,7 +43,7 @@ function AddCategoryPage({route}: AddCategoryPageProps) { const decodedCategoryName = getDecodedCategoryName(categoryName); return {name: decodedCategoryName, value: categoryName}; }); - }, [allPolicyCategories]); + }; const hash = route.params?.hash; const backToRoute = hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute(); @@ -62,28 +53,16 @@ function AddCategoryPage({route}: AddCategoryPageProps) { }; return ( - - - Navigation.goBack(backToRoute)} - /> - - - - - + Navigation.goBack(backToRoute)} + backToRoute={backToRoute} + hash={hash} + /> ); } diff --git a/src/pages/settings/Rules/Fields/AddDescriptionPage.tsx b/src/pages/settings/Rules/Fields/AddDescriptionPage.tsx index a62365e955f8..b910763b20fc 100644 --- a/src/pages/settings/Rules/Fields/AddDescriptionPage.tsx +++ b/src/pages/settings/Rules/Fields/AddDescriptionPage.tsx @@ -1,20 +1,39 @@ import React from 'react'; +import type {FormOnyxValues} from '@components/Form/types'; import RuleTextBase from '@components/Rule/RuleTextBase'; +import {updateDraftRule} 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 AddDescriptionPageProps = PlatformStackScreenProps; function AddDescriptionPage({route}: AddDescriptionPageProps) { + const hash = route.params?.hash; + + const goBack = () => { + Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); + }; + + const onSave = (values: FormOnyxValues) => { + updateDraftRule(values); + goBack(); + }; + return ( ); } diff --git a/src/pages/settings/Rules/Fields/AddMerchantPage.tsx b/src/pages/settings/Rules/Fields/AddMerchantPage.tsx index 79db39824fb6..805c850c688e 100644 --- a/src/pages/settings/Rules/Fields/AddMerchantPage.tsx +++ b/src/pages/settings/Rules/Fields/AddMerchantPage.tsx @@ -1,22 +1,41 @@ import React from 'react'; +import type {FormOnyxValues} from '@components/Form/types'; import RuleTextBase from '@components/Rule/RuleTextBase'; +import {updateDraftRule} 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 AddMerchantPageProps = PlatformStackScreenProps; function AddMerchantPage({route}: AddMerchantPageProps) { + const hash = route.params?.hash; + + const goBack = () => { + Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); + }; + + const onSave = (values: FormOnyxValues) => { + updateDraftRule(values); + goBack(); + }; + return ( ); } diff --git a/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx b/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx index 968b0a6ddb1f..bef1e0a8358f 100644 --- a/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx +++ b/src/pages/settings/Rules/Fields/AddReimbursablePage.tsx @@ -1,18 +1,37 @@ import React from 'react'; -import RuleBooleanBasePage from '@components/Rule/RuleBooleanBase'; +import RuleBooleanBase from '@components/Rule/RuleBooleanBase'; +import {updateDraftRule} 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 AddReimbursablePageProps = PlatformStackScreenProps; function AddReimbursablePage({route}: AddReimbursablePageProps) { + const hash = route.params?.hash; + + const goBack = () => { + Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); + }; + + const onSelect = (fieldID: string, value: boolean | 'true' | 'false' | null) => { + updateDraftRule({[fieldID]: value}); + goBack(); + }; + return ( - ); } diff --git a/src/pages/settings/Rules/Fields/AddRenameMerchantPage.tsx b/src/pages/settings/Rules/Fields/AddRenameMerchantPage.tsx index 1418a8f9b357..24fb50e2198d 100644 --- a/src/pages/settings/Rules/Fields/AddRenameMerchantPage.tsx +++ b/src/pages/settings/Rules/Fields/AddRenameMerchantPage.tsx @@ -1,20 +1,39 @@ import React from 'react'; +import type {FormOnyxValues} from '@components/Form/types'; import RuleTextBase from '@components/Rule/RuleTextBase'; +import {updateDraftRule} 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 AddRenameMerchantPageProps = PlatformStackScreenProps; function AddRenameMerchantPage({route}: AddRenameMerchantPageProps) { + const hash = route.params?.hash; + + const goBack = () => { + Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); + }; + + const onSave = (values: FormOnyxValues) => { + updateDraftRule(values); + goBack(); + }; + return ( ); } diff --git a/src/pages/settings/Rules/Fields/AddReportPage.tsx b/src/pages/settings/Rules/Fields/AddReportPage.tsx index 46fc5dbc08a9..2d6578688c18 100644 --- a/src/pages/settings/Rules/Fields/AddReportPage.tsx +++ b/src/pages/settings/Rules/Fields/AddReportPage.tsx @@ -1,21 +1,40 @@ import React from 'react'; +import type {FormOnyxValues} from '@components/Form/types'; import RuleTextBase from '@components/Rule/RuleTextBase'; +import {updateDraftRule} 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 AddReportPageProps = PlatformStackScreenProps; function AddReportPage({route}: AddReportPageProps) { + const hash = route.params?.hash; + + const goBack = () => { + Navigation.goBack(hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute()); + }; + + const onSave = (values: FormOnyxValues) => { + updateDraftRule(values); + goBack(); + }; + return ( ); } diff --git a/src/pages/settings/Rules/Fields/AddTagPage.tsx b/src/pages/settings/Rules/Fields/AddTagPage.tsx index a96648ecce14..290bb6d16c8b 100644 --- a/src/pages/settings/Rules/Fields/AddTagPage.tsx +++ b/src/pages/settings/Rules/Fields/AddTagPage.tsx @@ -1,13 +1,7 @@ -import React, {useMemo} from 'react'; -import {View} from 'react-native'; +import React from 'react'; import type {OnyxCollection} from 'react-native-onyx'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SearchSingleSelectionPicker from '@components/Search/SearchSingleSelectionPicker'; -import useLocalize from '@hooks/useLocalize'; +import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; import {updateDraftRule} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -22,9 +16,6 @@ import {getEmptyObject} from '@src/types/utils/EmptyObject'; type AddTagPageProps = PlatformStackScreenProps; function AddTagPage({route}: AddTagPageProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const [form] = useOnyx(ONYXKEYS.FORMS.EXPENSE_RULE_FORM, {canBeMissing: true}); const [allPolicyTagLists = getEmptyObject>>()] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS, { canBeMissing: true, @@ -32,7 +23,7 @@ function AddTagPage({route}: AddTagPageProps) { const selectedTagItem = form?.tag ? {name: getCleanedTagName(form.tag), value: form.tag} : undefined; - const tagItems = useMemo(() => { + const tagItems = () => { const uniqueTagNames = new Set(); const tagListsUnpacked = Object.values(allPolicyTagLists ?? {}).filter((item) => !!item); @@ -41,7 +32,7 @@ function AddTagPage({route}: AddTagPageProps) { } return Array.from(uniqueTagNames).map((tagName) => ({name: getCleanedTagName(tagName), value: tagName})); - }, [allPolicyTagLists]); + }; const hash = route.params?.hash; const backToRoute = hash ? ROUTES.SETTINGS_RULES_EDIT.getRoute(hash) : ROUTES.SETTINGS_RULES_ADD.getRoute(); @@ -51,28 +42,16 @@ function AddTagPage({route}: AddTagPageProps) { }; return ( - - - Navigation.goBack(backToRoute)} - /> - - - - - + Navigation.goBack(backToRoute)} + backToRoute={backToRoute} + hash={hash} + /> ); } diff --git a/src/pages/settings/Rules/Fields/AddTaxRatePage.tsx b/src/pages/settings/Rules/Fields/AddTaxRatePage.tsx index 8b799e9bf16e..73469a95f21f 100644 --- a/src/pages/settings/Rules/Fields/AddTaxRatePage.tsx +++ b/src/pages/settings/Rules/Fields/AddTaxRatePage.tsx @@ -1,12 +1,6 @@ import React from 'react'; -import {View} from 'react-native'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import RuleNotFoundPageWrapper from '@components/Rule/RuleNotFoundPageWrapper'; -import ScreenWrapper from '@components/ScreenWrapper'; -import SearchSingleSelectionPicker from '@components/Search/SearchSingleSelectionPicker'; -import useLocalize from '@hooks/useLocalize'; +import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; import useOnyx from '@hooks/useOnyx'; -import useThemeStyles from '@hooks/useThemeStyles'; import {updateDraftRule} from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; @@ -19,9 +13,6 @@ import type SCREENS from '@src/SCREENS'; type AddTaxRatePageProps = PlatformStackScreenProps; function AddTaxRatePage({route}: AddTaxRatePageProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const [form] = useOnyx(ONYXKEYS.FORMS.EXPENSE_RULE_FORM, {canBeMissing: true}); const [allTaxRates] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true, selector: getAllTaxRatesNamesAndValues}); @@ -39,28 +30,16 @@ function AddTaxRatePage({route}: AddTaxRatePageProps) { }; return ( - - - Navigation.goBack(backToRoute)} - /> - - - - - + Navigation.goBack(backToRoute)} + backToRoute={backToRoute} + hash={hash} + /> ); } diff --git a/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx b/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx new file mode 100644 index 000000000000..8284708709f5 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddBillablePage.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import RuleBooleanBase from '@components/Rule/RuleBooleanBase'; +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 AddBillablePageProps = PlatformStackScreenProps; + +function AddBillablePage({route}: AddBillablePageProps) { + const policyID = route.params.policyID; + + const goBack = () => { + Navigation.goBack(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + }; + + const onSelect = (fieldID: string, value: boolean | 'true' | 'false' | null) => { + updateDraftMerchantRule({[fieldID]: value}); + goBack(); + }; + + return ( + + ); +} + +AddBillablePage.displayName = 'AddBillablePage'; + +export default AddBillablePage; diff --git a/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx b/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx new file mode 100644 index 000000000000..48e57731f530 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddCategoryPage.tsx @@ -0,0 +1,53 @@ +import React, {useMemo} from 'react'; +import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; +import useOnyx from '@hooks/useOnyx'; +import {updateDraftMerchantRule} from '@libs/actions/User'; +import {getDecodedCategoryName} from '@libs/CategoryUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type AddCategoryPageProps = PlatformStackScreenProps; + +function AddCategoryPage({route}: AddCategoryPageProps) { + const policyID = route.params.policyID; + + const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, {canBeMissing: true}); + + const selectedCategoryItem = form?.category ? {name: getDecodedCategoryName(form.category), value: form.category} : undefined; + + const categoryItems = useMemo(() => { + return Object.values(policyCategories ?? {}) + .filter((category) => category.enabled) + .map((category) => { + const decodedCategoryName = getDecodedCategoryName(category.name); + return {name: decodedCategoryName, value: category.name}; + }); + }, [policyCategories]); + + const backToRoute = ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); + + const onSave = (value?: string) => { + updateDraftMerchantRule({category: value}); + }; + + return ( + Navigation.goBack(backToRoute)} + backToRoute={backToRoute} + /> + ); +} + +AddCategoryPage.displayName = 'AddCategoryPage'; + +export default AddCategoryPage; diff --git a/src/pages/workspace/rules/MerchantRules/AddDescriptionPage.tsx b/src/pages/workspace/rules/MerchantRules/AddDescriptionPage.tsx new file mode 100644 index 000000000000..9b4328e1910f --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddDescriptionPage.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import type {FormOnyxValues} from '@components/Form/types'; +import RuleTextBase from '@components/Rule/RuleTextBase'; +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 AddDescriptionPageProps = PlatformStackScreenProps; + +function AddDescriptionPage({route}: AddDescriptionPageProps) { + const policyID = route.params.policyID; + + const goBack = () => { + Navigation.goBack(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + }; + + const onSave = (values: FormOnyxValues) => { + updateDraftMerchantRule(values); + goBack(); + }; + + return ( + + ); +} + +AddDescriptionPage.displayName = 'AddDescriptionPage'; + +export default AddDescriptionPage; diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantPage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantPage.tsx new file mode 100644 index 000000000000..5a6756906377 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantPage.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import type {FormOnyxValues} from '@components/Form/types'; +import RuleTextBase from '@components/Rule/RuleTextBase'; +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 AddMerchantPageProps = PlatformStackScreenProps; + +function AddMerchantPage({route}: AddMerchantPageProps) { + const policyID = route.params.policyID; + + const goBack = () => { + Navigation.goBack(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + }; + + const onSave = (values: FormOnyxValues) => { + updateDraftMerchantRule(values); + goBack(); + }; + + return ( + + ); +} + +AddMerchantPage.displayName = 'AddMerchantPage'; + +export default AddMerchantPage; diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx new file mode 100644 index 000000000000..1f0da99badaa --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantRulePage.tsx @@ -0,0 +1,237 @@ +import React, {useEffect, useState} from 'react'; +import {View} from 'react-native'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import type {LocalizedTranslate} from '@components/LocaleContextProvider'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {setPolicyCodingRule} from '@libs/actions/Policy/Rules'; +import {clearDraftMerchantRule} from '@libs/actions/User'; +import {getDecodedCategoryName} from '@libs/CategoryUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {getCleanedTagName, getTagNamesFromTagsLists} from '@libs/PolicyUtils'; +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 type {MerchantRuleForm} from '@src/types/form'; + +type AddMerchantRulePageProps = PlatformStackScreenProps; + +type SectionItemType = { + descriptionTranslationKey: 'common.merchant' | 'common.category' | 'common.tag' | 'common.tax' | 'common.description' | 'common.reimbursable' | 'common.billable'; + required?: boolean; + title?: string; + onPress: () => void; +}; + +type SectionType = { + titleTranslationKey: 'workspace.rules.merchantRules.expensesWith' | 'workspace.rules.merchantRules.applyUpdates'; + items: Array; +}; + +const getBooleanTitle = (value: boolean | undefined, translate: LocalizedTranslate): string => { + if (value === undefined) { + return ''; + } + return translate(value ? 'common.yes' : 'common.no'); +}; + +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); + if (form?.merchantToMatch && hasAtLeastOneUpdate) { + return ''; + } + if (hasAtLeastOneUpdate) { + return translate('workspace.rules.merchantRules.confirmErrorMerchant'); + } + if (form?.merchantToMatch) { + return translate('workspace.rules.merchantRules.confirmErrorUpdate'); + } + return translate('workspace.rules.merchantRules.confirmError'); +}; + +function AddMerchantRulePage({route}: AddMerchantRulePageProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const policyID = route.params.policyID; + const policy = usePolicy(policyID); + + const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); + const [policyCategories] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, {canBeMissing: true}); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {canBeMissing: true}); + const [shouldShowError, setShouldShowError] = useState(false); + + useEffect(() => () => clearDraftMerchantRule(), []); + + const hasCategories = () => { + if (!policy?.areCategoriesEnabled) { + return false; + } + return Object.keys(policyCategories ?? {}).length > 0; + }; + + const hasTags = () => { + if (!policy?.areTagsEnabled) { + return false; + } + const tagNames = getTagNamesFromTagsLists(policyTags ?? {}); + return tagNames.length > 0; + }; + + const hasTaxes = () => { + if (!policy?.tax?.trackingEnabled) { + return false; + } + return Object.keys(policy?.taxRates?.taxes ?? {}).length > 0; + }; + + const isBillableEnabled = policy?.disabledFields?.defaultBillable !== true; + + const categoryDisplayName = form?.category ? getDecodedCategoryName(form.category) : undefined; + const tagDisplayName = form?.tag ? getCleanedTagName(form.tag) : undefined; + const taxDisplayName = () => { + if (!form?.tax || !policy?.taxRates?.taxes) { + return undefined; + } + const tax = policy.taxRates.taxes[form.tax]; + return tax ? `${tax.name} (${tax.value})` : undefined; + }; + + const errorMessage = getErrorMessage(translate, form); + + const handleSubmit = () => { + if (errorMessage) { + setShouldShowError(true); + return; + } + if (!form) { + return; + } + + setPolicyCodingRule(policyID, form, policy); + Navigation.goBack(); + }; + + const sections: SectionType[] = [ + { + titleTranslationKey: 'workspace.rules.merchantRules.expensesWith', + items: [ + { + descriptionTranslationKey: 'common.merchant', + required: true, + title: form?.merchantToMatch, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_MERCHANT_TO_MATCH.getRoute(policyID)), + }, + ], + }, + { + titleTranslationKey: 'workspace.rules.merchantRules.applyUpdates', + items: [ + { + descriptionTranslationKey: 'common.merchant', + title: form?.merchant, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_MERCHANT.getRoute(policyID)), + }, + hasCategories() + ? { + descriptionTranslationKey: 'common.category', + title: categoryDisplayName, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_CATEGORY.getRoute(policyID)), + } + : undefined, + hasTags() + ? { + descriptionTranslationKey: 'common.tag', + title: tagDisplayName, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAG.getRoute(policyID)), + } + : undefined, + hasTaxes() + ? { + descriptionTranslationKey: 'common.tax', + title: taxDisplayName(), + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_TAX.getRoute(policyID)), + } + : undefined, + { + descriptionTranslationKey: 'common.description', + title: form?.comment, + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_DESCRIPTION.getRoute(policyID)), + }, + { + descriptionTranslationKey: 'common.reimbursable', + title: getBooleanTitle(form?.reimbursable, translate), + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_REIMBURSABLE.getRoute(policyID)), + }, + isBillableEnabled + ? { + descriptionTranslationKey: 'common.billable', + title: getBooleanTitle(form?.billable, translate), + onPress: () => Navigation.navigate(ROUTES.RULES_MERCHANT_BILLABLE.getRoute(policyID)), + } + : undefined, + ], + }, + ]; + + return ( + + + + + {sections.map((section) => ( + + {translate(section.titleTranslationKey)} + {section.items + .filter((item): item is SectionItemType => !!item) + .map((item) => ( + + ))} + + ))} + + + + + ); +} + +AddMerchantRulePage.displayName = 'AddMerchantRulePage'; + +export default AddMerchantRulePage; diff --git a/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx b/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx new file mode 100644 index 000000000000..45ddd6b04f2c --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddMerchantToMatchPage.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import type {FormOnyxValues} from '@components/Form/types'; +import RuleTextBase from '@components/Rule/RuleTextBase'; +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 AddMerchantToMatchPageProps = PlatformStackScreenProps; + +function AddMerchantToMatchPage({route}: AddMerchantToMatchPageProps) { + const policyID = route.params.policyID; + + const goBack = () => { + Navigation.goBack(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + }; + + const onSave = (values: FormOnyxValues) => { + updateDraftMerchantRule(values); + goBack(); + }; + + return ( + + ); +} + +AddMerchantToMatchPage.displayName = 'AddMerchantToMatchPage'; + +export default AddMerchantToMatchPage; diff --git a/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx b/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx new file mode 100644 index 000000000000..843bcd7a4fd6 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddReimbursablePage.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import RuleBooleanBase from '@components/Rule/RuleBooleanBase'; +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 AddReimbursablePageProps = PlatformStackScreenProps; + +function AddReimbursablePage({route}: AddReimbursablePageProps) { + const policyID = route.params.policyID; + + const goBack = () => { + Navigation.goBack(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID)); + }; + + const onSelect = (fieldID: string, value: boolean | 'true' | 'false' | null) => { + updateDraftMerchantRule({[fieldID]: value}); + goBack(); + }; + + return ( + + ); +} + +AddReimbursablePage.displayName = 'AddReimbursablePage'; + +export default AddReimbursablePage; diff --git a/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx b/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx new file mode 100644 index 000000000000..8d8cd62d37ad --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddTagPage.tsx @@ -0,0 +1,59 @@ +import React, {useMemo} from 'react'; +import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; +import useOnyx from '@hooks/useOnyx'; +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 {getCleanedTagName} from '@libs/PolicyUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type AddTagPageProps = PlatformStackScreenProps; + +function AddTagPage({route}: AddTagPageProps) { + const policyID = route.params.policyID; + + const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); + const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {canBeMissing: true}); + + const selectedTagItem = form?.tag ? {name: getCleanedTagName(form.tag), value: form.tag} : undefined; + + const tagItems = useMemo(() => { + const tags: Array<{name: string; value: string}> = []; + + for (const tagList of Object.values(policyTags ?? {})) { + for (const tag of Object.values(tagList?.tags ?? {})) { + if (!tag.enabled) { + continue; + } + tags.push({name: getCleanedTagName(tag.name), value: tag.name}); + } + } + + return tags; + }, [policyTags]); + + const backToRoute = ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); + + const onSave = (value?: string) => { + updateDraftMerchantRule({tag: value}); + }; + + return ( + Navigation.goBack(backToRoute)} + backToRoute={backToRoute} + /> + ); +} + +AddTagPage.displayName = 'AddTagPage'; + +export default AddTagPage; diff --git a/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx b/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx new file mode 100644 index 000000000000..4d936107069f --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/AddTaxPage.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import RuleSelectionBase from '@components/Rule/RuleSelectionBase'; +import useOnyx from '@hooks/useOnyx'; +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 ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +type AddTaxPageProps = PlatformStackScreenProps; + +function AddTaxPage({route}: AddTaxPageProps) { + const policyID = route.params.policyID; + + const [form] = useOnyx(ONYXKEYS.FORMS.MERCHANT_RULE_FORM, {canBeMissing: true}); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {canBeMissing: true}); + + const taxes = policy?.taxRates?.taxes ?? {}; + const taxItems = Object.entries(taxes) + .filter(([, tax]) => !tax.isDisabled) + .map(([taxKey, tax]) => ({ + name: `${tax.name} (${tax.value})`, + value: taxKey, + })); + + const selectedTaxItem = form?.tax ? taxItems.find(({value}) => value === form.tax) : undefined; + + const backToRoute = ROUTES.RULES_MERCHANT_NEW.getRoute(policyID); + + const onSave = (value?: string) => { + updateDraftMerchantRule({tax: value}); + }; + + return ( + Navigation.goBack(backToRoute)} + backToRoute={backToRoute} + /> + ); +} + +AddTaxPage.displayName = 'AddTaxPage'; + +export default AddTaxPage; diff --git a/src/pages/workspace/rules/MerchantRulesSection.tsx b/src/pages/workspace/rules/MerchantRulesSection.tsx index c07048146de2..af0f84c4506b 100644 --- a/src/pages/workspace/rules/MerchantRulesSection.tsx +++ b/src/pages/workspace/rules/MerchantRulesSection.tsx @@ -12,7 +12,9 @@ import usePolicy from '@hooks/usePolicy'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {getDecodedCategoryName} from '@libs/CategoryUtils'; +import Navigation from '@libs/Navigation/Navigation'; import {getCleanedTagName} from '@libs/PolicyUtils'; +import ROUTES from '@src/ROUTES'; import type {CodingRule} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -46,7 +48,7 @@ function getRuleDescription(rule: CodingRule, translate: ReturnType @@ -147,7 +149,7 @@ function MerchantRulesSection({policyID}: MerchantRulesSectionProps) { iconHeight={20} iconWidth={20} style={[styles.sectionMenuItemTopDescription, !hasRules && styles.mt6, styles.mbn3]} - onPress={() => {}} + onPress={() => Navigation.navigate(ROUTES.RULES_MERCHANT_NEW.getRoute(policyID))} /> ); diff --git a/src/types/form/MerchantRuleForm.ts b/src/types/form/MerchantRuleForm.ts new file mode 100644 index 000000000000..43e3e87c1135 --- /dev/null +++ b/src/types/form/MerchantRuleForm.ts @@ -0,0 +1,24 @@ +import type {ValueOf} from 'type-fest'; +import CONST from '@src/CONST'; +import type Form from './Form'; + +const INPUT_IDS = CONST.MERCHANT_RULES.FIELDS; + +type InputID = ValueOf; + +type MerchantRuleForm = Form< + InputID, + { + [INPUT_IDS.BILLABLE]: boolean; + [INPUT_IDS.CATEGORY]: string; + [INPUT_IDS.DESCRIPTION]: string; + [INPUT_IDS.MERCHANT]: string; + [INPUT_IDS.MERCHANT_TO_MATCH]: string; + [INPUT_IDS.REIMBURSABLE]: boolean; + [INPUT_IDS.TAG]: string; + [INPUT_IDS.TAX]: string; + } +>; + +export type {MerchantRuleForm, InputID}; +export default INPUT_IDS; diff --git a/src/types/form/index.ts b/src/types/form/index.ts index b165f9068d6b..cac1d914cea6 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -104,4 +104,5 @@ export type {CreateDomainForm} from './CreateDomainForm'; export type {SplitExpenseEditDateForm} from './SplitExpenseEditDateForm'; export type {ResetDomainForm} from './ResetDomainForm'; export type {ExpenseRuleForm} from './ExpenseRuleForm'; +export type {MerchantRuleForm} from './MerchantRuleForm'; export type {AddDomainMemberForm} from './AddDomainMemberForm'; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index bff48c2d6e49..d8fde9159d65 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -1705,13 +1705,19 @@ type CodingRuleTax = { /** The external ID of the tax rate */ externalID: string; - /** The display value of the tax rate */ + /** The tax rate value (e.g., "8.5%") */ value: string; + + /** The name of the tax rate */ + name: string; }; }; /** Policy coding rule data model */ type CodingRule = { + /** Unique identifier for the rule */ + ruleID?: string; + /** Filter conditions for when this rule applies */ filters: CodingRuleFilter;