feat(automation-rules): simplify smart rules UI, fix re-evaluation, and localize amounts (#161)
## Why **Problem:** Bulk re-evaluation of transactions against smart rules was broken because the code required an encryption key that no longer exists in the app. Additionally, the rule builder UI had unnecessary complexity (priority field, labels/tags, date/category/notes conditions) that added cognitive load without value. Currency amounts were also always formatted with US conventions regardless of the user's locale. ## What **Automation rules – re-evaluation fix:** - Remove encryption key requirement from `evaluateRules`, `evaluateRulesForTransactions`, `evaluateRulesForNewTransaction`, and `prepareTransactionData` (accept `CryptoKey | null`) - Drop `isKeySet` guards from single-transaction and bulk re-evaluate handlers in `transaction-list.tsx` and `transaction-actions-menu.tsx` - Simplify `use-re-evaluate-all-transactions.tsx` to pass `null` as key directly **Automation rules – UI cleanup:** - Hide priority field from create/edit dialogs (still sends `priority: 0` / `rule.priority` to satisfy backend validation) - Remove labels/tags from create/edit dialogs, actions column, and table display - Remove `date`, `category`, and `notes` from rule condition field options in `rule-builder-utils.ts` - Add `opacity-30` to disabled X button when only one condition/group remains **Amount localization:** - Replace hardcoded `'en-US'` with `useLocale()` in `AmountDisplay` so symbol position and number format follow the user's locale (e.g. `89.705,00 €` in Spanish) ## Verification - 38/38 `AutomationRuleTest` + `AutomationRuleEvaluationTest` tests pass - Re-evaluation works without an encryption key set - Spanish locale users see `89.705,00 €` instead of `€89,705.00`
This commit is contained in:
parent
0c5ba916bf
commit
b1f01e4a8f
|
|
@ -19,7 +19,6 @@ import { useMemo, useState } from 'react';
|
|||
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 { LabelBadges } from '@/components/shared/label-combobox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
|
|
@ -52,10 +51,8 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import { type AutomationRule, getRuleActions } from '@/types/automation-rule';
|
||||
import { type Category, getCategoryColorClasses } from '@/types/category';
|
||||
import { type Label } from '@/types/label';
|
||||
import { __ } from '@/utils/i18n';
|
||||
|
||||
interface AutomationRulesDialogProps {
|
||||
|
|
@ -66,11 +63,9 @@ interface AutomationRulesDialogProps {
|
|||
function AutomationRuleActions({
|
||||
rule,
|
||||
categories,
|
||||
labels,
|
||||
}: {
|
||||
rule: AutomationRule;
|
||||
categories: Category[];
|
||||
labels: Label[];
|
||||
}) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
|
@ -105,7 +100,6 @@ function AutomationRuleActions({
|
|||
<EditAutomationRuleDialog
|
||||
rule={rule}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
|
|
@ -121,11 +115,9 @@ function AutomationRuleActions({
|
|||
function AutomationRuleRow({
|
||||
row,
|
||||
categories,
|
||||
labels,
|
||||
}: {
|
||||
row: Row<AutomationRule>;
|
||||
categories: Category[];
|
||||
labels: Label[];
|
||||
}) {
|
||||
const rule = row.original;
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
|
@ -171,7 +163,6 @@ function AutomationRuleRow({
|
|||
<EditAutomationRuleDialog
|
||||
rule={rule}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
|
|
@ -188,14 +179,12 @@ export function AutomationRulesDialog({
|
|||
open,
|
||||
onOpenChange,
|
||||
}: AutomationRulesDialogProps) {
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
const { automationRules: rawRules } = usePage<{
|
||||
automationRules: AutomationRule[];
|
||||
}>().props;
|
||||
|
||||
// Get categories and labels from globally shared Inertia data
|
||||
// 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) => ({
|
||||
|
|
@ -207,29 +196,13 @@ export function AutomationRulesDialog({
|
|||
})),
|
||||
[rawRules],
|
||||
);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{
|
||||
id: 'priority',
|
||||
desc: false,
|
||||
},
|
||||
]);
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
{},
|
||||
);
|
||||
|
||||
const columns: ColumnDef<AutomationRule>[] = [
|
||||
{
|
||||
accessorKey: 'priority',
|
||||
header: __('Priority'),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="font-medium">
|
||||
{row.getValue('priority')}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: __('Title'),
|
||||
|
|
@ -265,14 +238,6 @@ export function AutomationRulesDialog({
|
|||
{actions.category.name}
|
||||
</Badge>
|
||||
)}
|
||||
{actions.hasLabels && actions.labels && (
|
||||
<LabelBadges labels={actions.labels} max={2} />
|
||||
)}
|
||||
{actions.hasNote && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
+ note
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -294,13 +259,9 @@ export function AutomationRulesDialog({
|
|||
);
|
||||
}
|
||||
|
||||
if (actions.type === 'labels' && actions.labels) {
|
||||
return <LabelBadges labels={actions.labels} max={3} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{__('Add note')}
|
||||
{__('No action set')}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
|
|
@ -312,7 +273,6 @@ export function AutomationRulesDialog({
|
|||
<AutomationRuleActions
|
||||
rule={row.original}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
@ -362,11 +322,7 @@ export function AutomationRulesDialog({
|
|||
}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<CreateAutomationRuleDialog
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
disabled={!isKeySet}
|
||||
/>
|
||||
<CreateAutomationRuleDialog categories={categories} />
|
||||
</div>
|
||||
|
||||
<div className="max-h-[75vh] overflow-y-auto rounded-md border">
|
||||
|
|
@ -400,7 +356,6 @@ export function AutomationRulesDialog({
|
|||
key={row.id}
|
||||
row={row}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
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 {
|
||||
|
|
@ -21,33 +20,28 @@ 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) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [priority, setPriority] = useState('10');
|
||||
const [ruleStructure, setRuleStructure] = useState<RuleStructure>({
|
||||
groups: [createEmptyGroup()],
|
||||
groupOperator: 'or',
|
||||
});
|
||||
const [categoryId, setCategoryId] = useState<string>('');
|
||||
const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
|
|
@ -68,10 +62,10 @@ export function CreateAutomationRuleDialog({
|
|||
return;
|
||||
}
|
||||
|
||||
if (!categoryId && selectedLabelIds.length === 0) {
|
||||
if (!categoryId) {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
action_category_id: 'At least one action is required',
|
||||
action_category_id: 'A category is required',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
|
@ -85,13 +79,12 @@ export function CreateAutomationRuleDialog({
|
|||
store().url,
|
||||
{
|
||||
title: title.trim(),
|
||||
priority: parseInt(priority, 10),
|
||||
priority: 0,
|
||||
rules_json: JSON.stringify(jsonLogic),
|
||||
action_category_id: categoryId || null,
|
||||
action_category_id: categoryId,
|
||||
action_note: null,
|
||||
action_note_iv: null,
|
||||
action_label_ids:
|
||||
selectedLabelIds.length > 0 ? selectedLabelIds : null,
|
||||
action_label_ids: null,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
|
|
@ -99,13 +92,11 @@ export function CreateAutomationRuleDialog({
|
|||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
setTitle('');
|
||||
setPriority('10');
|
||||
setRuleStructure({
|
||||
groups: [createEmptyGroup()],
|
||||
groupOperator: 'and',
|
||||
});
|
||||
setCategoryId('');
|
||||
setSelectedLabelIds([]);
|
||||
setErrors({});
|
||||
onSuccess?.();
|
||||
},
|
||||
|
|
@ -135,7 +126,7 @@ export function CreateAutomationRuleDialog({
|
|||
<DialogTitle>{__('Create Automation Rule')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{__(
|
||||
'Create a rule to automatically categorize transactions\n and add labels.',
|
||||
'Create a rule to automatically categorize transactions.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
@ -157,30 +148,6 @@ export function CreateAutomationRuleDialog({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="priority">
|
||||
{__('Priority')}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id="priority"
|
||||
type="number"
|
||||
min="0"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
placeholder="10"
|
||||
required
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Lower numbers execute first')}
|
||||
</p>
|
||||
{errors.priority && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.priority}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RuleBuilder
|
||||
value={ruleStructure}
|
||||
onChange={setRuleStructure}
|
||||
|
|
@ -189,9 +156,6 @@ export function CreateAutomationRuleDialog({
|
|||
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<h4 className="font-medium">{__('Actions')}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('At least one action is required')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor="category">
|
||||
|
|
@ -202,40 +166,18 @@ export function CreateAutomationRuleDialog({
|
|||
value={categoryId}
|
||||
onValueChange={setCategoryId}
|
||||
categories={categories}
|
||||
placeholder={__(
|
||||
'Select a category (optional)',
|
||||
)}
|
||||
placeholder={__('Select a category')}
|
||||
showUncategorized={false}
|
||||
data-testid="action-category-select"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{__('Add Labels')}</FormLabel>
|
||||
<div className="mt-1">
|
||||
<LabelCombobox
|
||||
value={selectedLabelIds}
|
||||
onValueChange={setSelectedLabelIds}
|
||||
labels={labels}
|
||||
placeholder={__('Select labels (optional)')}
|
||||
allowCreate={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errors.action_category_id && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.action_category_id}
|
||||
</p>
|
||||
)}
|
||||
{(errors['action_label_ids.0'] ||
|
||||
errors.action_label_ids) && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors['action_label_ids.0'] ||
|
||||
errors.action_label_ids}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
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,
|
||||
|
|
@ -21,7 +20,6 @@ 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';
|
||||
|
|
@ -29,7 +27,6 @@ import { useEffect, useState } from 'react';
|
|||
interface EditAutomationRuleDialogProps {
|
||||
rule: AutomationRule;
|
||||
categories: Category[];
|
||||
labels: LabelType[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
|
|
@ -38,31 +35,26 @@ interface EditAutomationRuleDialogProps {
|
|||
export function EditAutomationRuleDialog({
|
||||
rule,
|
||||
categories,
|
||||
labels,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: EditAutomationRuleDialogProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [priority, setPriority] = useState('0');
|
||||
const [ruleStructure, setRuleStructure] = useState<RuleStructure>({
|
||||
groups: [createEmptyGroup()],
|
||||
groupOperator: 'and',
|
||||
});
|
||||
const [categoryId, setCategoryId] = useState<string>('');
|
||||
const [selectedLabelIds, setSelectedLabelIds] = useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (rule && open) {
|
||||
setTitle(rule.title);
|
||||
setPriority(String(rule.priority));
|
||||
setRuleStructure(parseJsonLogic(rule.rules_json));
|
||||
setCategoryId(
|
||||
rule.action_category_id ? String(rule.action_category_id) : '',
|
||||
);
|
||||
setSelectedLabelIds(rule.labels?.map((l) => l.id) || []);
|
||||
}
|
||||
}, [rule, open]);
|
||||
|
||||
|
|
@ -83,10 +75,10 @@ export function EditAutomationRuleDialog({
|
|||
return;
|
||||
}
|
||||
|
||||
if (!categoryId && selectedLabelIds.length === 0) {
|
||||
if (!categoryId) {
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
action_category_id: 'At least one action is required',
|
||||
action_category_id: 'A category is required',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
|
@ -100,13 +92,12 @@ export function EditAutomationRuleDialog({
|
|||
update(rule.id).url,
|
||||
{
|
||||
title: title.trim(),
|
||||
priority: parseInt(priority, 10),
|
||||
priority: rule.priority,
|
||||
rules_json: JSON.stringify(jsonLogic),
|
||||
action_category_id: categoryId || null,
|
||||
action_category_id: categoryId,
|
||||
action_note: null,
|
||||
action_note_iv: null,
|
||||
action_label_ids:
|
||||
selectedLabelIds.length > 0 ? selectedLabelIds : null,
|
||||
action_label_ids: null,
|
||||
},
|
||||
{
|
||||
preserveState: true,
|
||||
|
|
@ -137,7 +128,7 @@ export function EditAutomationRuleDialog({
|
|||
<DialogTitle>{__('Edit Automation Rule')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{__(
|
||||
'Update the rule to automatically categorize transactions\n and add labels.',
|
||||
'Update the rule to automatically categorize transactions.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
@ -159,28 +150,6 @@ export function EditAutomationRuleDialog({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="priority">{__('Priority')}</Label>
|
||||
<Input
|
||||
id="priority"
|
||||
type="number"
|
||||
min="0"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value)}
|
||||
placeholder="0"
|
||||
required
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Lower numbers execute first')}
|
||||
</p>
|
||||
{errors.priority && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.priority}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RuleBuilder
|
||||
value={ruleStructure}
|
||||
onChange={setRuleStructure}
|
||||
|
|
@ -189,9 +158,6 @@ export function EditAutomationRuleDialog({
|
|||
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<h4 className="font-medium">{__('Actions')}</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__('At least one action is required')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">
|
||||
|
|
@ -201,35 +167,17 @@ export function EditAutomationRuleDialog({
|
|||
value={categoryId}
|
||||
onValueChange={setCategoryId}
|
||||
categories={categories}
|
||||
placeholder={__('Select a category (optional)')}
|
||||
placeholder={__('Select a category')}
|
||||
showUncategorized={false}
|
||||
data-testid="action-category-select"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{__('Add Labels')}</Label>
|
||||
<LabelCombobox
|
||||
value={selectedLabelIds}
|
||||
onValueChange={setSelectedLabelIds}
|
||||
labels={labels}
|
||||
placeholder={__('Select labels (optional)')}
|
||||
allowCreate={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errors.action_category_id && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.action_category_id}
|
||||
</p>
|
||||
)}
|
||||
{(errors['action_label_ids.0'] ||
|
||||
errors.action_label_ids) && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors['action_label_ids.0'] ||
|
||||
errors.action_label_ids}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
|
|
|
|||
|
|
@ -272,12 +272,7 @@ function ConditionRow({
|
|||
condition.operator !== 'is_empty' &&
|
||||
condition.operator !== 'is_not_empty';
|
||||
|
||||
const inputType =
|
||||
fieldConfig?.type === 'number'
|
||||
? 'number'
|
||||
: fieldConfig?.type === 'date'
|
||||
? 'date'
|
||||
: 'text';
|
||||
const inputType = fieldConfig?.type === 'number' ? 'number' : 'text';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
|
|
@ -331,7 +326,7 @@ function ConditionRow({
|
|||
size="sm"
|
||||
onClick={onRemove}
|
||||
disabled={!canRemove}
|
||||
className="self-end sm:self-auto"
|
||||
className={`self-end sm:self-auto${!canRemove ? 'opacity-30' : ''}`}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from '@/components/ui/tooltip';
|
||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import { useReEvaluateAllTransactions } from '@/hooks/use-re-evaluate-all-transactions';
|
||||
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
import { type AutomationRule } from '@/types/automation-rule';
|
||||
import { type Category } from '@/types/category';
|
||||
|
|
@ -73,13 +74,6 @@ export function TransactionActionsMenu({
|
|||
};
|
||||
|
||||
const handleReEvaluateAll = async () => {
|
||||
if (!isKeySet) {
|
||||
toast.error(
|
||||
'Please unlock your encryption key to re-evaluate transactions',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!transactions.length) {
|
||||
toast.error('No transactions to re-evaluate');
|
||||
return;
|
||||
|
|
@ -199,11 +193,7 @@ export function TransactionActionsMenu({
|
|||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={handleReEvaluateAll}
|
||||
disabled={
|
||||
!isKeySet ||
|
||||
isReEvaluating ||
|
||||
!transactions.length
|
||||
}
|
||||
disabled={isReEvaluating || !transactions.length}
|
||||
>
|
||||
<WandSparkles className="mr-2 h-4 w-4" />
|
||||
{__('Re-evaluate All Expenses')}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,6 @@ import { consoleDebug } from '@/lib/debug';
|
|||
import { db } from '@/lib/dexie-db';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { evaluateRules } from '@/lib/rule-engine';
|
||||
import { appendNoteIfNotPresent } from '@/lib/utils';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import { type Account, type Bank } from '@/types/account';
|
||||
import { type AutomationRule } from '@/types/automation-rule';
|
||||
|
|
@ -846,18 +845,6 @@ export function TransactionList({
|
|||
|
||||
setIsReEvaluating(true);
|
||||
try {
|
||||
const keyString = getStoredKey();
|
||||
if (!keyString || !isKeySet) {
|
||||
consoleDebug('❌ Encryption key not set');
|
||||
console.error('Encryption key not set');
|
||||
toast.error(
|
||||
'Please unlock your encryption key to re-evaluate rules',
|
||||
);
|
||||
return;
|
||||
}
|
||||
consoleDebug('✓ Encryption key found');
|
||||
|
||||
const key = await importKey(keyString);
|
||||
consoleDebug(
|
||||
`Found ${automationRules.length} automation rules`,
|
||||
);
|
||||
|
|
@ -874,7 +861,7 @@ export function TransactionList({
|
|||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
key,
|
||||
null,
|
||||
);
|
||||
|
||||
consoleDebug('Rule evaluation result:', result);
|
||||
|
|
@ -884,27 +871,6 @@ export function TransactionList({
|
|||
let finalNotes = transaction.notes;
|
||||
let finalNotesIv = transaction.notes_iv;
|
||||
|
||||
if (result.note && result.noteIv) {
|
||||
consoleDebug('Adding note from rule');
|
||||
const decryptedRuleNote = await decrypt(
|
||||
result.note,
|
||||
key,
|
||||
result.noteIv,
|
||||
);
|
||||
const combinedNote = appendNoteIfNotPresent(
|
||||
transaction.decryptedNotes,
|
||||
decryptedRuleNote,
|
||||
);
|
||||
|
||||
if (combinedNote !== transaction.decryptedNotes) {
|
||||
finalNotes = combinedNote;
|
||||
finalNotesIv = null;
|
||||
consoleDebug('Combined notes with rule note');
|
||||
} else {
|
||||
consoleDebug('Rule note already present, skipping');
|
||||
}
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
category_id: result.categoryId,
|
||||
notes: finalNotes,
|
||||
|
|
@ -923,16 +889,7 @@ export function TransactionList({
|
|||
null
|
||||
: null;
|
||||
|
||||
let decryptedNotes = transaction.decryptedNotes;
|
||||
if (finalNotes && !finalNotesIv) {
|
||||
decryptedNotes = finalNotes;
|
||||
} else if (finalNotes && finalNotesIv) {
|
||||
decryptedNotes = await decrypt(
|
||||
finalNotes,
|
||||
key,
|
||||
finalNotesIv,
|
||||
);
|
||||
}
|
||||
const decryptedNotes = transaction.decryptedNotes;
|
||||
|
||||
const updatedTransaction = {
|
||||
...transaction,
|
||||
|
|
@ -961,14 +918,7 @@ export function TransactionList({
|
|||
consoleDebug('=== Re-evaluation complete ===');
|
||||
}
|
||||
},
|
||||
[
|
||||
isKeySet,
|
||||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
updateTransaction,
|
||||
automationRules,
|
||||
],
|
||||
[categories, accounts, banks, updateTransaction, automationRules],
|
||||
);
|
||||
|
||||
async function handleBulkReEvaluateRules() {
|
||||
|
|
@ -983,18 +933,6 @@ export function TransactionList({
|
|||
|
||||
setIsReEvaluating(true);
|
||||
try {
|
||||
const keyString = getStoredKey();
|
||||
if (!keyString || !isKeySet) {
|
||||
consoleDebug('❌ Encryption key not set');
|
||||
console.error('Encryption key not set');
|
||||
toast.error(
|
||||
'Please unlock your encryption key to re-evaluate rules',
|
||||
);
|
||||
return;
|
||||
}
|
||||
consoleDebug('✓ Encryption key found');
|
||||
|
||||
const key = await importKey(keyString);
|
||||
consoleDebug(`Found ${automationRules.length} automation rules`);
|
||||
|
||||
if (automationRules.length === 0) {
|
||||
|
|
@ -1031,36 +969,15 @@ export function TransactionList({
|
|||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
key,
|
||||
null,
|
||||
);
|
||||
|
||||
consoleDebug('Rule evaluation result:', result);
|
||||
|
||||
if (result) {
|
||||
consoleDebug('✓ Rule matched! Applying changes...');
|
||||
let finalNotes = transaction.notes;
|
||||
let finalNotesIv = transaction.notes_iv;
|
||||
|
||||
if (result.note && result.noteIv) {
|
||||
consoleDebug('Adding note from rule');
|
||||
const decryptedRuleNote = await decrypt(
|
||||
result.note,
|
||||
key,
|
||||
result.noteIv,
|
||||
);
|
||||
const combinedNote = appendNoteIfNotPresent(
|
||||
transaction.decryptedNotes,
|
||||
decryptedRuleNote,
|
||||
);
|
||||
|
||||
if (combinedNote !== transaction.decryptedNotes) {
|
||||
finalNotes = combinedNote;
|
||||
finalNotesIv = null;
|
||||
consoleDebug('Combined notes with rule note');
|
||||
} else {
|
||||
consoleDebug('Rule note already present, skipping');
|
||||
}
|
||||
}
|
||||
const finalNotes = transaction.notes;
|
||||
const finalNotesIv = transaction.notes_iv;
|
||||
|
||||
const updateData = {
|
||||
category_id: result.categoryId,
|
||||
|
|
@ -1080,16 +997,7 @@ export function TransactionList({
|
|||
null
|
||||
: null;
|
||||
|
||||
let decryptedNotes = transaction.decryptedNotes;
|
||||
if (finalNotes && !finalNotesIv) {
|
||||
decryptedNotes = finalNotes;
|
||||
} else if (finalNotes && finalNotesIv) {
|
||||
decryptedNotes = await decrypt(
|
||||
finalNotes,
|
||||
key,
|
||||
finalNotesIv,
|
||||
);
|
||||
}
|
||||
const decryptedNotes = transaction.decryptedNotes;
|
||||
|
||||
updates.push({
|
||||
transaction,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
import { useLocale } from '@/hooks/use-locale';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
|
|
@ -55,6 +56,7 @@ export function AmountDisplay({
|
|||
highlightPositive = false,
|
||||
}: AmountDisplayProps) {
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
const locale = useLocale();
|
||||
const [amount, setAmount] = useState<number>(amountInCents / 100);
|
||||
const isPositive = amountInCents > 0
|
||||
|
||||
|
|
@ -73,13 +75,13 @@ export function AmountDisplay({
|
|||
}, [amountInCents, shouldHideAmount]);
|
||||
|
||||
const formatted = useMemo(() => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
minimumFractionDigits,
|
||||
maximumFractionDigits,
|
||||
}).format(amount);
|
||||
}, [amount, currencyCode, minimumFractionDigits, maximumFractionDigits]);
|
||||
}, [amount, currencyCode, minimumFractionDigits, maximumFractionDigits, locale]);
|
||||
|
||||
const getBackgroundClass = (shouldHideAmount: boolean) => {
|
||||
if (!highlightPositive && !shouldHideAmount) return '';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,4 @@
|
|||
import { decrypt, importKey } from '@/lib/crypto';
|
||||
import { getStoredKey } from '@/lib/key-storage';
|
||||
import { evaluateRules } from '@/lib/rule-engine';
|
||||
import { appendNoteIfNotPresent } from '@/lib/utils';
|
||||
import { transactionSyncService } from '@/services/transaction-sync';
|
||||
import type { Account, Bank } from '@/types/account';
|
||||
import type { AutomationRule } from '@/types/automation-rule';
|
||||
|
|
@ -34,14 +31,6 @@ export function useReEvaluateAllTransactions() {
|
|||
return;
|
||||
}
|
||||
|
||||
const keyString = getStoredKey();
|
||||
if (!keyString) {
|
||||
toast.error('Please unlock your encryption key');
|
||||
return;
|
||||
}
|
||||
|
||||
const key = await importKey(keyString);
|
||||
|
||||
if (!automationRules.length) {
|
||||
toast.error('No automation rules found');
|
||||
return;
|
||||
|
|
@ -71,34 +60,14 @@ export function useReEvaluateAllTransactions() {
|
|||
categories,
|
||||
accounts,
|
||||
banks,
|
||||
key,
|
||||
null,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
let finalNotes = transaction.notes;
|
||||
let finalNotesIv = transaction.notes_iv;
|
||||
|
||||
if (result.note && result.noteIv) {
|
||||
const decryptedRuleNote = await decrypt(
|
||||
result.note,
|
||||
key,
|
||||
result.noteIv,
|
||||
);
|
||||
const combinedNote = appendNoteIfNotPresent(
|
||||
transaction.decryptedNotes,
|
||||
decryptedRuleNote,
|
||||
);
|
||||
|
||||
if (combinedNote !== transaction.decryptedNotes) {
|
||||
finalNotes = combinedNote;
|
||||
finalNotesIv = null;
|
||||
}
|
||||
}
|
||||
|
||||
await transactionSyncService.update(transaction.id, {
|
||||
category_id: result.categoryId,
|
||||
notes: finalNotes,
|
||||
notes_iv: finalNotesIv,
|
||||
notes: transaction.notes,
|
||||
notes_iv: transaction.notes_iv,
|
||||
});
|
||||
|
||||
successCount++;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export type FieldType = 'string' | 'number' | 'date' | 'nullable';
|
||||
export type FieldType = 'string' | 'number';
|
||||
|
||||
export type Operator =
|
||||
| 'contains'
|
||||
|
|
@ -35,21 +35,11 @@ export const FIELD_CONFIG: Record<
|
|||
type: 'string',
|
||||
operators: ['contains', 'equals'],
|
||||
},
|
||||
notes: {
|
||||
label: 'Notes',
|
||||
type: 'nullable',
|
||||
operators: ['contains', 'equals', 'is_empty', 'is_not_empty'],
|
||||
},
|
||||
amount: {
|
||||
label: 'Amount',
|
||||
type: 'number',
|
||||
operators: ['equals', 'greater_than', 'less_than'],
|
||||
},
|
||||
transaction_date: {
|
||||
label: 'Transaction Date',
|
||||
type: 'date',
|
||||
operators: ['equals'],
|
||||
},
|
||||
bank_name: {
|
||||
label: 'Bank Name',
|
||||
type: 'string',
|
||||
|
|
@ -62,7 +52,7 @@ export const FIELD_CONFIG: Record<
|
|||
},
|
||||
category: {
|
||||
label: 'Category',
|
||||
type: 'nullable',
|
||||
type: 'string',
|
||||
operators: ['equals', 'is_empty', 'is_not_empty'],
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ export async function prepareTransactionData(
|
|||
accounts: Account[],
|
||||
banks: Bank[],
|
||||
categories: Category[],
|
||||
encryptionKey: CryptoKey,
|
||||
encryptionKey: CryptoKey | null,
|
||||
): Promise<TransactionData> {
|
||||
const account = accounts.find((a) => a.id === transaction.account_id);
|
||||
const bank = account?.bank?.id
|
||||
|
|
@ -136,7 +136,7 @@ export async function evaluateRules(
|
|||
categories: Category[],
|
||||
accounts: Account[],
|
||||
banks: Bank[],
|
||||
encryptionKey: CryptoKey,
|
||||
encryptionKey: CryptoKey | null,
|
||||
): Promise<RuleEvaluationResult | null> {
|
||||
const sortedRules = [...rules].sort((a, b) => a.priority - b.priority);
|
||||
|
||||
|
|
@ -201,7 +201,7 @@ export async function evaluateRulesForTransactions(
|
|||
categories: Category[],
|
||||
accounts: Account[],
|
||||
banks: Bank[],
|
||||
encryptionKey: CryptoKey,
|
||||
encryptionKey: CryptoKey | null,
|
||||
): Promise<Map<string, RuleEvaluationResult>> {
|
||||
const results = new Map<string, RuleEvaluationResult>();
|
||||
|
||||
|
|
@ -237,7 +237,7 @@ export async function evaluateRulesForNewTransaction(
|
|||
categories: Category[],
|
||||
accounts: Account[],
|
||||
banks: Bank[],
|
||||
encryptionKey: CryptoKey,
|
||||
encryptionKey: CryptoKey | null,
|
||||
): Promise<RuleEvaluationResult | null> {
|
||||
if (!rules || !categories || !accounts || !banks) {
|
||||
consoleDebug(
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import { CreateAutomationRuleDialog } from '@/components/automation-rules/create
|
|||
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 { LabelBadges } from '@/components/shared/label-combobox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
|
|
@ -47,13 +46,11 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||
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 Label } from '@/types/label';
|
||||
import { __ } from '@/utils/i18n';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
|
|
@ -66,11 +63,9 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||
function AutomationRuleActions({
|
||||
rule,
|
||||
categories,
|
||||
labels,
|
||||
}: {
|
||||
rule: AutomationRule;
|
||||
categories: Category[];
|
||||
labels: Label[];
|
||||
}) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
|
@ -105,7 +100,6 @@ function AutomationRuleActions({
|
|||
<EditAutomationRuleDialog
|
||||
rule={rule}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
|
|
@ -121,11 +115,9 @@ function AutomationRuleActions({
|
|||
function AutomationRuleRow({
|
||||
row,
|
||||
categories,
|
||||
labels,
|
||||
}: {
|
||||
row: Row<AutomationRule>;
|
||||
categories: Category[];
|
||||
labels: Label[];
|
||||
}) {
|
||||
const rule = row.original;
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
|
@ -171,7 +163,6 @@ function AutomationRuleRow({
|
|||
<EditAutomationRuleDialog
|
||||
rule={rule}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
|
|
@ -185,14 +176,12 @@ function AutomationRuleRow({
|
|||
}
|
||||
|
||||
export default function AutomationRules() {
|
||||
const { isKeySet } = useEncryptionKey();
|
||||
const { automationRules: rawRules } = usePage<{
|
||||
automationRules: AutomationRule[];
|
||||
}>().props;
|
||||
|
||||
// Get categories and labels from globally shared Inertia data
|
||||
// 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) => ({
|
||||
|
|
@ -204,29 +193,13 @@ export default function AutomationRules() {
|
|||
})),
|
||||
[rawRules],
|
||||
);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{
|
||||
id: 'priority',
|
||||
desc: false,
|
||||
},
|
||||
]);
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
{},
|
||||
);
|
||||
|
||||
const columns: ColumnDef<AutomationRule>[] = [
|
||||
{
|
||||
accessorKey: 'priority',
|
||||
header: __('Priority'),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="font-medium">
|
||||
{row.getValue('priority')}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: __('Title'),
|
||||
|
|
@ -262,14 +235,6 @@ export default function AutomationRules() {
|
|||
{actions.category.name}
|
||||
</Badge>
|
||||
)}
|
||||
{actions.hasLabels && actions.labels && (
|
||||
<LabelBadges labels={actions.labels} max={2} />
|
||||
)}
|
||||
{actions.hasNote && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
+ note
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -291,13 +256,9 @@ export default function AutomationRules() {
|
|||
);
|
||||
}
|
||||
|
||||
if (actions.type === 'labels' && actions.labels) {
|
||||
return <LabelBadges labels={actions.labels} max={3} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{__('Add note')}
|
||||
{__('No action set')}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
|
|
@ -309,7 +270,6 @@ export default function AutomationRules() {
|
|||
<AutomationRuleActions
|
||||
rule={row.original}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
@ -362,8 +322,6 @@ export default function AutomationRules() {
|
|||
/>
|
||||
<CreateAutomationRuleDialog
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
disabled={!isKeySet}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -405,7 +363,6 @@ export default function AutomationRules() {
|
|||
key={row.id}
|
||||
row={row}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ it('can create an automation rule with visual builder', function () {
|
|||
->click('button:has-text("Create Rule")')
|
||||
->wait(0.5)
|
||||
->fill('title', 'Test Rule')
|
||||
->fill('priority', '10')
|
||||
->assertSee('Conditions')
|
||||
->assertSee('Description')
|
||||
->fill('input[placeholder="Value"]', 'grocery')
|
||||
|
|
@ -42,7 +41,7 @@ it('can create an automation rule with visual builder', function () {
|
|||
$this->assertDatabaseHas('automation_rules', [
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Test Rule',
|
||||
'priority' => 10,
|
||||
'priority' => 0,
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -62,7 +61,6 @@ it('can add multiple conditions to a group', function () {
|
|||
->click('button:has-text("Create Rule")')
|
||||
->wait(0.5)
|
||||
->fill('title', 'Multi-Condition Rule')
|
||||
->fill('priority', '5')
|
||||
->fill('input[placeholder="Value"]', 'grocery')
|
||||
->click('Add Condition')
|
||||
->wait(0.5)
|
||||
|
|
@ -100,7 +98,6 @@ it('can add multiple groups', function () {
|
|||
->click('button:has-text("Create Rule")')
|
||||
->wait(0.5)
|
||||
->fill('title', 'Multi-Group Rule')
|
||||
->fill('priority', '3')
|
||||
->fill('input[placeholder="Value"]', 'grocery')
|
||||
->click('Add Group')
|
||||
->wait(0.5)
|
||||
|
|
@ -138,7 +135,6 @@ it('can select different field types and operators', function () {
|
|||
->click('button:has-text("Create Rule")')
|
||||
->wait(0.5)
|
||||
->fill('title', 'Amount Rule')
|
||||
->fill('priority', '1')
|
||||
->click('button:has-text("Description")')
|
||||
->wait(0.5)
|
||||
->click('[role="option"]:has-text("Amount")')
|
||||
|
|
@ -179,7 +175,6 @@ it('can edit an existing rule with visual builder', function () {
|
|||
->click('button:has-text("Create Rule")')
|
||||
->wait(0.5)
|
||||
->fill('title', 'Original Rule')
|
||||
->fill('priority', '5')
|
||||
->fill('input[placeholder="Value"]', 'grocery')
|
||||
->click('[data-testid="action-category-select"]')
|
||||
->wait(0.5)
|
||||
|
|
@ -222,7 +217,6 @@ it('validates that at least one condition is required', function () {
|
|||
->click('button:has-text("Create Rule")')
|
||||
->wait(0.5)
|
||||
->fill('title', 'Invalid Rule')
|
||||
->fill('priority', '1')
|
||||
->click('[data-testid="action-category-select"]')
|
||||
->wait(0.5)
|
||||
->click('Entertainment')
|
||||
|
|
@ -252,7 +246,6 @@ it('can toggle group operators between AND and OR', function () {
|
|||
->click('button:has-text("Create Rule")')
|
||||
->wait(0.5)
|
||||
->fill('title', 'OR Rule')
|
||||
->fill('priority', '1')
|
||||
->fill('input[placeholder="Value"]', 'test')
|
||||
->click('Add Condition')
|
||||
->wait(0.5)
|
||||
|
|
@ -293,7 +286,6 @@ it('can use is empty operator for nullable fields', function () {
|
|||
->click('button:has-text("Create Rule")')
|
||||
->wait(0.5)
|
||||
->fill('title', 'Empty Category Rule')
|
||||
->fill('priority', '1')
|
||||
->click('button:has-text("Description")')
|
||||
->wait(0.5)
|
||||
->click('[role="option"]:has-text("Category")')
|
||||
|
|
|
|||
Loading…
Reference in New Issue