diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 0429be45be59..b4b7a199632f 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -7836,6 +7836,11 @@ const CONST = { EXTERNAL_ID: 'externalID', MAX_AMOUNT_NO_RECEIPT: 'maxAmountNoReceipt', MAX_AMOUNT_NO_ITEMIZED_RECEIPT: 'maxAmountNoItemizedReceipt', + MERCHANT_IS: 'merchantIs', + MERCHANT_CONTAINS: 'merchantContains', + UPDATED_MERCHANT: 'updatedMerchant', + REIMBURSABLE: 'reimbursable', + BILLABLE: 'billable', }, IMPORT_SPREADSHEET: { @@ -8764,6 +8769,7 @@ const CONST = { MERCHANT_TYPE_RULE_SAVE: 'WorkspaceRules-MerchantTypeRuleSave', MERCHANT_TYPE_RULE_CATEGORY: 'WorkspaceRules-MerchantTypeRuleCategory', ADD_MERCHANT_RULE: 'WorkspaceRules-AddMerchantRule', + IMPORT_MERCHANT_RULES: 'WorkspaceRules-ImportMerchantRules', MERCHANT_RULE_SECTION_ITEM: 'WorkspaceRules-MerchantRuleSectionItem', MERCHANT_RULE_SAVE: 'WorkspaceRules-MerchantRuleSave', MERCHANT_RULE_PREVIEW_MATCHES: 'WorkspaceRules-MerchantRulePreviewMatches', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f7b9df0bae9d..d067567d22d4 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -3282,6 +3282,14 @@ const ROUTES = { route: 'workspaces/:policyID/rules/merchant-rules/new', getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/new` as const, }, + RULES_MERCHANT_IMPORT: { + route: 'workspaces/:policyID/rules/merchant-rules/import', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/import` as const, + }, + RULES_MERCHANT_IMPORTED: { + route: 'workspaces/:policyID/rules/merchant-rules/imported', + getRoute: (policyID: string) => `workspaces/${policyID}/rules/merchant-rules/imported` as const, + }, RULES_SPEND_NEW: { route: 'workspaces/:policyID/rules/spend-rules/new', getRoute: (policyID: string) => `workspaces/${policyID}/rules/spend-rules/new` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 17513ec8312b..87277c810146 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -879,6 +879,8 @@ const SCREENS = { RULES_PROHIBITED_DEFAULT: 'Rules_Prohibited_Default', RULES_NEW: 'Rules_New', RULES_MERCHANT_NEW: 'Rules_Merchant_New', + RULES_MERCHANT_IMPORT: 'Rules_Merchant_Import', + RULES_MERCHANT_IMPORTED: 'Rules_Merchant_Imported', RULES_SPEND_NEW: 'Rules_Spend_New', RULES_REQUIRE_FIELDS_RULE_NEW: 'Rules_Require_Fields_Rule_New', RULES_REQUIRE_FIELDS_RULE_EDIT: 'Rules_Require_Fields_Rule_Edit', diff --git a/src/components/ImportColumn.tsx b/src/components/ImportColumn.tsx index 34bd9978a8d8..1d68d9f83ade 100644 --- a/src/components/ImportColumn.tsx +++ b/src/components/ImportColumn.tsx @@ -31,9 +31,14 @@ function findColumnName(header: string, columnRoles?: ColumnRole[]): string { case 'category': case 'categories': + case 'updatedcategory': attribute = CONST.CSV_IMPORT_COLUMNS.CATEGORY; break; + case 'updateddescription': + attribute = CONST.CSV_IMPORT_COLUMNS.COMMENT; + break; + case 'glcode': case 'gl': attribute = CONST.CSV_IMPORT_COLUMNS.GL_CODE; @@ -137,6 +142,39 @@ function findColumnName(header: string, columnRoles?: ColumnRole[]): string { attribute = CONST.CSV_IMPORT_COLUMNS.MERCHANT; break; + case 'merchantis': + attribute = CONST.CSV_IMPORT_COLUMNS.MERCHANT_IS; + break; + + case 'merchantcontains': + attribute = CONST.CSV_IMPORT_COLUMNS.MERCHANT_CONTAINS; + break; + + case 'updatedmerchant': + case 'newmerchant': + attribute = CONST.CSV_IMPORT_COLUMNS.UPDATED_MERCHANT; + break; + + case 'updatedtag': + case 'newtag': + attribute = CONST.CSV_IMPORT_COLUMNS.TAG; + break; + + case 'reimbursable': + case 'reimburseable': + attribute = CONST.CSV_IMPORT_COLUMNS.REIMBURSABLE; + break; + + case 'preferredmerchantname': + case 'preferredmerchant(vendor)name': + case 'preferredvendorname': + attribute = CONST.CSV_IMPORT_COLUMNS.MERCHANT_IS; + break; + + case 'billable': + attribute = CONST.CSV_IMPORT_COLUMNS.BILLABLE; + break; + case 'rateid': attribute = CONST.CSV_IMPORT_COLUMNS.RATE_ID; break; @@ -163,6 +201,13 @@ function findColumnName(header: string, columnRoles?: ColumnRole[]): string { break; } + // A bare "Description" header is ambiguous across import flows (e.g. bank CSVs use it for the + // transaction descriptor), so it only auto-maps to the updated-description action in the merchant + // rules import, which is the only flow offering the MERCHANT_IS column role. + if (!attribute && formattedHeader === 'description' && columnRoles?.some((role) => role.value === CONST.CSV_IMPORT_COLUMNS.MERCHANT_IS)) { + attribute = CONST.CSV_IMPORT_COLUMNS.COMMENT; + } + // If the detected attribute isn't available in the current context but a semantic equivalent is, // remap to it. This handles e.g. "Date" headers in company card imports where DATE is not a // valid column role but POSTED_DATE is. @@ -172,6 +217,14 @@ function findColumnName(header: string, columnRoles?: ColumnRole[]): string { if (attribute === CONST.CSV_IMPORT_COLUMNS.DATE && columnRoles.some((role) => role.value === CONST.CSV_IMPORT_COLUMNS.POSTED_DATE)) { return CONST.CSV_IMPORT_COLUMNS.POSTED_DATE; } + if (attribute === CONST.CSV_IMPORT_COLUMNS.MERCHANT && columnRoles.some((role) => role.value === CONST.CSV_IMPORT_COLUMNS.UPDATED_MERCHANT)) { + return CONST.CSV_IMPORT_COLUMNS.UPDATED_MERCHANT; + } + // Only tag-like headers remap from NAME to TAG, so headers like "Name" or "Customer" stay + // unmapped in contexts without a NAME role instead of silently becoming a tag column. + if (attribute === CONST.CSV_IMPORT_COLUMNS.NAME && ['tag', 'tags'].includes(formattedHeader) && columnRoles.some((role) => role.value === CONST.CSV_IMPORT_COLUMNS.TAG)) { + return CONST.CSV_IMPORT_COLUMNS.TAG; + } return ''; } } @@ -225,7 +278,10 @@ function ImportColumn({column, columnName, columnRoles, columnIndex, shouldShowD isSelected: spreadsheet?.columns?.[columnIndex] === item.value, })); - const columnValuesString = column.slice(containsHeader ? 1 : 0).join(', '); + const columnValuesString = column + .slice(containsHeader ? 1 : 0) + .filter((value) => String(value).trim() !== '') + .join(', '); const currentColumnValue = spreadsheet?.columns?.[columnIndex]; // Treat 'ignore' as unmapped so auto-detection can still run diff --git a/src/languages/de.ts b/src/languages/de.ts index 6b8c6f2eadb6..8638288c44b6 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1161,6 +1161,14 @@ const translations: TranslationDeepObject = { importTagsSuccessfulDescription: ({tags}: {tags: number}) => (tags > 1 ? `${tags} Tags wurden hinzugefügt.` : '1 Tag wurde hinzugefügt.'), importMultiLevelTagsSuccessfulDescription: 'Mehrstufige Tags wurden hinzugefügt.', importPerDiemRatesSuccessfulDescription: ({rates}: {rates: number}) => (rates > 1 ? `${rates} Tagessätze wurden hinzugefügt.` : '1 Tagegeldsatz wurde hinzugefügt.'), + importMerchantRulesSuccessfulDescription: ({rules}: {rules: number}) => { + if (rules === 0) { + return 'Es wurden keine Händlerregeln hinzugefügt, da sie alle bereits existieren.'; + } + return rules > 1 ? `${rules} Händlerregeln wurden hinzugefügt.` : '1 Händlerregel wurde hinzugefügt.'; + }, + importMerchantRulesRequiredColumns: + 'Ups! Du musst mindestens eine Spalte („Händler ist“ oder „Händler enthält“) zuordnen sowie mindestens ein zu aktualisierendes Feld. Bitte überprüfe es und versuche es erneut.', importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => transactions > 1 ? `${transactions} Buchungen wurden importiert.` : '1 Transaktion wurde importiert.', importFailedTitle: 'Import fehlgeschlagen', @@ -7466,6 +7474,14 @@ Fordern Sie Spesendetails wie Belege und Beschreibungen an, legen Sie Limits und addRule: 'Händlerregel hinzufügen', addRuleTitle: 'Regel hinzufügen', editRuleTitle: 'Regel bearbeiten', + importRulesTitle: 'Händlerregeln importieren', + importRulesSupportingText: 'Ordne jede Spalte in deiner Tabelle einem Feld der Händlerregel zu. Wenn alles gut aussieht, klicke unten, um deine Regeln zu importieren.', + importColumnMerchantIs: 'Händler ist', + importColumnMerchantContains: 'Händler enthält', + importColumnUpdatedMerchant: 'Aktualisierter Händler', + importColumnUpdatedCategory: 'Aktualisierte Kategorie', + importColumnUpdatedTag: 'Aktualisiertes Tag', + importColumnUpdatedDescription: 'Aktualisierte Beschreibung', expensesWith: 'Für Ausgaben mit:', expensesExactlyMatching: 'Für Ausgaben mit genau folgender Übereinstimmung:', applyUpdates: 'Diese Aktualisierungen anwenden:', diff --git a/src/languages/en.ts b/src/languages/en.ts index 216a3d60c119..cfb00f4dcba2 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1231,6 +1231,13 @@ const translations = { importTagsSuccessfulDescription: ({tags}: {tags: number}) => (tags > 1 ? `${tags} tags have been added.` : '1 tag has been added.'), importMultiLevelTagsSuccessfulDescription: 'Multi-level tags have been added.', importPerDiemRatesSuccessfulDescription: ({rates}: {rates: number}) => (rates > 1 ? `${rates} per diem rates have been added.` : '1 per diem rate has been added.'), + importMerchantRulesSuccessfulDescription: ({rules}: {rules: number}) => { + if (rules === 0) { + return 'No merchant rules have been added, since they all already exist.'; + } + return rules > 1 ? `${rules} merchant rules have been added.` : '1 merchant rule has been added.'; + }, + importMerchantRulesRequiredColumns: 'Oops! You must map at least one "Merchant is" or "Merchant contains" column, plus at least one field to update. Please review and try again.', importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => transactions > 1 ? `${transactions} transactions have been imported.` : '1 transaction has been imported.', importFailedTitle: 'Import failed', @@ -7704,6 +7711,14 @@ const translations = { findRule: 'Find merchant rule', addRuleTitle: 'Add rule', editRuleTitle: 'Edit rule', + importRulesTitle: 'Import merchant rules', + importRulesSupportingText: 'Map each column in your spreadsheet to a merchant rule field. Once everything looks good, click below to import your rules.', + importColumnMerchantIs: 'Merchant is', + importColumnMerchantContains: 'Merchant contains', + importColumnUpdatedMerchant: 'Updated merchant', + importColumnUpdatedCategory: 'Updated category', + importColumnUpdatedTag: 'Updated tag', + importColumnUpdatedDescription: 'Updated description', expensesWith: 'For expenses with:', expensesExactlyMatching: 'For expenses exactly matching:', applyUpdates: 'Apply these updates:', diff --git a/src/languages/es.ts b/src/languages/es.ts index 9bf1359733ee..97b2cd29d12b 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1121,6 +1121,14 @@ const translations: TranslationDeepObject = { importTagsSuccessfulDescription: ({tags}) => (tags > 1 ? `Se han agregado ${tags} etiquetas.` : 'Se ha agregado 1 etiqueta.'), importMultiLevelTagsSuccessfulDescription: 'Etiquetas de nivel múltiple han sido agregadas.', importPerDiemRatesSuccessfulDescription: ({rates}) => (rates > 1 ? `Se han añadido ${rates} tasas de per diem.` : 'Se ha añadido 1 tasa de per diem.'), + importMerchantRulesSuccessfulDescription: ({rules}: {rules: number}) => { + if (rules === 0) { + return 'No se han añadido reglas de comerciante, ya que todas ya existen.'; + } + return rules > 1 ? `Se han añadido ${rules} reglas de comerciante.` : 'Se ha añadido 1 regla de comerciante.'; + }, + importMerchantRulesRequiredColumns: + '¡Ups! Debes asignar al menos una columna "El comerciante es" o "El comerciante contiene", además de al menos un campo para actualizar. Por favor, revisa e inténtalo de nuevo.', importTransactionsSuccessfulDescription: ({transactions}) => (transactions > 1 ? `Se han importado ${transactions} transacciones.` : 'Se ha importado 1 transacción.'), importCompanyCardTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => transactions > 1 ? `Se han importado ${transactions} transacciones.` : 'Se ha importado 1 transacción.', @@ -7398,6 +7406,15 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, findRule: 'Buscar regla de comerciante', addRuleTitle: 'Añadir regla', editRuleTitle: 'Editar regla', + importRulesTitle: 'Importar reglas de comerciante', + importRulesSupportingText: + 'Asigna cada columna de tu hoja de cálculo a un campo de la regla de comerciante. Cuando todo esté correcto, haz clic abajo para importar tus reglas.', + importColumnMerchantIs: 'El comerciante es', + importColumnMerchantContains: 'El comerciante contiene', + importColumnUpdatedMerchant: 'Comerciante actualizado', + importColumnUpdatedCategory: 'Categoría actualizada', + importColumnUpdatedTag: 'Etiqueta actualizada', + importColumnUpdatedDescription: 'Descripción actualizada', expensesWith: 'Para gastos con:', expensesExactlyMatching: 'Para gastos que coincidan exactamente con:', applyUpdates: 'Aplicar estas actualizaciones:', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 2f9ae12fa815..7a54c8362be7 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1165,6 +1165,14 @@ const translations: TranslationDeepObject = { importTagsSuccessfulDescription: ({tags}: {tags: number}) => (tags > 1 ? `${tags} tags ont été ajoutés.` : '1 tag a été ajouté.'), importMultiLevelTagsSuccessfulDescription: 'Des tags à plusieurs niveaux ont été ajoutés.', importPerDiemRatesSuccessfulDescription: ({rates}: {rates: number}) => (rates > 1 ? `${rates} indemnités journalières ont été ajoutées.` : '1 taux de per diem a été ajouté.'), + importMerchantRulesSuccessfulDescription: ({rules}: {rules: number}) => { + if (rules === 0) { + return "Aucune règle de marchand n'a été ajoutée, car elles existent toutes déjà."; + } + return rules > 1 ? `${rules} règles de marchand ont été ajoutées.` : '1 règle de marchand a été ajoutée.'; + }, + importMerchantRulesRequiredColumns: + 'Oups ! Vous devez associer au moins une colonne « Le marchand est » ou « Le marchand contient », ainsi qu’au moins un champ à mettre à jour. Veuillez examiner et réessayer.', importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => transactions > 1 ? `${transactions} transactions ont été importées.` : '1 transaction a été importée.', importFailedTitle: 'Échec de l’importation', @@ -7494,6 +7502,15 @@ Rendez obligatoires des informations de dépense comme les reçus et les descrip addRule: 'Ajouter une règle de marchand', addRuleTitle: 'Ajouter une règle', editRuleTitle: 'Modifier la règle', + importRulesTitle: 'Importer des règles de marchand', + importRulesSupportingText: + 'Faites correspondre chaque colonne de votre feuille de calcul à un champ de règle de marchand. Si tout semble correct, cliquez ci-dessous pour importer vos règles.', + importColumnMerchantIs: 'Le marchand est', + importColumnMerchantContains: 'Le marchand contient', + importColumnUpdatedMerchant: 'Marchand mis à jour', + importColumnUpdatedCategory: 'Catégorie mise à jour', + importColumnUpdatedTag: 'Tag mis à jour', + importColumnUpdatedDescription: 'Description mise à jour', expensesWith: 'Pour les dépenses avec :', expensesExactlyMatching: 'Pour les dépenses correspondant exactement :', applyUpdates: 'Appliquer ces mises à jour :', diff --git a/src/languages/it.ts b/src/languages/it.ts index 6e49ff0d79c0..a1b10e1b816f 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1162,6 +1162,13 @@ const translations: TranslationDeepObject = { importTagsSuccessfulDescription: ({tags}: {tags: number}) => (tags > 1 ? `Sono stati aggiunti ${tags} tag.` : 'È stato aggiunto 1 tag.'), importMultiLevelTagsSuccessfulDescription: 'Sono state aggiunte etichette multilivello.', importPerDiemRatesSuccessfulDescription: ({rates}: {rates: number}) => (rates > 1 ? `Sono state aggiunte le diarie giornaliere ${rates}.` : 'È stata aggiunta 1 diaria.'), + importMerchantRulesSuccessfulDescription: ({rules}: {rules: number}) => { + if (rules === 0) { + return 'Nessuna regola esercente è stata aggiunta, poiché esistono già tutte.'; + } + return rules > 1 ? `Sono state aggiunte ${rules} regole esercente.` : 'È stata aggiunta 1 regola esercente.'; + }, + importMerchantRulesRequiredColumns: 'Ops! Devi mappare almeno una colonna “L’esercente è” o “L’esercente contiene”, oltre ad almeno un campo da aggiornare. Controlla e riprova.', importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => transactions > 1 ? `${transactions} transazioni sono state importate.` : '1 transazione è stata importata.', importFailedTitle: 'Importazione non riuscita', @@ -7448,6 +7455,15 @@ Richiedi dettagli sulle spese come ricevute e descrizioni, imposta limiti e valo addRule: 'Aggiungi regola esercente', addRuleTitle: 'Aggiungi regola', editRuleTitle: 'Modifica regola', + importRulesTitle: 'Importa regole esercente', + importRulesSupportingText: + 'Mappa ogni colonna del tuo foglio di calcolo a un campo della regola esercente. Se ti sembra tutto corretto, fai clic qui sotto per importare le tue regole.', + importColumnMerchantIs: 'L’esercente è', + importColumnMerchantContains: 'L’esercente contiene', + importColumnUpdatedMerchant: 'Esercente aggiornato', + importColumnUpdatedCategory: 'Categoria aggiornata', + importColumnUpdatedTag: 'Tag aggiornato', + importColumnUpdatedDescription: 'Descrizione aggiornata', expensesWith: 'Per le spese con:', expensesExactlyMatching: 'Per le spese che corrispondono esattamente a:', applyUpdates: 'Applica questi aggiornamenti:', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 2bde735c0fe4..a1b4b3eab7a3 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1144,6 +1144,14 @@ const translations: TranslationDeepObject = { importTagsSuccessfulDescription: ({tags}: {tags: number}) => (tags > 1 ? `${tags} 個のタグを追加しました。` : 'タグを1件追加しました。'), importMultiLevelTagsSuccessfulDescription: 'マルチレベルタグが追加されました。', importPerDiemRatesSuccessfulDescription: ({rates}: {rates: number}) => (rates > 1 ? `${rates}件の日当レートが追加されました。` : '1件の日当レートが追加されました。'), + importMerchantRulesSuccessfulDescription: ({rules}: {rules: number}) => { + if (rules === 0) { + return 'すべての加盟店ルールが既に存在するため、追加されませんでした。'; + } + return rules > 1 ? `${rules}件の加盟店ルールが追加されました。` : '1件の加盟店ルールが追加されました。'; + }, + importMerchantRulesRequiredColumns: + 'おっと!「加盟店が一致」または「加盟店を含む」の列を少なくとも1つ、さらに更新するフィールドを少なくとも1つマッピングしてください。確認してもう一度お試しください。', importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => transactions > 1 ? `${transactions} 件の取引がインポートされました。` : '1件の取引がインポートされました。', importFailedTitle: 'インポートに失敗しました', @@ -7357,6 +7365,14 @@ ${reportName}`, addRule: '支払先ルールを追加', addRuleTitle: 'ルールを追加', editRuleTitle: 'ルールを編集', + importRulesTitle: '加盟店ルールをインポート', + importRulesSupportingText: 'スプレッドシートの各列を加盟店ルールのフィールドに対応付けてください。問題なければ、下をクリックしてルールをインポートしてください。', + importColumnMerchantIs: '加盟店が一致', + importColumnMerchantContains: '加盟店を含む', + importColumnUpdatedMerchant: '更新後の加盟店', + importColumnUpdatedCategory: '更新後のカテゴリ', + importColumnUpdatedTag: '更新後のタグ', + importColumnUpdatedDescription: '更新後の説明', expensesWith: '対象となる経費条件:', expensesExactlyMatching: '次の条件に完全一致する経費の場合:', applyUpdates: 'これらの更新を適用する', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 3bf61f9c238e..368a79a6bfa3 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1160,6 +1160,14 @@ const translations: TranslationDeepObject = { importTagsSuccessfulDescription: ({tags}: {tags: number}) => (tags > 1 ? `${tags} tags zijn toegevoegd.` : '1 tag is toegevoegd.'), importMultiLevelTagsSuccessfulDescription: 'Tags met meerdere niveaus zijn toegevoegd.', importPerDiemRatesSuccessfulDescription: ({rates}: {rates: number}) => (rates > 1 ? `${rates} per diem-tarieven zijn toegevoegd.` : '1 dagvergoeding is toegevoegd.'), + importMerchantRulesSuccessfulDescription: ({rules}: {rules: number}) => { + if (rules === 0) { + return 'Er zijn geen handelaarregels toegevoegd, omdat ze allemaal al bestaan.'; + } + return rules > 1 ? `${rules} handelaarregels zijn toegevoegd.` : '1 handelaarregel is toegevoegd.'; + }, + importMerchantRulesRequiredColumns: + 'Oeps! Je moet ten minste één kolom "Handelaar is" of "Handelaar bevat" toewijzen, plus ten minste één veld om bij te werken. Controleer het en probeer het opnieuw.', importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => transactions > 1 ? `${transactions} transacties zijn geïmporteerd.` : '1 transactie is geïmporteerd.', importFailedTitle: 'Import mislukt', @@ -7428,6 +7436,14 @@ Vereis onkostendetails zoals bonnen en beschrijvingen, stel limieten en standaar addRule: 'Merchantregel toevoegen', addRuleTitle: 'Regel toevoegen', editRuleTitle: 'Regel bewerken', + importRulesTitle: 'Handelaarregels importeren', + importRulesSupportingText: 'Koppel elke kolom in je spreadsheet aan een veld van een handelaarregel. Als alles er goed uitziet, klik hieronder om je regels te importeren.', + importColumnMerchantIs: 'Handelaar is', + importColumnMerchantContains: 'Handelaar bevat', + importColumnUpdatedMerchant: 'Bijgewerkte handelaar', + importColumnUpdatedCategory: 'Bijgewerkte categorie', + importColumnUpdatedTag: 'Bijgewerkte tag', + importColumnUpdatedDescription: 'Bijgewerkte omschrijving', expensesWith: 'Voor declaraties met:', expensesExactlyMatching: 'Voor onkosten die exact overeenkomen met:', applyUpdates: 'Pas deze updates toe:', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index b4c34c53be25..fe28ee1c3c75 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1157,6 +1157,14 @@ const translations: TranslationDeepObject = { importTagsSuccessfulDescription: ({tags}: {tags: number}) => (tags > 1 ? `Dodano ${tags} tagów.` : 'Dodano 1 znacznik.'), importMultiLevelTagsSuccessfulDescription: 'Dodano wielopoziomowe tagi.', importPerDiemRatesSuccessfulDescription: ({rates}: {rates: number}) => (rates > 1 ? `Dodano stawki diety: ${rates}.` : 'Dodano 1 stawkę diety.'), + importMerchantRulesSuccessfulDescription: ({rules}: {rules: number}) => { + if (rules === 0) { + return 'Nie dodano żadnych reguł sprzedawcy, ponieważ wszystkie już istnieją.'; + } + return rules > 1 ? `Dodano reguły sprzedawcy: ${rules}.` : 'Dodano 1 regułę sprzedawcy.'; + }, + importMerchantRulesRequiredColumns: + 'Ups! Musisz zmapować co najmniej jedną kolumnę „Sprzedawca to” lub „Sprzedawca zawiera” oraz co najmniej jedno pole do aktualizacji. Sprawdź i spróbuj ponownie.', importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => (transactions > 1 ? `Zaimportowano ${transactions} transakcje.` : 'Zaimportowano 1 transakcję.'), importFailedTitle: 'Import nieudany', importFailedDescription: 'Upewnij się, że wszystkie pola zostały poprawnie wypełnione i spróbuj ponownie. Jeśli problem będzie się powtarzał, skontaktuj się z Concierge.', @@ -7407,6 +7415,15 @@ Wymagaj szczegółów wydatków, takich jak paragony i opisy, ustawiaj limity i addRule: 'Dodaj regułę dla sprzedawcy', addRuleTitle: 'Dodaj regułę', editRuleTitle: 'Edytuj regułę', + importRulesTitle: 'Importuj reguły sprzedawcy', + importRulesSupportingText: + 'Zmapuj każdą kolumnę w arkuszu kalkulacyjnym do pola reguły sprzedawcy. Jeśli wszystko wygląda dobrze, kliknij poniżej, aby zaimportować swoje reguły.', + importColumnMerchantIs: 'Sprzedawca to', + importColumnMerchantContains: 'Sprzedawca zawiera', + importColumnUpdatedMerchant: 'Zaktualizowany sprzedawca', + importColumnUpdatedCategory: 'Zaktualizowana kategoria', + importColumnUpdatedTag: 'Zaktualizowany tag', + importColumnUpdatedDescription: 'Zaktualizowany opis', expensesWith: 'Dla wydatków z:', expensesExactlyMatching: 'Dla wydatków dokładnie pasujących do:', applyUpdates: 'Zastosuj te aktualizacje:', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 429816f1f138..5c5acc3c28d7 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1160,6 +1160,14 @@ const translations: TranslationDeepObject = { importTagsSuccessfulDescription: ({tags}: {tags: number}) => (tags > 1 ? `${tags} tags foram adicionadas.` : '1 tag foi adicionada.'), importMultiLevelTagsSuccessfulDescription: 'Tags de vários níveis foram adicionadas.', importPerDiemRatesSuccessfulDescription: ({rates}: {rates: number}) => (rates > 1 ? `Foram adicionadas ${rates} diárias.` : '1 diária foi adicionada.'), + importMerchantRulesSuccessfulDescription: ({rules}: {rules: number}) => { + if (rules === 0) { + return 'Nenhuma regra de comerciante foi adicionada, pois todas já existem.'; + } + return rules > 1 ? `Foram adicionadas ${rules} regras de comerciante.` : '1 regra de comerciante foi adicionada.'; + }, + importMerchantRulesRequiredColumns: + 'Ops! Você precisa mapear pelo menos uma coluna "O comerciante é" ou "O comerciante contém", além de pelo menos um campo para atualizar. Revise e tente novamente.', importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => transactions > 1 ? `${transactions} transações foram importadas.` : '1 transação foi importada.', importFailedTitle: 'Falha na importação', @@ -7420,6 +7428,14 @@ Exija dados de despesas como recibos e descrições, defina limites e padrões e addRule: 'Adicionar regra de comerciante', addRuleTitle: 'Adicionar regra', editRuleTitle: 'Editar regra', + importRulesTitle: 'Importar regras de comerciante', + importRulesSupportingText: 'Mapeie cada coluna da sua planilha para um campo de regra de comerciante. Se tudo estiver certo, clique abaixo para importar suas regras.', + importColumnMerchantIs: 'O comerciante é', + importColumnMerchantContains: 'O comerciante contém', + importColumnUpdatedMerchant: 'Comerciante atualizado', + importColumnUpdatedCategory: 'Categoria atualizada', + importColumnUpdatedTag: 'Tag atualizada', + importColumnUpdatedDescription: 'Descrição atualizada', expensesWith: 'Para despesas com:', expensesExactlyMatching: 'Para despesas que correspondam exatamente:', applyUpdates: 'Aplicar estas atualizações:', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 286066528d33..f165fa686c26 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1116,6 +1116,13 @@ const translations: TranslationDeepObject = { importTagsSuccessfulDescription: ({tags}: {tags: number}) => (tags > 1 ? `已添加 ${tags} 个标签。` : '已添加 1 个标签。'), importMultiLevelTagsSuccessfulDescription: '已添加多级标签。', importPerDiemRatesSuccessfulDescription: ({rates}: {rates: number}) => (rates > 1 ? `已添加 ${rates} 个每日补贴标准。` : '已添加 1 条日津贴费率。'), + importMerchantRulesSuccessfulDescription: ({rules}: {rules: number}) => { + if (rules === 0) { + return '未添加任何商户规则,因为它们均已存在。'; + } + return rules > 1 ? `已添加 ${rules} 条商户规则。` : '已添加 1 条商户规则。'; + }, + importMerchantRulesRequiredColumns: '哎呀!您必须至少映射一列“商户为”或“商户包含”,并且至少映射一个要更新的字段。请检查后重试。', importTransactionsSuccessfulDescription: ({transactions}: {transactions: number}) => (transactions > 1 ? `已导入 ${transactions} 笔交易。` : '已导入 1 笔交易。'), importFailedTitle: '导入失败', importFailedDescription: '请确保所有字段均已正确填写,然后重试。如果问题仍然存在,请联系 Concierge。', @@ -7207,6 +7214,14 @@ ${reportName}`, addRule: '添加商家规则', addRuleTitle: '添加规则', editRuleTitle: '编辑规则', + importRulesTitle: '导入商户规则', + importRulesSupportingText: '将电子表格中的每一列映射到商户规则的字段。如果一切看起来都没问题,请点击下方以导入您的规则。', + importColumnMerchantIs: '商户为', + importColumnMerchantContains: '商户包含', + importColumnUpdatedMerchant: '更新后的商户', + importColumnUpdatedCategory: '更新后的类别', + importColumnUpdatedTag: '更新后的标签', + importColumnUpdatedDescription: '更新后的描述', expensesWith: '针对以下费用:', expensesExactlyMatching: '对于完全匹配以下条件的报销:', applyUpdates: '应用这些更新:', diff --git a/src/libs/API/parameters/ImportMerchantRulesSpreadsheet.ts b/src/libs/API/parameters/ImportMerchantRulesSpreadsheet.ts new file mode 100644 index 000000000000..52ef8cb3505b --- /dev/null +++ b/src/libs/API/parameters/ImportMerchantRulesSpreadsheet.ts @@ -0,0 +1,12 @@ +type ImportMerchantRulesSpreadsheetParams = { + /** ID of the policy */ + policyID: string; + + /** + * Stringified JSON object mapping client-generated ruleID to coding rule value: + * Record + */ + rules: string; +}; + +export default ImportMerchantRulesSpreadsheetParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index c7d8933002e4..bec01cd550f3 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -426,6 +426,7 @@ export type {default as RemovePolicyCategoryItemizedReceiptsRequiredParams} from export type {default as UpdateQuickbooksOnlineAutoCreateVendorParams} from './UpdateQuickbooksOnlineAutoCreateVendorParams'; export type {default as ImportCategoriesSpreadsheetParams} from './ImportCategoriesSpreadsheet'; export type {default as ImportMembersSpreadsheetParams} from './ImportMembersSpreadsheet'; +export type {default as ImportMerchantRulesSpreadsheetParams} from './ImportMerchantRulesSpreadsheet'; export type {default as ExportMembersSpreadsheetParams} from './ExportMembersSpreadsheetParams'; export type {default as ImportTagsSpreadsheetParams} from './ImportTagsSpreadsheet'; export type {default as ExportCategoriesSpreadsheetParams} from './ExportCategoriesSpreadsheet'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index a8844655049c..11a93015c619 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -1541,6 +1541,7 @@ const SIDE_EFFECT_REQUEST_COMMANDS = { GET_MISSING_ONYX_MESSAGES: 'GetMissingOnyxMessages', IMPORT_CATEGORIES_SPREADSHEET: 'ImportCategoriesSpreadsheet', IMPORT_MEMBERS_SPREADSHEET: 'ImportMembersSpreadsheet', + IMPORT_MERCHANT_RULES_SPREADSHEET: 'ImportMerchantRulesSpreadsheet', IMPORT_PER_DIEM_RATES: 'ImportPerDiemRates', IMPORT_TAGS_SPREADSHEET: 'ImportTagsSpreadsheet', OPEN_OLD_DOT_LINK: 'OpenOldDotLink', @@ -1586,6 +1587,7 @@ type SideEffectRequestCommandParameters = { [SIDE_EFFECT_REQUEST_COMMANDS.AUTHENTICATE_PUSHER]: Parameters.AuthenticatePusherParams; [SIDE_EFFECT_REQUEST_COMMANDS.IMPORT_CATEGORIES_SPREADSHEET]: Parameters.ImportCategoriesSpreadsheetParams; [SIDE_EFFECT_REQUEST_COMMANDS.IMPORT_MEMBERS_SPREADSHEET]: Parameters.ImportMembersSpreadsheetParams; + [SIDE_EFFECT_REQUEST_COMMANDS.IMPORT_MERCHANT_RULES_SPREADSHEET]: Parameters.ImportMerchantRulesSpreadsheetParams; [SIDE_EFFECT_REQUEST_COMMANDS.IMPORT_PER_DIEM_RATES]: Parameters.ImportPerDiemRatesParams; [SIDE_EFFECT_REQUEST_COMMANDS.IMPORT_TAGS_SPREADSHEET]: Parameters.ImportTagsSpreadsheetParams; [SIDE_EFFECT_REQUEST_COMMANDS.OPEN_OLD_DOT_LINK]: Parameters.OpenOldDotLinkParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index c622eb827269..d7d0a0157452 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -1026,6 +1026,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/rules/RulesProhibitedDefaultPage').default, [SCREENS.WORKSPACE.RULES_NEW]: () => require('../../../../pages/workspace/rules/RulesNewPage').default, [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: () => require('../../../../pages/workspace/rules/MerchantRules/AddMerchantRulePage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORT]: () => require('../../../../pages/workspace/rules/MerchantRules/ImportMerchantRulesPage').default, + [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORTED]: () => require('../../../../pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage').default, [SCREENS.WORKSPACE.RULES_SPEND_NEW]: () => require('../../../../pages/workspace/rules/SpendRules/AddSpendRulePage').default, [SCREENS.WORKSPACE.RULES_REQUIRE_FIELDS_RULE_NEW]: () => require('../../../../pages/workspace/rules/RequireFieldsRules/AddRequireFieldsRulePage').default, [SCREENS.WORKSPACE.RULES_REQUIRE_FIELDS_RULE_EDIT]: () => require('../../../../pages/workspace/rules/RequireFieldsRules/EditRequireFieldsRulePage').default, diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts index 0e5ffec4a561..37a38365dcd1 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts @@ -376,6 +376,8 @@ const WORKSPACE_TO_RHP: Partial['config'] = { [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { path: ROUTES.RULES_MERCHANT_NEW.route, }, + [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORT]: { + path: ROUTES.RULES_MERCHANT_IMPORT.route, + }, + [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORTED]: { + path: ROUTES.RULES_MERCHANT_IMPORTED.route, + }, [SCREENS.WORKSPACE.RULES_SPEND_NEW]: { path: ROUTES.RULES_SPEND_NEW.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 6303a525a220..24a2720e08ec 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1542,6 +1542,12 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.RULES_MERCHANT_NEW]: { policyID: string; }; + [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORT]: { + policyID: string; + }; + [SCREENS.WORKSPACE.RULES_MERCHANT_IMPORTED]: { + policyID: string; + }; [SCREENS.WORKSPACE.RULES_SPEND_NEW]: { policyID: string; }; diff --git a/src/libs/actions/Policy/Rules.ts b/src/libs/actions/Policy/Rules.ts index 499592f7d956..7dde67ddfb7f 100644 --- a/src/libs/actions/Policy/Rules.ts +++ b/src/libs/actions/Policy/Rules.ts @@ -1,8 +1,9 @@ +import {getImportFailedFinalModal} from '@libs/actions/ImportSpreadsheet'; import * as API from '@libs/API'; -import type {AddPolicyAgentRuleParams, DeletePolicyAgentRuleParams, UpdatePolicyAgentRuleParams} from '@libs/API/parameters'; +import type {AddPolicyAgentRuleParams, DeletePolicyAgentRuleParams, ImportMerchantRulesSpreadsheetParams, UpdatePolicyAgentRuleParams} from '@libs/API/parameters'; import type OpenPolicyRulesPageParams from '@libs/API/parameters/OpenPolicyRulesPageParams'; import type SetPolicyCodingRuleParams from '@libs/API/parameters/SetPolicyCodingRuleParams'; -import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import Log from '@libs/Log'; import * as NumberUtils from '@libs/NumberUtils'; @@ -11,6 +12,7 @@ import Parser from '@libs/Parser'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {MerchantRuleForm} from '@src/types/form'; +import type {ImportFinalModal} from '@src/types/onyx/ImportedSpreadsheet'; import type Policy from '@src/types/onyx/Policy'; import type {AgentRule, CodingRule, CodingRuleFilter, CodingRuleTax} from '@src/types/onyx/Policy'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -19,6 +21,9 @@ import type {OnyxUpdate} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +/** A coding rule parsed from an imported spreadsheet row, keyed by a client-generated ruleID */ +type ImportedMerchantRule = Omit; + /** * Builds the tax object from a tax key and policy */ @@ -231,6 +236,39 @@ function setPolicyCodingRule(policyID: string, form: MerchantRuleForm, policy: P API.write(WRITE_COMMANDS.SET_POLICY_CODING_RULE, parameters, onyxData); } +/** + * Imports coding rules parsed from a spreadsheet into the given policy in bulk + * @param policyID - The ID of the policy to import the rules into + * @param rules - Coding rule values keyed by client-generated ruleID + */ +async function importMerchantRulesSpreadsheet(policyID: string, rules: Record): Promise { + // The API rejects an empty rules object, so fail fast when the spreadsheet produced no importable rules + if (Object.keys(rules).length === 0) { + return getImportFailedFinalModal(); + } + + const importFinalModal: ImportFinalModal = { + titleKey: 'spreadsheet.importSuccessfulTitle', + promptKey: 'spreadsheet.importMerchantRulesSuccessfulDescription', + promptKeyParams: {rules: Object.keys(rules).length}, + }; + + const parameters: ImportMerchantRulesSpreadsheetParams = { + policyID, + rules: JSON.stringify(rules), + }; + + try { + // We need the server result immediately so the initiating page can show the final confirmation modal + // without storing transient modal state in Onyx. + // eslint-disable-next-line rulesdir/no-api-side-effects-method + const response = await API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.IMPORT_MERCHANT_RULES_SPREADSHEET, parameters); + return response?.jsonCode === CONST.JSON_CODE.SUCCESS ? importFinalModal : getImportFailedFinalModal(); + } catch { + return getImportFailedFinalModal(); + } +} + function getTransactionsMatchingCodingRule(policyID: string, filters: CodingRuleFilter) { const optimisticData: Array> = [ { @@ -594,6 +632,7 @@ function clearPolicyAgentRuleErrors(policyID: string, agentRuleID: string, agent export { openPolicyRulesPage, setPolicyCodingRule, + importMerchantRulesSpreadsheet, deletePolicyCodingRule, getTransactionsMatchingCodingRule, addPolicyAgentRule, @@ -602,3 +641,4 @@ export { clearPolicyCodingRuleErrors, clearPolicyAgentRuleErrors, }; +export type {ImportedMerchantRule}; diff --git a/src/pages/workspace/rules/MerchantRules/ImportMerchantRulesPage.tsx b/src/pages/workspace/rules/MerchantRules/ImportMerchantRulesPage.tsx new file mode 100644 index 000000000000..092f982d48be --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/ImportMerchantRulesPage.tsx @@ -0,0 +1,35 @@ +import ImportSpreadsheet from '@components/ImportSpreadsheet'; + +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import React from 'react'; + +type ImportMerchantRulesPageProps = PlatformStackScreenProps; + +function ImportMerchantRulesPage({route}: ImportMerchantRulesPageProps) { + const policyID = route.params.policyID; + + return ( + + + + ); +} + +export default ImportMerchantRulesPage; diff --git a/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx b/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx new file mode 100644 index 000000000000..ef39b9e92432 --- /dev/null +++ b/src/pages/workspace/rules/MerchantRules/ImportedMerchantRulesPage.tsx @@ -0,0 +1,266 @@ +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import type {ColumnRole} from '@components/ImportColumn'; +import ImportSpreadsheetColumns from '@components/ImportSpreadsheetColumns'; +import ScreenWrapper from '@components/ScreenWrapper'; + +import useCloseImportPage from '@hooks/useCloseImportPage'; +import useImportSpreadsheetConfirmModal from '@hooks/useImportSpreadsheetConfirmModal'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePolicy from '@hooks/usePolicy'; + +import type {ImportedMerchantRule} from '@libs/actions/Policy/Rules'; +import {importMerchantRulesSpreadsheet} from '@libs/actions/Policy/Rules'; +import {findDuplicate, generateColumnNames} from '@libs/importSpreadsheetUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import {rand64} from '@libs/NumberUtils'; +import Parser from '@libs/Parser'; + +import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; +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 {ImportFinalModal} from '@src/types/onyx/ImportedSpreadsheet'; +import type {Errors} from '@src/types/onyx/OnyxCommon'; +import type {CodingRule} from '@src/types/onyx/Policy'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +import React, {useState} from 'react'; + +/** Column roles that update the matched expense; at least one must be mapped alongside a merchant filter */ +const ACTION_COLUMNS: string[] = [ + CONST.CSV_IMPORT_COLUMNS.UPDATED_MERCHANT, + CONST.CSV_IMPORT_COLUMNS.CATEGORY, + CONST.CSV_IMPORT_COLUMNS.TAG, + CONST.CSV_IMPORT_COLUMNS.COMMENT, + CONST.CSV_IMPORT_COLUMNS.REIMBURSABLE, + CONST.CSV_IMPORT_COLUMNS.BILLABLE, +]; + +/** + * Serializes the importable fields of a rule so identical rules can be detected. Used to skip + * spreadsheet rows that would recreate a rule the policy already has (e.g. the same spreadsheet + * imported twice) as well as duplicate rows within the same spreadsheet. + */ +function getRuleContentKey(rule: Pick): string { + return JSON.stringify([ + rule.filters.operator, + rule.filters.right.toLowerCase(), + rule.merchant ?? '', + rule.category ?? '', + rule.tag ?? '', + rule.comment ?? '', + rule.reimbursable ?? null, + rule.billable ?? null, + ]); +} + +/** Parses a CSV cell into a boolean, or undefined when the cell is empty or unrecognized so the field is left unset */ +function parseCsvBooleanValue(raw: string | undefined): boolean | undefined { + const trimmed = raw?.trim().toLowerCase() ?? ''; + if (['true', 'yes'].includes(trimmed)) { + return true; + } + if (['false', 'no'].includes(trimmed)) { + return false; + } + return undefined; +} + +type ImportedMerchantRulesPageProps = PlatformStackScreenProps; + +function ImportedMerchantRulesPage({route}: ImportedMerchantRulesPageProps) { + const {translate} = useLocalize(); + const [spreadsheet, spreadsheetMetadata] = useOnyx(ONYXKEYS.IMPORTED_SPREADSHEET); + const [isImportingRules, setIsImportingRules] = useState(false); + const {containsHeader = true} = spreadsheet ?? {}; + const [isValidationEnabled, setIsValidationEnabled] = useState(false); + const policyID = route.params.policyID; + const policy = usePolicy(policyID); + + const {setIsClosing} = useCloseImportPage(); + const showImportSpreadsheetConfirmModal = useImportSpreadsheetConfirmModal(); + + const columnNames = generateColumnNames(spreadsheet?.data?.length ?? 0); + + const columnRoles: ColumnRole[] = [ + {text: translate('common.ignore'), value: CONST.CSV_IMPORT_COLUMNS.IGNORE}, + {text: translate('workspace.rules.merchantRules.importColumnMerchantIs'), value: CONST.CSV_IMPORT_COLUMNS.MERCHANT_IS}, + {text: translate('workspace.rules.merchantRules.importColumnMerchantContains'), value: CONST.CSV_IMPORT_COLUMNS.MERCHANT_CONTAINS}, + {text: translate('workspace.rules.merchantRules.importColumnUpdatedMerchant'), value: CONST.CSV_IMPORT_COLUMNS.UPDATED_MERCHANT}, + {text: translate('workspace.rules.merchantRules.importColumnUpdatedCategory'), value: CONST.CSV_IMPORT_COLUMNS.CATEGORY}, + {text: translate('workspace.rules.merchantRules.importColumnUpdatedTag'), value: CONST.CSV_IMPORT_COLUMNS.TAG}, + {text: translate('workspace.rules.merchantRules.importColumnUpdatedDescription'), value: CONST.CSV_IMPORT_COLUMNS.COMMENT}, + {text: translate('common.reimbursable'), value: CONST.CSV_IMPORT_COLUMNS.REIMBURSABLE}, + {text: translate('common.billable'), value: CONST.CSV_IMPORT_COLUMNS.BILLABLE}, + ]; + + const validate = () => { + const columns = Object.values(spreadsheet?.columns ?? {}); + let errors: Errors = {}; + + const hasMerchantFilterColumn = columns.includes(CONST.CSV_IMPORT_COLUMNS.MERCHANT_IS) || columns.includes(CONST.CSV_IMPORT_COLUMNS.MERCHANT_CONTAINS); + const hasActionColumn = ACTION_COLUMNS.some((actionColumn) => columns.includes(actionColumn)); + + if (!hasMerchantFilterColumn || !hasActionColumn) { + errors.required = translate('spreadsheet.importMerchantRulesRequiredColumns'); + } else { + const duplicate = findDuplicate(columns); + const duplicateColumn = columnRoles.find((role) => role.value === duplicate); + + if (duplicateColumn) { + errors.duplicates = translate('spreadsheet.singleFieldMultipleColumns', duplicateColumn.text); + } else { + errors = {}; + } + } + return errors; + }; + + const closeImportPageAndModal = () => { + setIsClosing(true); + setIsImportingRules(false); + Navigation.goBack(ROUTES.WORKSPACE_RULES.getRoute(policyID)); + }; + + const importRules = async () => { + setIsValidationEnabled(true); + const errors = validate(); + if (Object.keys(errors).length > 0) { + return; + } + + const columns = Object.values(spreadsheet?.columns ?? {}); + const merchantIsColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.MERCHANT_IS); + const merchantContainsColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.MERCHANT_CONTAINS); + const updatedMerchantColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.UPDATED_MERCHANT); + const categoryColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.CATEGORY); + const tagColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.TAG); + const commentColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.COMMENT); + const reimbursableColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.REIMBURSABLE); + const billableColumn = columns.findIndex((column) => column === CONST.CSV_IMPORT_COLUMNS.BILLABLE); + + const rowCount = (spreadsheet?.data.at(0)?.length ?? 0) - (containsHeader ? 1 : 0); + const getCellValue = (columnIndex: number, rowIndex: number): string => { + if (columnIndex === -1) { + return ''; + } + const dataIndex = containsHeader ? rowIndex + 1 : rowIndex; + return spreadsheet?.data.at(columnIndex)?.at(dataIndex)?.toString().trim() ?? ''; + }; + + // Seed the duplicate check with the policy's current rules so re-importing a spreadsheet doesn't recreate them + const seenRuleKeys = new Set( + Object.values(policy?.rules?.codingRules ?? {}) + .filter((rule) => rule.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && rule.filters?.right) + .map(getRuleContentKey), + ); + let skippedDuplicateCount = 0; + + const rules: Record = {}; + for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { + // "Merchant is" wins when both filter columns have a value for the same row + const merchantIsValue = getCellValue(merchantIsColumn, rowIndex); + const merchantToMatch = merchantIsValue || getCellValue(merchantContainsColumn, rowIndex); + if (!merchantToMatch) { + continue; + } + + const updatedMerchant = getCellValue(updatedMerchantColumn, rowIndex); + const category = getCellValue(categoryColumn, rowIndex); + const tag = getCellValue(tagColumn, rowIndex); + const comment = getCellValue(commentColumn, rowIndex); + const reimbursable = parseCsvBooleanValue(getCellValue(reimbursableColumn, rowIndex)); + const billable = parseCsvBooleanValue(getCellValue(billableColumn, rowIndex)); + + // Skip rows where every action cell is empty since the resulting rule would never change anything + if (!updatedMerchant && !category && !tag && !comment && reimbursable === undefined && billable === undefined) { + continue; + } + + const rule: ImportedMerchantRule = { + filters: { + left: 'merchant', + operator: merchantIsValue ? CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO : CONST.SEARCH.SYNTAX_OPERATORS.CONTAINS, + right: merchantToMatch, + }, + ...(updatedMerchant && {merchant: updatedMerchant}), + ...(category && {category}), + ...(tag && {tag}), + ...(comment && {comment: Parser.replace(comment)}), + ...(reimbursable !== undefined && {reimbursable}), + ...(billable !== undefined && {billable}), + created: new Date().toISOString(), + }; + + const ruleKey = getRuleContentKey(rule); + if (seenRuleKeys.has(ruleKey)) { + skippedDuplicateCount++; + continue; + } + seenRuleKeys.add(ruleKey); + + rules[rand64()] = rule; + } + + setIsImportingRules(true); + // When every row duplicates an existing rule, skip the API call and confirm that nothing was added + const importFinalModal: ImportFinalModal = + Object.keys(rules).length === 0 && skippedDuplicateCount > 0 + ? {titleKey: 'spreadsheet.importSuccessfulTitle', promptKey: 'spreadsheet.importMerchantRulesSuccessfulDescription', promptKeyParams: {rules: 0}} + : await importMerchantRulesSpreadsheet(policyID, rules); + const didShowImportFinalModal = await showImportSpreadsheetConfirmModal(importFinalModal, {shouldHandleNavigationBack: false}); + if (!didShowImportFinalModal) { + setIsImportingRules(false); + return; + } + closeImportPageAndModal(); + }; + + if (!spreadsheet && isLoadingOnyxValue(spreadsheetMetadata)) { + return null; + } + + const spreadsheetColumns = spreadsheet?.data; + + if (!spreadsheetColumns) { + return ; + } + + return ( + + + Navigation.goBack(ROUTES.RULES_MERCHANT_IMPORT.getRoute(policyID))} + /> + + + + ); +} + +export default ImportedMerchantRulesPage; diff --git a/src/pages/workspace/rules/PolicyRulesPage.tsx b/src/pages/workspace/rules/PolicyRulesPage.tsx index 59b8e8c87925..3628e417bf5e 100644 --- a/src/pages/workspace/rules/PolicyRulesPage.tsx +++ b/src/pages/workspace/rules/PolicyRulesPage.tsx @@ -1,13 +1,16 @@ import AgentPromotionalBanner from '@components/AgentPromotionalBanner'; +import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; +import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import SpendRulesSection from '@components/SpendRules/SpendRulesSection'; -import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import usePolicyFeatureWriteAccess from '@hooks/usePolicyFeatureWriteAccess'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; @@ -25,6 +28,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type DismissedProductTraining from '@src/types/onyx/DismissedProductTraining'; +import type DeepValueOf from '@src/types/utils/DeepValueOf'; import type {OnyxEntry} from 'react-native-onyx'; @@ -32,6 +36,7 @@ import React, {useCallback, useEffect} from 'react'; import {View} from 'react-native'; import AgentRulesSection from './AgentRulesSection'; +import getImportMerchantRulesOption from './getImportMerchantRulesOption'; import IndividualExpenseRulesSection from './IndividualExpenseRulesSection'; import MerchantRulesSection from './MerchantRulesSection'; import PolicyRulesPageRevamp from './PolicyRulesPageRevamp'; @@ -48,7 +53,9 @@ function PolicyRulesPage(props: PolicyRulesPageProps) { useWorkspaceDocumentTitle(policy?.name, 'workspace.common.rules'); const styles = useThemeStyles(); const {shouldUseNarrowLayout} = useResponsiveLayout(); + const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); const illustrations = useMemoizedLazyIllustrations(['Rules']); + const icons = useMemoizedLazyExpensifyIcons(['Table']); const {canWrite: canWriteRules, showReadOnlyModal, withReadOnlyFallback} = usePolicyFeatureWriteAccess(policy, CONST.POLICY.POLICY_FEATURE.RULES); const {isBetaEnabled} = usePermissions(); const isRulesRevampEnabled = isBetaEnabled(CONST.BETAS.RULES_REVAMP); @@ -71,6 +78,23 @@ function PolicyRulesPage(props: PolicyRulesPageProps) { return ; } + const moreOptions: Array>> = [ + getImportMerchantRulesOption({policyID, canWriteRules, showReadOnlyModal, translate, icon: icons.Table}), + ]; + + const headerButtons = ( + {}} + shouldAlwaysShowDropdownMenu + customText={translate('common.more')} + options={moreOptions} + isSplitButton={false} + wrapperStyle={styles.flexGrow0} + style={[shouldDisplayButtonsInSeparateLine && styles.w100]} + sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.RULES.IMPORT_MERCHANT_RULES} + /> + ); + return ( + {shouldDisplayButtonsInSeparateLine && {headerButtons}} {isCustomAgentBetaEnabled && !isAgentsRulesBannerDismissed && ( + ); + } + + const moreOptions: Array>> = [ + getImportMerchantRulesOption({policyID, canWriteRules, showReadOnlyModal, translate, icon: icons.Table}), + ]; + return ( -