diff --git a/lang/es.json b/lang/es.json index 9c21139f..0779dbbb 100644 --- a/lang/es.json +++ b/lang/es.json @@ -804,8 +804,10 @@ "Manage your profile and account settings": "Gestiona tu perfil y configuración de cuenta", "Manage your subscription, update payment methods, or view invoices through the Stripe billing portal.": "Gestiona tu suscripción, actualiza métodos de pago o consulta facturas a través del portal de facturación de Stripe.", "Manage your subscription, update payment methods, or\\n view invoices through the Stripe billing portal.": "Gestiona tu suscripción, actualiza métodos de pago o consulta facturas a través del portal de facturación de Stripe.", + "Manage rules that categorize transactions and add labels automatically": "Gestiona reglas que categorizan transacciones y agregan etiquetas automáticamente", "Manage your transaction automation rules": "Gestiona tus reglas de automatización de transacciones", "Manage your transaction automation rules. Rules will be applied to categorize transactions automatically.": "Gestiona tus reglas de automatización de transacciones. Las reglas se aplicarán para categorizar transacciones automáticamente.", + "Manage your transaction automation rules. Rules will categorize transactions and add labels automatically.": "Gestiona tus reglas de automatización de transacciones. Las reglas categorizarán transacciones y agregarán etiquetas automáticamente.", "Manage your transaction categories": "Gestiona tus categorías de transacciones", "Manage your transaction labels": "Gestiona tus etiquetas de transacciones", "Manage your two-factor authentication settings": "Gestiona tu configuración de autenticación de dos factores", @@ -1127,6 +1129,7 @@ "Select date column": "Seleccionar columna de fecha", "Select description column": "Seleccionar columna de descripción", "Select invested amount column (optional)": "Seleccionar columna de cantidad invertida (opcional)", + "Select labels": "Seleccionar etiquetas", "Select labels (optional)": "Seleccionar etiquetas (opcional)", "Select labels...": "Seleccionar etiquetas...", "Select language": "Seleccionar idioma", diff --git a/resources/js/components/automation-rules/automation-rule-action-badges.tsx b/resources/js/components/automation-rules/automation-rule-action-badges.tsx new file mode 100644 index 00000000..0beb2997 --- /dev/null +++ b/resources/js/components/automation-rules/automation-rule-action-badges.tsx @@ -0,0 +1,51 @@ +import { LabelBadges } from '@/components/shared/label-combobox'; +import { Badge } from '@/components/ui/badge'; +import { type AutomationRule, getRuleActions } from '@/types/automation-rule'; +import { getCategoryColorClasses } from '@/types/category'; +import { __ } from '@/utils/i18n'; +import * as Icons from 'lucide-react'; + +export function AutomationRuleActionBadges({ rule }: { rule: AutomationRule }) { + const actions = getRuleActions(rule); + const hasLabels = actions.hasLabels && actions.labels?.length; + + if (!actions.category && !hasLabels && !actions.hasNote) { + return ( + + {__('No action set')} + + ); + } + + return ( +
+ {actions.category && } + + {hasLabels && } + + {actions.hasNote && ( + {__('Add note')} + )} +
+ ); +} + +function CategoryBadge({ + category, +}: { + category: NonNullable['category']>; +}) { + const colorClasses = getCategoryColorClasses(category.color); + const IconComponent = Icons[ + category.icon as keyof typeof Icons + ] as Icons.LucideIcon; + + return ( + + {IconComponent && } + {category.name} + + ); +} diff --git a/resources/js/components/automation-rules/automation-rules-dialog.tsx b/resources/js/components/automation-rules/automation-rules-dialog.tsx index 87835ad9..62cd8c0e 100644 --- a/resources/js/components/automation-rules/automation-rules-dialog.tsx +++ b/resources/js/components/automation-rules/automation-rules-dialog.tsx @@ -12,14 +12,13 @@ import { useReactTable, VisibilityState, } from '@tanstack/react-table'; -import * as Icons from 'lucide-react'; import { MoreHorizontal } from 'lucide-react'; import { useMemo, useState } from 'react'; +import { AutomationRuleActionBadges } from '@/components/automation-rules/automation-rule-action-badges'; import { CreateAutomationRuleDialog } from '@/components/automation-rules/create-automation-rule-dialog'; import { DeleteAutomationRuleDialog } from '@/components/automation-rules/delete-automation-rule-dialog'; import { EditAutomationRuleDialog } from '@/components/automation-rules/edit-automation-rule-dialog'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { ContextMenu, @@ -51,8 +50,9 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; -import { type AutomationRule, getRuleActions } from '@/types/automation-rule'; -import { type Category, getCategoryColorClasses } from '@/types/category'; +import { type AutomationRule } from '@/types/automation-rule'; +import { type Category } from '@/types/category'; +import { type Label } from '@/types/label'; import { __ } from '@/utils/i18n'; interface AutomationRulesDialogProps { @@ -63,9 +63,11 @@ interface AutomationRulesDialogProps { function AutomationRuleActions({ rule, categories, + labels, }: { rule: AutomationRule; categories: Category[]; + labels: Label[]; }) { const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); @@ -100,6 +102,7 @@ function AutomationRuleActions({ @@ -115,9 +118,11 @@ function AutomationRuleActions({ function AutomationRuleRow({ row, categories, + labels, }: { row: Row; categories: Category[]; + labels: Label[]; }) { const rule = row.original; const [editOpen, setEditOpen] = useState(false); @@ -163,6 +168,7 @@ function AutomationRuleRow({ @@ -183,8 +189,8 @@ export function AutomationRulesDialog({ automationRules: AutomationRule[]; }>().props; - // Get categories from globally shared Inertia data const categories = usePage().props.categories as Category[]; + const labels = usePage().props.labels as Label[]; const rules = useMemo( () => rawRules.map((rule) => ({ @@ -216,54 +222,7 @@ export function AutomationRulesDialog({ id: 'actions_display', header: __('Actions'), cell: ({ row }) => { - const rule = row.original; - const actions = getRuleActions(rule); - - if (actions.type === 'multiple') { - return ( -
- {actions.category && ( - - {(() => { - const IconComponent = Icons[ - actions.category - .icon as keyof typeof Icons - ] as Icons.LucideIcon; - return IconComponent ? ( - - ) : null; - })()} - {actions.category.name} - - )} -
- ); - } - - if (actions.type === 'category' && actions.category) { - const colorClasses = getCategoryColorClasses( - actions.category.color, - ); - const IconComponent = Icons[ - actions.category.icon as keyof typeof Icons - ] as Icons.LucideIcon; - return ( - - - {actions.category.name} - - ); - } - - return ( - - {__('No action set')} - - ); + return ; }, }, { @@ -273,6 +232,7 @@ export function AutomationRulesDialog({ ), }, @@ -301,7 +261,7 @@ export function AutomationRulesDialog({ {__('Automation Rules')} {__( - 'Manage your transaction automation rules. Rules will be applied to categorize transactions automatically.', + 'Manage your transaction automation rules. Rules will categorize transactions and add labels automatically.', )} @@ -322,7 +282,10 @@ export function AutomationRulesDialog({ } className="max-w-sm" /> - +
@@ -356,6 +319,7 @@ export function AutomationRulesDialog({ key={row.id} row={row} categories={categories} + labels={labels} /> )) ) : ( diff --git a/resources/js/components/automation-rules/create-automation-rule-dialog.tsx b/resources/js/components/automation-rules/create-automation-rule-dialog.tsx index b3ed8644..bd66e7a4 100644 --- a/resources/js/components/automation-rules/create-automation-rule-dialog.tsx +++ b/resources/js/components/automation-rules/create-automation-rule-dialog.tsx @@ -1,6 +1,7 @@ import { store } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController'; import { RuleBuilder } from '@/components/automation-rules/rule-builder'; import { CategoryCombobox } from '@/components/shared/category-combobox'; +import { LabelCombobox } from '@/components/shared/label-combobox'; import { Button } from '@/components/ui/button'; import { CreateButton } from '@/components/ui/create-button'; import { @@ -20,18 +21,21 @@ import { type RuleStructure, } from '@/lib/rule-builder-utils'; import type { Category } from '@/types/category'; +import type { Label } from '@/types/label'; import { __ } from '@/utils/i18n'; import { router } from '@inertiajs/react'; import { useState } from 'react'; interface CreateAutomationRuleDialogProps { categories: Category[]; + labels: Label[]; disabled?: boolean; onSuccess?: () => void; } export function CreateAutomationRuleDialog({ categories, + labels, disabled = false, onSuccess, }: CreateAutomationRuleDialogProps) { @@ -42,6 +46,7 @@ export function CreateAutomationRuleDialog({ groupOperator: 'or', }); const [categoryId, setCategoryId] = useState(''); + const [labelIds, setLabelIds] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [errors, setErrors] = useState>({}); @@ -62,10 +67,11 @@ export function CreateAutomationRuleDialog({ return; } - if (!categoryId) { + if (!categoryId && labelIds.length === 0) { setErrors((prev) => ({ ...prev, - action_category_id: 'A category is required', + action_category_id: + 'At least one category or label is required', })); return; } @@ -81,10 +87,10 @@ export function CreateAutomationRuleDialog({ title: title.trim(), priority: 0, rules_json: JSON.stringify(jsonLogic), - action_category_id: categoryId, + action_category_id: categoryId || null, action_note: null, action_note_iv: null, - action_label_ids: null, + action_label_ids: labelIds, }, { preserveState: true, @@ -97,6 +103,7 @@ export function CreateAutomationRuleDialog({ groupOperator: 'and', }); setCategoryId(''); + setLabelIds([]); setErrors({}); onSuccess?.(); }, @@ -158,9 +165,21 @@ export function CreateAutomationRuleDialog({

{__('Actions')}

- - {__('Set Category')} - +
+ + {__('Set Category')} + + {categoryId && ( + + )} +
- {errors.action_category_id && ( +
+ {__('Add Labels')} + +
+ + {(errors.action_category_id || + errors.action_label_ids || + errors['action_label_ids.0']) && (

- {errors.action_category_id} + {errors.action_category_id || + errors.action_label_ids || + errors['action_label_ids.0']}

)}
diff --git a/resources/js/components/automation-rules/edit-automation-rule-dialog.tsx b/resources/js/components/automation-rules/edit-automation-rule-dialog.tsx index b5b638e9..0aa4d651 100644 --- a/resources/js/components/automation-rules/edit-automation-rule-dialog.tsx +++ b/resources/js/components/automation-rules/edit-automation-rule-dialog.tsx @@ -1,6 +1,7 @@ import { update } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController'; import { RuleBuilder } from '@/components/automation-rules/rule-builder'; import { CategoryCombobox } from '@/components/shared/category-combobox'; +import { LabelCombobox } from '@/components/shared/label-combobox'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -20,6 +21,7 @@ import { } from '@/lib/rule-builder-utils'; import type { AutomationRule } from '@/types/automation-rule'; import type { Category } from '@/types/category'; +import type { Label as LabelType } from '@/types/label'; import { __ } from '@/utils/i18n'; import { router } from '@inertiajs/react'; import { useEffect, useState } from 'react'; @@ -27,6 +29,7 @@ import { useEffect, useState } from 'react'; interface EditAutomationRuleDialogProps { rule: AutomationRule; categories: Category[]; + labels: LabelType[]; open: boolean; onOpenChange: (open: boolean) => void; onSuccess?: () => void; @@ -35,6 +38,7 @@ interface EditAutomationRuleDialogProps { export function EditAutomationRuleDialog({ rule, categories, + labels, open, onOpenChange, onSuccess, @@ -45,6 +49,7 @@ export function EditAutomationRuleDialog({ groupOperator: 'and', }); const [categoryId, setCategoryId] = useState(''); + const [labelIds, setLabelIds] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [errors, setErrors] = useState>({}); @@ -55,6 +60,7 @@ export function EditAutomationRuleDialog({ setCategoryId( rule.action_category_id ? String(rule.action_category_id) : '', ); + setLabelIds(rule.labels?.map((label) => label.id) ?? []); } }, [rule, open]); @@ -75,10 +81,11 @@ export function EditAutomationRuleDialog({ return; } - if (!categoryId) { + if (!categoryId && labelIds.length === 0) { setErrors((prev) => ({ ...prev, - action_category_id: 'A category is required', + action_category_id: + 'At least one category or label is required', })); return; } @@ -94,10 +101,10 @@ export function EditAutomationRuleDialog({ title: title.trim(), priority: rule.priority, rules_json: JSON.stringify(jsonLogic), - action_category_id: categoryId, + action_category_id: categoryId || null, action_note: null, action_note_iv: null, - action_label_ids: null, + action_label_ids: labelIds, }, { preserveState: true, @@ -128,7 +135,7 @@ export function EditAutomationRuleDialog({ {__('Edit Automation Rule')} {__( - 'Update the rule to automatically categorize transactions.', + 'Update the rule to automatically categorize transactions and add labels.', )} @@ -160,9 +167,21 @@ export function EditAutomationRuleDialog({

{__('Actions')}

- +
+ + {categoryId && ( + + )} +
- {errors.action_category_id && ( +
+ + +
+ + {(errors.action_category_id || + errors.action_label_ids || + errors['action_label_ids.0']) && (

- {errors.action_category_id} + {errors.action_category_id || + errors.action_label_ids || + errors['action_label_ids.0']}

)}
@@ -194,7 +227,9 @@ export function EditAutomationRuleDialog({ disabled={isSubmitting} data-testid="submit-automation-rule" > - {isSubmitting ? 'Saving...' : 'Save Changes'} + {isSubmitting + ? __('Saving...') + : __('Save Changes')} diff --git a/resources/js/components/transactions/transaction-list.tsx b/resources/js/components/transactions/transaction-list.tsx index b913f76f..fdff7a91 100644 --- a/resources/js/components/transactions/transaction-list.tsx +++ b/resources/js/components/transactions/transaction-list.tsx @@ -59,6 +59,7 @@ import { decrypt, importKey } from '@/lib/crypto'; import { consoleDebug } from '@/lib/debug'; import { db } from '@/lib/dexie-db'; import { getStoredKey } from '@/lib/key-storage'; +import { mergeReEvaluatedTransaction } from '@/lib/transaction-re-evaluation'; import { transactionSyncService } from '@/services/transaction-sync'; import { type Account, type Bank } from '@/types/account'; import { type AutomationRule } from '@/types/automation-rule'; @@ -861,14 +862,9 @@ export function TransactionList({ const updated = response.data.data; - updateTransaction({ - ...transaction, - category_id: updated.category_id, - category: updated.category, - labels: updated.labels, - notes: updated.notes, - notes_iv: updated.notes_iv, - }); + updateTransaction( + mergeReEvaluatedTransaction(transaction, updated), + ); consoleDebug('✓ UI state updated successfully'); } catch (error) { consoleDebug('❌ Error during re-evaluation:', error); diff --git a/resources/js/lib/transaction-re-evaluation.test.ts b/resources/js/lib/transaction-re-evaluation.test.ts new file mode 100644 index 00000000..9aa851a9 --- /dev/null +++ b/resources/js/lib/transaction-re-evaluation.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { type DecryptedTransaction } from '@/types/transaction'; +import { mergeReEvaluatedTransaction } from './transaction-re-evaluation'; + +function transaction( + overrides: Partial = {}, +): DecryptedTransaction { + return { + id: 'transaction-1', + user_id: 'user-1', + account_id: 'account-1', + category_id: null, + description: 'Coffee', + description_iv: null, + transaction_date: '2026-05-11', + amount: -450, + currency_code: 'EUR', + notes: null, + notes_iv: null, + source: 'imported', + label_ids: [], + created_at: '2026-05-11T00:00:00.000000Z', + updated_at: '2026-05-11T00:00:00.000000Z', + decryptedDescription: 'Coffee', + decryptedNotes: null, + ...overrides, + }; +} + +describe('mergeReEvaluatedTransaction', () => { + it('derives label ids from returned labels', () => { + const merged = mergeReEvaluatedTransaction( + transaction(), + transaction({ + labels: [ + { + id: 'label-1', + user_id: 'user-1', + name: 'Work', + color: 'blue', + created_at: '2026-05-11T00:00:00.000000Z', + updated_at: '2026-05-11T00:00:00.000000Z', + deleted_at: null, + }, + ], + }), + ); + + expect(merged.label_ids).toEqual(['label-1']); + }); + + it('updates plaintext notes for immediate display', () => { + const merged = mergeReEvaluatedTransaction( + transaction(), + transaction({ + notes: 'Needs review', + notes_iv: null, + }), + ); + + expect(merged.decryptedNotes).toBe('Needs review'); + }); +}); diff --git a/resources/js/lib/transaction-re-evaluation.ts b/resources/js/lib/transaction-re-evaluation.ts new file mode 100644 index 00000000..627f50ad --- /dev/null +++ b/resources/js/lib/transaction-re-evaluation.ts @@ -0,0 +1,25 @@ +import { type DecryptedTransaction } from '@/types/transaction'; + +export function mergeReEvaluatedTransaction( + transaction: DecryptedTransaction, + updated: DecryptedTransaction, +): DecryptedTransaction { + const labels = updated.labels ?? transaction.labels; + const labelIds = updated.labels + ? updated.labels.map((label) => label.id) + : (updated.label_ids ?? transaction.label_ids); + + return { + ...transaction, + category_id: updated.category_id, + category: updated.category, + labels, + label_ids: labelIds, + notes: updated.notes, + notes_iv: updated.notes_iv, + decryptedNotes: + updated.notes_iv === null + ? (updated.notes ?? null) + : transaction.decryptedNotes, + }; +} diff --git a/resources/js/pages/settings/automation-rules.tsx b/resources/js/pages/settings/automation-rules.tsx index b12be01c..559c4d15 100644 --- a/resources/js/pages/settings/automation-rules.tsx +++ b/resources/js/pages/settings/automation-rules.tsx @@ -12,16 +12,15 @@ import { useReactTable, VisibilityState, } from '@tanstack/react-table'; -import * as Icons from 'lucide-react'; import { MoreHorizontal } from 'lucide-react'; import { useMemo, useState } from 'react'; import { index as automationRulesIndex } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController'; +import { AutomationRuleActionBadges } from '@/components/automation-rules/automation-rule-action-badges'; import { CreateAutomationRuleDialog } from '@/components/automation-rules/create-automation-rule-dialog'; import { DeleteAutomationRuleDialog } from '@/components/automation-rules/delete-automation-rule-dialog'; import { EditAutomationRuleDialog } from '@/components/automation-rules/edit-automation-rule-dialog'; import HeadingSmall from '@/components/heading-small'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { ContextMenu, @@ -49,8 +48,9 @@ import { import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; import { type BreadcrumbItem } from '@/types'; -import { type AutomationRule, getRuleActions } from '@/types/automation-rule'; -import { type Category, getCategoryColorClasses } from '@/types/category'; +import { type AutomationRule } from '@/types/automation-rule'; +import { type Category } from '@/types/category'; +import { type Label } from '@/types/label'; import { __ } from '@/utils/i18n'; const breadcrumbs: BreadcrumbItem[] = [ @@ -63,9 +63,11 @@ const breadcrumbs: BreadcrumbItem[] = [ function AutomationRuleActions({ rule, categories, + labels, }: { rule: AutomationRule; categories: Category[]; + labels: Label[]; }) { const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); @@ -100,6 +102,7 @@ function AutomationRuleActions({ @@ -115,9 +118,11 @@ function AutomationRuleActions({ function AutomationRuleRow({ row, categories, + labels, }: { row: Row; categories: Category[]; + labels: Label[]; }) { const rule = row.original; const [editOpen, setEditOpen] = useState(false); @@ -163,6 +168,7 @@ function AutomationRuleRow({ @@ -180,8 +186,8 @@ export default function AutomationRules() { automationRules: AutomationRule[]; }>().props; - // Get categories from globally shared Inertia data const categories = usePage().props.categories as Category[]; + const labels = usePage().props.labels as Label[]; const rules = useMemo( () => rawRules.map((rule) => ({ @@ -213,54 +219,7 @@ export default function AutomationRules() { id: 'actions_display', header: __('Actions'), cell: ({ row }) => { - const rule = row.original; - const actions = getRuleActions(rule); - - if (actions.type === 'multiple') { - return ( -
- {actions.category && ( - - {(() => { - const IconComponent = Icons[ - actions.category - .icon as keyof typeof Icons - ] as Icons.LucideIcon; - return IconComponent ? ( - - ) : null; - })()} - {actions.category.name} - - )} -
- ); - } - - if (actions.type === 'category' && actions.category) { - const colorClasses = getCategoryColorClasses( - actions.category.color, - ); - const IconComponent = Icons[ - actions.category.icon as keyof typeof Icons - ] as Icons.LucideIcon; - return ( - - - {actions.category.name} - - ); - } - - return ( - - {__('No action set')} - - ); + return ; }, }, { @@ -270,6 +229,7 @@ export default function AutomationRules() { ), }, @@ -300,7 +260,7 @@ export default function AutomationRules() { @@ -322,6 +282,7 @@ export default function AutomationRules() { /> @@ -363,6 +324,7 @@ export default function AutomationRules() { key={row.id} row={row} categories={categories} + labels={labels} /> )) ) : ( diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index 11a13ec7..e6f7211f 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -67,6 +67,7 @@ import { isCursorPaginatedResponse, } from '@/lib/cursor-pagination'; import { consoleDebug } from '@/lib/debug'; +import { mergeReEvaluatedTransaction } from '@/lib/transaction-re-evaluation'; import { cn } from '@/lib/utils'; import { transactionSyncService } from '@/services/transaction-sync'; import { type BreadcrumbItem } from '@/types'; @@ -662,14 +663,9 @@ export default function Transactions({ const updated = response.data.data; - updateTransaction({ - ...transaction, - category_id: updated.category_id, - category: updated.category, - labels: updated.labels, - notes: updated.notes, - notes_iv: updated.notes_iv, - }); + updateTransaction( + mergeReEvaluatedTransaction(transaction, updated), + ); } catch (error) { console.error('Failed to re-evaluate rules:', error); } finally {