Add labels to automation rules (#379)
## Summary - add label selection to automation rule create/edit dialogs - allow rules with category, labels, or both - display category, labels, and note actions in rule tables ## Verification - php artisan test --compact tests/Feature/AutomationRuleTest.php tests/Feature/AutomationRuleEvaluationTest.php - npx eslint resources/js/components/automation-rules/automation-rule-action-badges.tsx resources/js/components/automation-rules/create-automation-rule-dialog.tsx resources/js/components/automation-rules/edit-automation-rule-dialog.tsx resources/js/components/automation-rules/automation-rules-dialog.tsx resources/js/pages/settings/automation-rules.tsx - npm run build (exit 0; Sentry sourcemap upload logged SSL error)
This commit is contained in:
parent
a20eeff378
commit
5b8e7e86d7
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{__('No action set')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{actions.category && <CategoryBadge category={actions.category} />}
|
||||
|
||||
{hasLabels && <LabelBadges labels={actions.labels ?? []} max={5} />}
|
||||
|
||||
{actions.hasNote && (
|
||||
<Badge variant="outline">{__('Add note')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryBadge({
|
||||
category,
|
||||
}: {
|
||||
category: NonNullable<ReturnType<typeof getRuleActions>['category']>;
|
||||
}) {
|
||||
const colorClasses = getCategoryColorClasses(category.color);
|
||||
const IconComponent = Icons[
|
||||
category.icon as keyof typeof Icons
|
||||
] as Icons.LucideIcon;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={`${colorClasses.bg} ${colorClasses.text} flex items-center gap-2`}
|
||||
>
|
||||
{IconComponent && <IconComponent className="h-3 w-3 opacity-80" />}
|
||||
{category.name}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
@ -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({
|
|||
<EditAutomationRuleDialog
|
||||
rule={rule}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
|
|
@ -115,9 +118,11 @@ function AutomationRuleActions({
|
|||
function AutomationRuleRow({
|
||||
row,
|
||||
categories,
|
||||
labels,
|
||||
}: {
|
||||
row: Row<AutomationRule>;
|
||||
categories: Category[];
|
||||
labels: Label[];
|
||||
}) {
|
||||
const rule = row.original;
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
|
@ -163,6 +168,7 @@ function AutomationRuleRow({
|
|||
<EditAutomationRuleDialog
|
||||
rule={rule}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
|
|
@ -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 (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{actions.category && (
|
||||
<Badge
|
||||
className={`${getCategoryColorClasses(actions.category.color).bg} ${getCategoryColorClasses(actions.category.color).text} flex items-center gap-2`}
|
||||
>
|
||||
{(() => {
|
||||
const IconComponent = Icons[
|
||||
actions.category
|
||||
.icon as keyof typeof Icons
|
||||
] as Icons.LucideIcon;
|
||||
return IconComponent ? (
|
||||
<IconComponent className="h-3 w-3 opacity-80" />
|
||||
) : null;
|
||||
})()}
|
||||
{actions.category.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Badge
|
||||
className={`${colorClasses.bg} ${colorClasses.text} flex items-center gap-2`}
|
||||
>
|
||||
<IconComponent className="h-3 w-3 opacity-80" />
|
||||
{actions.category.name}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{__('No action set')}
|
||||
</span>
|
||||
);
|
||||
return <AutomationRuleActionBadges rule={row.original} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -273,6 +232,7 @@ export function AutomationRulesDialog({
|
|||
<AutomationRuleActions
|
||||
rule={row.original}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
@ -301,7 +261,7 @@ export function AutomationRulesDialog({
|
|||
<DialogTitle>{__('Automation Rules')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{__(
|
||||
'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.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
@ -322,7 +282,10 @@ export function AutomationRulesDialog({
|
|||
}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<CreateAutomationRuleDialog categories={categories} />
|
||||
<CreateAutomationRuleDialog
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[75vh] overflow-y-auto rounded-md border">
|
||||
|
|
@ -356,6 +319,7 @@ export function AutomationRulesDialog({
|
|||
key={row.id}
|
||||
row={row}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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<string>('');
|
||||
const [labelIds, setLabelIds] = useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
|
|
@ -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({
|
|||
<h4 className="font-medium">{__('Actions')}</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="category">
|
||||
{__('Set Category')}
|
||||
</FormLabel>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FormLabel htmlFor="category">
|
||||
{__('Set Category')}
|
||||
</FormLabel>
|
||||
{categoryId && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCategoryId('')}
|
||||
>
|
||||
{__('Clear')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<CategoryCombobox
|
||||
value={categoryId}
|
||||
|
|
@ -173,9 +192,23 @@ export function CreateAutomationRuleDialog({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{errors.action_category_id && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{__('Add Labels')}</FormLabel>
|
||||
<LabelCombobox
|
||||
value={labelIds}
|
||||
onValueChange={setLabelIds}
|
||||
labels={labels}
|
||||
placeholder={__('Select labels')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(errors.action_category_id ||
|
||||
errors.action_label_ids ||
|
||||
errors['action_label_ids.0']) && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.action_category_id}
|
||||
{errors.action_category_id ||
|
||||
errors.action_label_ids ||
|
||||
errors['action_label_ids.0']}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<string>('');
|
||||
const [labelIds, setLabelIds] = useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
|
|
@ -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({
|
|||
<DialogTitle>{__('Edit Automation Rule')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{__(
|
||||
'Update the rule to automatically categorize transactions.',
|
||||
'Update the rule to automatically categorize transactions and add labels.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
@ -160,9 +167,21 @@ export function EditAutomationRuleDialog({
|
|||
<h4 className="font-medium">{__('Actions')}</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">
|
||||
{__('Set Category')}
|
||||
</Label>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor="category">
|
||||
{__('Set Category')}
|
||||
</Label>
|
||||
{categoryId && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCategoryId('')}
|
||||
>
|
||||
{__('Clear')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CategoryCombobox
|
||||
value={categoryId}
|
||||
onValueChange={setCategoryId}
|
||||
|
|
@ -173,9 +192,23 @@ export function EditAutomationRuleDialog({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{errors.action_category_id && (
|
||||
<div className="space-y-2">
|
||||
<Label>{__('Add Labels')}</Label>
|
||||
<LabelCombobox
|
||||
value={labelIds}
|
||||
onValueChange={setLabelIds}
|
||||
labels={labels}
|
||||
placeholder={__('Select labels')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(errors.action_category_id ||
|
||||
errors.action_label_ids ||
|
||||
errors['action_label_ids.0']) && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.action_category_id}
|
||||
{errors.action_category_id ||
|
||||
errors.action_label_ids ||
|
||||
errors['action_label_ids.0']}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -194,7 +227,9 @@ export function EditAutomationRuleDialog({
|
|||
disabled={isSubmitting}
|
||||
data-testid="submit-automation-rule"
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
{isSubmitting
|
||||
? __('Saving...')
|
||||
: __('Save Changes')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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> = {},
|
||||
): 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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({
|
|||
<EditAutomationRuleDialog
|
||||
rule={rule}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
|
|
@ -115,9 +118,11 @@ function AutomationRuleActions({
|
|||
function AutomationRuleRow({
|
||||
row,
|
||||
categories,
|
||||
labels,
|
||||
}: {
|
||||
row: Row<AutomationRule>;
|
||||
categories: Category[];
|
||||
labels: Label[];
|
||||
}) {
|
||||
const rule = row.original;
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
|
@ -163,6 +168,7 @@ function AutomationRuleRow({
|
|||
<EditAutomationRuleDialog
|
||||
rule={rule}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
|
|
@ -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 (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{actions.category && (
|
||||
<Badge
|
||||
className={`${getCategoryColorClasses(actions.category.color).bg} ${getCategoryColorClasses(actions.category.color).text} flex items-center gap-2`}
|
||||
>
|
||||
{(() => {
|
||||
const IconComponent = Icons[
|
||||
actions.category
|
||||
.icon as keyof typeof Icons
|
||||
] as Icons.LucideIcon;
|
||||
return IconComponent ? (
|
||||
<IconComponent className="h-3 w-3 opacity-80" />
|
||||
) : null;
|
||||
})()}
|
||||
{actions.category.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Badge
|
||||
className={`${colorClasses.bg} ${colorClasses.text} flex items-center gap-2`}
|
||||
>
|
||||
<IconComponent className="h-3 w-3 opacity-80" />
|
||||
{actions.category.name}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{__('No action set')}
|
||||
</span>
|
||||
);
|
||||
return <AutomationRuleActionBadges rule={row.original} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -270,6 +229,7 @@ export default function AutomationRules() {
|
|||
<AutomationRuleActions
|
||||
rule={row.original}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
@ -300,7 +260,7 @@ export default function AutomationRules() {
|
|||
<HeadingSmall
|
||||
title={__('Automation rules settings')}
|
||||
description={__(
|
||||
'Manage your transaction automation rules',
|
||||
'Manage rules that categorize transactions and add labels automatically',
|
||||
)}
|
||||
/>
|
||||
|
||||
|
|
@ -322,6 +282,7 @@ export default function AutomationRules() {
|
|||
/>
|
||||
<CreateAutomationRuleDialog
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -363,6 +324,7 @@ export default function AutomationRules() {
|
|||
key={row.id}
|
||||
row={row}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue