Prompt automation after categorizing transactions (#378)

## Summary
- show Automatize toast after category assignment in categorizer,
transactions table, and edit modal
- add modal flow for creating a new automation rule or updating an
existing one
- extract shared automation rule form and add rule helper tests

## Verification
- npm run test -- resources/js/lib/rule-builder-utils.test.ts
- npx eslint changed frontend files
- npm run build (app compiled; Sentry sourcemap upload failed with TLS
error)


https://github.com/user-attachments/assets/1f7d90e8-520a-4eaf-9fc4-4124adb848a0
This commit is contained in:
Víctor Falcón 2026-05-14 08:17:12 +01:00 committed by GitHub
parent e71a743a0a
commit aa36bb8a86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1037 additions and 424 deletions

View File

@ -132,6 +132,7 @@
"Add note": "Agregar nota",
"Add notes...": "Agregar notas...",
"Add the app to your home screen for the best experience.": "Añade la app a tu pantalla de inicio para la mejor experiencia.",
"Add this description to a rule.": "Agrega esta descripción a una regla.",
"Add to Home Screen": "Añadir a pantalla de inicio",
"Add transaction": "Agregar transacción",
"Additional notes about this property": "Notas adicionales sobre esta propiedad",
@ -210,6 +211,9 @@
"Automation rules": "Reglas de automatización",
"Automation rules settings": "Configuración de reglas de automatización",
"Automation rules to categorize transactions automatically.": "Reglas de automatización para categorizar transacciones automáticamente.",
"Automatize": "Automatizar",
"Automatize Categorization": "Automatizar categorización",
"Automatize the categorization of future transactions like this one": "Automatizar la categorización de futuras transacciones como esta",
"Available:": "Disponible:",
"Back": "Atrás",
"Back to Transactions": "Volver a Transacciones",
@ -383,6 +387,7 @@
"Create Your First Account": "Crea Tu Primera Cuenta",
"Create a Password": "Crear una Contraseña",
"Create a bank account, loan, or property to track it manually.": "Crea una cuenta bancaria, un prestamo o una propiedad para seguirla manualmente.",
"Create a new rule": "Crear una nueva regla",
"Create a new transaction": "Crea una nueva transacción",
"Create a new transaction.": "Crea una nueva transacción.",
"Create a rule to automatically categorize transactions and add labels.": "Crea una regla para categorizar automáticamente transacciones y agregar etiquetas.",
@ -394,6 +399,7 @@
"Create budgets that adapt to your spending habits and goals.": "Crea presupuestos que se adaptan a tus hábitos de gasto y metas.",
"Create budgets that adapt to your spending habits and help you reach your goals.": "Crea presupuestos que se adapten a tus hábitos de gasto y te ayuden a alcanzar tus objetivos.",
"Create new account": "Crear nueva cuenta",
"Create or update a rule so future transactions with this description are categorized automatically.": "Crea o actualiza una regla para que futuras transacciones con esta descripción se categoricen automáticamente.",
"Create realistic budgets based on your actual spending habits.": "Crea presupuestos realistas basados en tus hábitos de gasto reales.",
"Create rules like \"If description contains 'AMAZON', categorize as Shopping\"": "Crea reglas como \"Si la descripción contiene 'AMAZON', categorizar como Compras\"",
"Create rules like \"If description contains \\'AMAZON\\', categorize as Shopping\"": "Crea reglas como \"Si la descripción contiene 'AMAZON', categorizar como Compras\"",
@ -838,6 +844,7 @@
"Michael R.": "Michael R.",
"Min": "Mín",
"Mobile app (iOS & Android)": "App móvil (iOS y Android)",
"Modify an existing rule": "Modificar una regla existente",
"Money Flow": "Flujo de Caja",
"Money coming into an account from a source (e.g., salary, refunds, interest). Increases your balance.": "Dinero que entra en una cuenta desde una fuente (ej., salario, reembolsos, intereses). Aumenta tu balance.",
"Money going out of an account to pay for something (e.g., groceries, rent, subscriptions). Decreases your balance.": "Dinero que sale de una cuenta para pagar algo (ej., supermercado, alquiler, suscripciones). Disminuye tu balance.",
@ -889,6 +896,7 @@
"No accounts found. Please create an account first.": "No se encontraron cuentas. Por favor crea una cuenta primero.",
"No action set": "Sin acción establecida",
"No active period": "Sin periodo activo",
"No automation rules available.": "No hay reglas de automatización disponibles.",
"No automation rules found.": "No se encontraron reglas de automatización.",
"No balance data available": "No hay datos de balance disponibles",
"No balance records found.": "No se encontraron registros de balance.",
@ -898,6 +906,7 @@
"No cashflow data for this period": "Sin datos de flujo de efectivo para este período",
"No categories found.": "No se encontraron categorías.",
"No category found.": "No se encontró categoría.",
"No category set": "Sin categoría definida",
"No expenses this period": "Sin gastos en este período",
"No income this period": "Sin ingresos en este período",
"No labels found.": "No se encontraron etiquetas.",
@ -1023,10 +1032,10 @@
"Profile Settings": "Configuración del Perfil",
"Profile information": "Información del perfil",
"Projected": "Proyectado",
"Projected mortgage": "Hipoteca proyectada",
"Property Details": "Detalles de Propiedad",
"Property Type": "Tipo de Propiedad",
"Property address": "Dirección de la propiedad",
"Projected mortgage": "Hipoteca proyectada",
"Protect your privacy with no tracking or third-party analytics.": "Protege tu privacidad sin rastreo ni análisis de terceros.",
"Purchase Date": "Fecha de Compra",
"Purchase Price": "Precio de Compra",
@ -1217,6 +1226,7 @@
"Start My Financial Journey": "Comenzar Mi Viaje Financiero",
"Start Your Financial Journey": "Comienza Tu Viaje Financiero",
"Start managing your money privately. No credit card required.": "Empieza a gestionar tu dinero de forma privada. No se requiere tarjeta de crédito.",
"Start with this category and transaction description.": "Empieza con esta categoría y descripción de transacción.",
"Starting day": "Día de inicio",
"Statement": "Extracto",
"Status": "Estado",
@ -1360,6 +1370,7 @@
"Tracking:": "Seguimiento:",
"Transaction": "Transacción",
"Transaction Date": "Fecha transaccion",
"Transaction categorized": "Transacción categorizada",
"Transaction created successfully": "Transacción creada exitosamente",
"Transaction created, but failed to update balance": "Transacción creada, pero no se pudo actualizar el balance",
"Transaction description": "Descripción",
@ -1514,8 +1525,8 @@
"We were unable to sync your :provider connection because your credentials appear to have expired or been revoked.": "No pudimos sincronizar tu conexión con :provider porque tus credenciales parecen haber expirado o sido revocadas.",
"We're **Victor and Alvaro**, the founders of Whisper Money. Thanks so much for joining the list — it genuinely means a lot to us.": "Somos **Víctor y Álvaro**, los fundadores de Whisper Money. Muchísimas gracias por unirte a la lista — significa mucho para nosotros.",
"We're Victor and Álvaro, the founders of Whisper Money. You signed up a while back to hear about our privacy-first personal finance app, and we're excited to tell you - we're live!": "Somos Víctor y Álvaro, los fundadores de Whisper Money. Te registraste hace un tiempo para recibir noticias sobre nuestra app de finanzas personales centrada en la privacidad, y estamos emocionados de contarte: ¡ya estamos en vivo!",
"We're Víctor and Álvaro, the founders of Whisper Money. Someone you invited landed in our top 10 — that makes you a Whisper Money founder too.": "Somos Víctor y Álvaro, los fundadores de Whisper Money. Alguien a quien invitaste está entre los 10 primeros — eso te convierte en un usuario de Whisper Money desde el día uno también.",
"We're Víctor and Álvaro, the founders of Whisper Money. Now Whisper Money is open. As a thank you, your first months are on us.": "Somos Víctor y Álvaro, los fundadores de Whisper Money. Ya puedes registrate. Como agradecimiento, los primeros meses los invitamos nosotros.",
"We're Víctor and Álvaro, the founders of Whisper Money. Someone you invited landed in our top 10 — that makes you a Whisper Money founder too.": "Somos Víctor y Álvaro, los fundadores de Whisper Money. Alguien a quien invitaste está entre los 10 primeros — eso te convierte en un usuario de Whisper Money desde el día uno también.",
"We're Víctor and Álvaro, the founders of Whisper Money. You joined the waitlist a while back — thanks for waiting. Whisper Money is open, and we saved a welcome gift for you.": "Somos Víctor y Álvaro, los fundadores de Whisper Money. Te apuntaste a la lista de espera hace un tiempo — gracias por esperar. Ya puedes registrarte y te hemos guardado un regalo de bienvenida.",
"We're Víctor and Álvaro, the founders of Whisper Money. You're one of our first 10. That makes you a Whisper Money founder user.": "Somos Víctor y Álvaro, los fundadores de Whisper Money. Estás entre los 10 primeros. Eso te convierte en un usuario de Whisper Money desde el día uno.",
"We're looking through your transaction history to find expenses that match this budget. This usually takes a few seconds.": "Estamos revisando tu historial de transacciones para encontrar gastos que coincidan con este presupuesto. Esto usualmente toma unos segundos.",
@ -1533,6 +1544,7 @@
"Welcome to Whisper Money - Your Privacy-First Finance App": "Bienvenido a Whisper Money - Tu App de Finanzas con Privacidad",
"Welcome to Whisper Money, :name!": "¡Bienvenido a Whisper Money, :name!",
"Welcome to</br>Whisper Money": "Bienvenido a</br>Whisper Money",
"What do you want to do?": "¿Qué quieres hacer?",
"What features would make Whisper Money more useful for you?": "¿Qué funciones harían que Whisper Money fuera más útil para ti?",
"What file formats are supported for import?": "¿Qué formatos de archivo se admiten para la importación?",
"What happens after you confirm?": "¿Qué pasa después de confirmar?",

View File

@ -0,0 +1,249 @@
import { AutomationRuleForm } from '@/components/automation-rules/automation-rule-form';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
addDescriptionMatchToRuleStructure,
createDescriptionCondition,
parseJsonLogic,
type RuleStructure,
} from '@/lib/rule-builder-utils';
import type { AutomationRule } from '@/types/automation-rule';
import type { Category } from '@/types/category';
import type { Label } from '@/types/label';
import type { DecryptedTransaction } from '@/types/transaction';
import { __ } from '@/utils/i18n';
import { usePage } from '@inertiajs/react';
import { ArrowLeft, FilePlus2, ListPlus } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
export interface AutomateCategorizationCandidate {
transaction: DecryptedTransaction;
category: Category;
}
interface AutomateCategorizationDialogProps {
open: boolean;
candidate: AutomateCategorizationCandidate | null;
categories: Category[];
onOpenChange: (open: boolean) => void;
onSaved?: () => void;
}
type Step = 'choose' | 'create' | 'select-rule' | 'edit';
function descriptionRuleStructure(description: string): RuleStructure {
return {
groups: [
{
id: crypto.randomUUID(),
operator: 'or',
conditions: [createDescriptionCondition(description)],
},
],
groupOperator: 'or',
};
}
export function AutomateCategorizationDialog({
open,
candidate,
categories,
onOpenChange,
onSaved,
}: AutomateCategorizationDialogProps) {
const { automationRules: rawRules, labels } = usePage<{
automationRules: AutomationRule[];
labels: Label[];
}>().props;
const rules = useMemo(
() =>
rawRules.map((rule) => ({
...rule,
rules_json:
typeof rule.rules_json === 'string'
? JSON.parse(rule.rules_json)
: rule.rules_json,
})),
[rawRules],
);
const [step, setStep] = useState<Step>('choose');
const [selectedRule, setSelectedRule] = useState<AutomationRule | null>(
null,
);
useEffect(() => {
if (open) {
setStep('choose');
setSelectedRule(null);
}
}, [open, candidate]);
if (!candidate) {
return null;
}
const description =
candidate.transaction.decryptedDescription ||
candidate.transaction.description;
const createInitialStructure = descriptionRuleStructure(description);
const editInitialStructure = selectedRule
? addDescriptionMatchToRuleStructure(
parseJsonLogic(selectedRule.rules_json),
description,
)
: undefined;
const handleSaved = () => {
onOpenChange(false);
onSaved?.();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-x-hidden sm:max-w-[640px]">
<DialogHeader>
<DialogTitle>
{step === 'create'
? __('Create Automation Rule')
: step === 'edit'
? __('Edit Automation Rule')
: __('Automatize Categorization')}
</DialogTitle>
<DialogDescription>
{__(
'Create or update a rule so future transactions with this description are categorized automatically.',
)}
</DialogDescription>
</DialogHeader>
<div className="rounded border-l-primary/60 bg-muted/40 px-4 py-3 text-sm">
<div className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{__('Transaction')}
</div>
<div className="mt-1 font-medium text-foreground">
{candidate.category.name}
</div>
<div className="mt-1 line-clamp-2 text-muted-foreground">
{description}
</div>
</div>
{step === 'choose' && (
<div className="grid gap-3">
<h4 className="text-sm font-medium opacity-75">
{__('What do you want to do?')}
</h4>
<Button
type="button"
variant="outline"
className="h-auto items-start justify-start gap-3 p-4 text-left whitespace-normal"
onClick={() => setStep('create')}
>
<FilePlus2 className="mt-0.5 h-5 w-5" />
<span className="min-w-0 flex-1 whitespace-normal">
<span className="block font-medium whitespace-normal">
{__('Create a new rule')}
</span>
<span className="mt-1 block text-sm leading-5 break-words whitespace-normal text-muted-foreground">
{__(
'Start with this category and transaction description.',
)}
</span>
</span>
</Button>
<Button
type="button"
variant="outline"
className="h-auto items-start justify-start gap-3 p-4 text-left whitespace-normal"
onClick={() => setStep('select-rule')}
disabled={rules.length === 0}
>
<ListPlus className="mt-0.5 h-5 w-5" />
<span className="min-w-0 flex-1 whitespace-normal">
<span className="block font-medium whitespace-normal">
{__('Modify an existing rule')}
</span>
<span className="mt-1 block text-sm leading-5 break-words whitespace-normal text-muted-foreground">
{rules.length === 0
? __('No automation rules available.')
: __('Add this description to a rule.')}
</span>
</span>
</Button>
</div>
)}
{step === 'select-rule' && (
<div className="space-y-3">
<Button
type="button"
variant="ghost"
size="sm"
className="gap-2"
onClick={() => setStep('choose')}
>
<ArrowLeft className="h-4 w-4" />
{__('Back')}
</Button>
<div className="max-h-80 space-y-2 overflow-y-auto">
{rules.map((rule) => (
<Button
key={rule.id}
type="button"
variant="outline"
className="h-auto w-full justify-start p-3 text-left"
onClick={() => {
setSelectedRule(rule);
setStep('edit');
}}
>
<span>
<span className="block font-medium">
{rule.title}
</span>
<span className="block text-sm text-muted-foreground">
{rule.category?.name ??
__('No category set')}
</span>
</span>
</Button>
))}
</div>
</div>
)}
{step === 'create' && (
<AutomationRuleForm
mode="create"
categories={categories}
labels={labels}
initialTitle={candidate.category.name}
initialRuleStructure={createInitialStructure}
initialCategoryId={String(candidate.category.id)}
onCancel={() => setStep('choose')}
onSuccess={handleSaved}
/>
)}
{step === 'edit' && selectedRule && editInitialStructure && (
<AutomationRuleForm
key={`${selectedRule.id}-${description}`}
mode="edit"
rule={selectedRule}
categories={categories}
labels={labels}
initialRuleStructure={editInitialStructure}
onCancel={() => setStep('select-rule')}
onSuccess={handleSaved}
/>
)}
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,253 @@
import {
store,
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 { Input } from '@/components/ui/input';
import { Label as FormLabel } from '@/components/ui/label';
import {
buildJsonLogic,
createEmptyGroup,
isValidRuleStructure,
parseJsonLogic,
type RuleStructure,
} from '@/lib/rule-builder-utils';
import type { AutomationRule } from '@/types/automation-rule';
import type { Category } from '@/types/category';
import type { Label } from '@/types/label';
import { __ } from '@/utils/i18n';
import { router } from '@inertiajs/react';
import { useEffect, useState } from 'react';
interface AutomationRuleFormProps {
mode: 'create' | 'edit';
categories: Category[];
labels: Label[];
rule?: AutomationRule;
initialTitle?: string;
initialRuleStructure?: RuleStructure;
initialCategoryId?: string;
onCancel: () => void;
onSuccess?: () => void;
}
export function AutomationRuleForm({
mode,
categories,
labels,
rule,
initialTitle = '',
initialRuleStructure,
initialCategoryId = '',
onCancel,
onSuccess,
}: AutomationRuleFormProps) {
const [title, setTitle] = useState(initialTitle);
const [ruleStructure, setRuleStructure] = useState<RuleStructure>(
initialRuleStructure ?? {
groups: [createEmptyGroup()],
groupOperator: 'or',
},
);
const [categoryId, setCategoryId] = useState<string>(initialCategoryId);
const [labelIds, setLabelIds] = useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
if (mode === 'edit' && rule) {
setTitle(initialTitle || rule.title);
setRuleStructure(
initialRuleStructure ?? parseJsonLogic(rule.rules_json),
);
setCategoryId(
initialCategoryId ||
(rule.action_category_id
? String(rule.action_category_id)
: ''),
);
setLabelIds(rule.labels?.map((label) => label.id) ?? []);
setErrors({});
return;
}
setTitle(initialTitle);
setRuleStructure(
initialRuleStructure ?? {
groups: [createEmptyGroup()],
groupOperator: 'or',
},
);
setCategoryId(initialCategoryId);
setLabelIds([]);
setErrors({});
}, [initialCategoryId, initialRuleStructure, initialTitle, mode, rule]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
if (!title.trim()) {
setErrors((prev) => ({ ...prev, title: 'Title is required' }));
return;
}
if (!isValidRuleStructure(ruleStructure)) {
setErrors((prev) => ({
...prev,
rules_json: 'At least one valid condition is required',
}));
return;
}
if (!categoryId && labelIds.length === 0) {
setErrors((prev) => ({
...prev,
action_category_id:
'At least one category or label is required',
}));
return;
}
setIsSubmitting(true);
try {
const jsonLogic = buildJsonLogic(ruleStructure);
const payload = {
title: title.trim(),
priority: rule?.priority ?? 0,
rules_json: JSON.stringify(jsonLogic),
action_category_id: categoryId || null,
action_note: null,
action_note_iv: null,
action_label_ids: labelIds,
};
const options = {
preserveState: true,
preserveScroll: true,
onSuccess: () => {
setErrors({});
onSuccess?.();
},
onError: (errors: Record<string, string>) => {
setErrors(errors);
},
onFinish: () => {
setIsSubmitting(false);
},
};
if (mode === 'edit' && rule) {
router.patch(update(rule.id).url, payload, options);
return;
}
router.post(store().url, payload, options);
} catch (error) {
console.error('Failed to submit automation rule:', error);
setIsSubmitting(false);
}
};
const actionError =
errors.action_category_id ||
errors.action_label_ids ||
errors['action_label_ids.0'];
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<FormLabel htmlFor="title">{__('Title')}</FormLabel>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={__('Rule title')}
required
/>
{errors.title && (
<p className="text-sm text-red-500">{errors.title}</p>
)}
</div>
<RuleBuilder
value={ruleStructure}
onChange={setRuleStructure}
error={errors.rules_json}
/>
<div className="space-y-4 rounded-md border p-4">
<h4 className="font-medium">{__('Actions')}</h4>
<div className="space-y-2">
<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>
<CategoryCombobox
value={categoryId}
onValueChange={setCategoryId}
categories={categories}
placeholder={__('Select a category')}
showUncategorized={false}
data-testid="action-category-select"
/>
</div>
<div className="space-y-2">
<FormLabel>{__('Add Labels')}</FormLabel>
<LabelCombobox
value={labelIds}
onValueChange={setLabelIds}
labels={labels}
placeholder={__('Select labels')}
/>
</div>
{actionError && (
<p className="text-sm text-red-500">{actionError}</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isSubmitting}
>
{__('Cancel')}
</Button>
<Button
type="submit"
disabled={isSubmitting}
data-testid="submit-automation-rule"
>
{isSubmitting
? mode === 'create'
? __('Creating...')
: __('Saving...')
: mode === 'create'
? __('Create')
: __('Save Changes')}
</Button>
</div>
</form>
);
}

View File

@ -1,8 +1,4 @@
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 { AutomationRuleForm } from '@/components/automation-rules/automation-rule-form';
import { CreateButton } from '@/components/ui/create-button';
import {
Dialog,
@ -12,24 +8,20 @@ import {
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label as FormLabel } from '@/components/ui/label';
import {
buildJsonLogic,
createEmptyGroup,
isValidRuleStructure,
type RuleStructure,
} from '@/lib/rule-builder-utils';
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';
import { type ReactNode, useState } from 'react';
interface CreateAutomationRuleDialogProps {
categories: Category[];
labels: Label[];
disabled?: boolean;
initialTitle?: string;
initialRuleStructure?: RuleStructure;
initialCategoryId?: string;
trigger?: ReactNode;
onSuccess?: () => void;
}
@ -37,96 +29,22 @@ export function CreateAutomationRuleDialog({
categories,
labels,
disabled = false,
initialTitle,
initialRuleStructure,
initialCategoryId,
trigger,
onSuccess,
}: CreateAutomationRuleDialogProps) {
const [open, setOpen] = useState(false);
const [title, setTitle] = useState('');
const [ruleStructure, setRuleStructure] = useState<RuleStructure>({
groups: [createEmptyGroup()],
groupOperator: 'or',
});
const [categoryId, setCategoryId] = useState<string>('');
const [labelIds, setLabelIds] = useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
if (!title.trim()) {
setErrors((prev) => ({ ...prev, title: 'Title is required' }));
return;
}
if (!isValidRuleStructure(ruleStructure)) {
setErrors((prev) => ({
...prev,
rules_json: 'At least one valid condition is required',
}));
return;
}
if (!categoryId && labelIds.length === 0) {
setErrors((prev) => ({
...prev,
action_category_id:
'At least one category or label is required',
}));
return;
}
setIsSubmitting(true);
try {
const jsonLogic = buildJsonLogic(ruleStructure);
router.post(
store().url,
{
title: title.trim(),
priority: 0,
rules_json: JSON.stringify(jsonLogic),
action_category_id: categoryId || null,
action_note: null,
action_note_iv: null,
action_label_ids: labelIds,
},
{
preserveState: true,
preserveScroll: true,
onSuccess: () => {
setOpen(false);
setTitle('');
setRuleStructure({
groups: [createEmptyGroup()],
groupOperator: 'and',
});
setCategoryId('');
setLabelIds([]);
setErrors({});
onSuccess?.();
},
onError: (errors) => {
setErrors(errors as Record<string, string>);
},
onFinish: () => {
setIsSubmitting(false);
},
},
);
} catch (error) {
console.error('Failed to create automation rule:', error);
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<CreateButton disabled={disabled}>
{__('Create Rule')}
</CreateButton>
{trigger ?? (
<CreateButton disabled={disabled}>
{__('Create Rule')}
</CreateButton>
)}
</DialogTrigger>
<DialogContent className="overflow-x-hidden sm:max-w-[600px]">
<DialogHeader>
@ -137,100 +55,19 @@ export function CreateAutomationRuleDialog({
)}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<FormLabel htmlFor="title">{__('Title')}</FormLabel>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={__('Rule title')}
required
/>
{errors.title && (
<p className="text-sm text-red-500">
{errors.title}
</p>
)}
</div>
<RuleBuilder
value={ruleStructure}
onChange={setRuleStructure}
error={errors.rules_json}
/>
<div className="space-y-4 rounded-md border p-4">
<h4 className="font-medium">{__('Actions')}</h4>
<div className="space-y-2">
<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}
onValueChange={setCategoryId}
categories={categories}
placeholder={__('Select a category')}
showUncategorized={false}
data-testid="action-category-select"
/>
</div>
</div>
<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_label_ids ||
errors['action_label_ids.0']}
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{__('Cancel')}
</Button>
<Button
type="submit"
disabled={isSubmitting}
data-testid="submit-automation-rule"
>
{isSubmitting ? __('Creating...') : __('Create')}
</Button>
</div>
</form>
<AutomationRuleForm
mode="create"
categories={categories}
labels={labels}
initialTitle={initialTitle}
initialRuleStructure={initialRuleStructure}
initialCategoryId={initialCategoryId}
onCancel={() => setOpen(false)}
onSuccess={() => {
setOpen(false);
onSuccess?.();
}}
/>
</DialogContent>
</Dialog>
);

View File

@ -1,8 +1,4 @@
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 { AutomationRuleForm } from '@/components/automation-rules/automation-rule-form';
import {
Dialog,
DialogContent,
@ -10,27 +6,18 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
buildJsonLogic,
createEmptyGroup,
isValidRuleStructure,
parseJsonLogic,
type RuleStructure,
} from '@/lib/rule-builder-utils';
import type { RuleStructure } 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 type { Label } from '@/types/label';
import { __ } from '@/utils/i18n';
import { router } from '@inertiajs/react';
import { useEffect, useState } from 'react';
interface EditAutomationRuleDialogProps {
rule: AutomationRule;
categories: Category[];
labels: LabelType[];
labels: Label[];
open: boolean;
initialRuleStructure?: RuleStructure;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
@ -40,94 +27,10 @@ export function EditAutomationRuleDialog({
categories,
labels,
open,
initialRuleStructure,
onOpenChange,
onSuccess,
}: EditAutomationRuleDialogProps) {
const [title, setTitle] = useState('');
const [ruleStructure, setRuleStructure] = useState<RuleStructure>({
groups: [createEmptyGroup()],
groupOperator: 'and',
});
const [categoryId, setCategoryId] = useState<string>('');
const [labelIds, setLabelIds] = useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
if (rule && open) {
setTitle(rule.title);
setRuleStructure(parseJsonLogic(rule.rules_json));
setCategoryId(
rule.action_category_id ? String(rule.action_category_id) : '',
);
setLabelIds(rule.labels?.map((label) => label.id) ?? []);
}
}, [rule, open]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
if (!title.trim()) {
setErrors((prev) => ({ ...prev, title: 'Title is required' }));
return;
}
if (!isValidRuleStructure(ruleStructure)) {
setErrors((prev) => ({
...prev,
rules_json: 'At least one valid condition is required',
}));
return;
}
if (!categoryId && labelIds.length === 0) {
setErrors((prev) => ({
...prev,
action_category_id:
'At least one category or label is required',
}));
return;
}
setIsSubmitting(true);
try {
const jsonLogic = buildJsonLogic(ruleStructure);
router.patch(
update(rule.id).url,
{
title: title.trim(),
priority: rule.priority,
rules_json: JSON.stringify(jsonLogic),
action_category_id: categoryId || null,
action_note: null,
action_note_iv: null,
action_label_ids: labelIds,
},
{
preserveState: true,
preserveScroll: true,
onSuccess: () => {
onOpenChange(false);
setErrors({});
onSuccess?.();
},
onError: (errors) => {
setErrors(errors as Record<string, string>);
},
onFinish: () => {
setIsSubmitting(false);
},
},
);
} catch (error) {
console.error('Failed to update automation rule:', error);
setIsSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-x-hidden sm:max-w-[600px]">
@ -139,100 +42,18 @@ export function EditAutomationRuleDialog({
)}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">{__('Title')}</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={__('Rule title')}
required
/>
{errors.title && (
<p className="text-sm text-red-500">
{errors.title}
</p>
)}
</div>
<RuleBuilder
value={ruleStructure}
onChange={setRuleStructure}
error={errors.rules_json}
/>
<div className="space-y-4 rounded-md border p-4">
<h4 className="font-medium">{__('Actions')}</h4>
<div className="space-y-2">
<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}
categories={categories}
placeholder={__('Select a category')}
showUncategorized={false}
data-testid="action-category-select"
/>
</div>
<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_label_ids ||
errors['action_label_ids.0']}
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
{__('Cancel')}
</Button>
<Button
type="submit"
disabled={isSubmitting}
data-testid="submit-automation-rule"
>
{isSubmitting
? __('Saving...')
: __('Save Changes')}
</Button>
</div>
</form>
<AutomationRuleForm
mode="edit"
rule={rule}
categories={categories}
labels={labels}
initialRuleStructure={initialRuleStructure}
onCancel={() => onOpenChange(false)}
onSuccess={() => {
onOpenChange(false);
onSuccess?.();
}}
/>
</DialogContent>
</Dialog>
);

View File

@ -13,6 +13,11 @@ interface CategoryCellProps {
accounts: Account[];
banks: Bank[];
onUpdate: (transaction: DecryptedTransaction) => void;
onCategorized?: (
transaction: DecryptedTransaction,
category: Category,
source: 'transaction_table',
) => void;
className?: string;
withoutChevronIcon?: boolean;
}
@ -23,6 +28,7 @@ export function CategoryCell({
accounts,
banks,
onUpdate,
onCategorized,
className,
withoutChevronIcon,
}: CategoryCellProps) {
@ -61,6 +67,14 @@ export function CategoryCell({
};
onUpdate(updatedTransaction);
if (updatedCategory) {
onCategorized?.(
updatedTransaction,
updatedCategory,
'transaction_table',
);
}
} catch (error) {
console.error('Failed to update category:', error);
} finally {

View File

@ -57,6 +57,11 @@ interface EditTransactionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: (transaction: DecryptedTransaction) => void;
onCategorized?: (
transaction: DecryptedTransaction,
category: Category,
source: 'edit_transaction_modal',
) => void;
onLabelCreated?: (label: Label) => void;
mode: 'create' | 'edit';
}
@ -71,6 +76,7 @@ export function EditTransactionDialog({
open,
onOpenChange,
onSuccess,
onCategorized,
onLabelCreated,
mode,
}: EditTransactionDialogProps) {
@ -533,6 +539,18 @@ export function EditTransactionDialog({
toast.success(__('Transaction updated successfully'));
onSuccess(updatedTransaction);
if (
selectedCategoryId &&
selectedCategoryId !== transaction.category_id &&
updatedCategory
) {
onCategorized?.(
updatedTransaction,
updatedCategory,
'edit_transaction_modal',
);
}
onOpenChange(false);
// Sync to update IndexedDB

View File

@ -34,6 +34,11 @@ interface CreateColumnsOptions {
onEdit: (transaction: DecryptedTransaction) => void;
onDelete: (transaction: DecryptedTransaction) => void;
onUpdate: (transaction: DecryptedTransaction) => void;
onCategorized?: (
transaction: DecryptedTransaction,
category: Category,
source: 'transaction_table',
) => void;
onReEvaluateRules: (transaction: DecryptedTransaction) => void;
}
@ -46,6 +51,7 @@ export function createTransactionColumns({
onEdit,
onDelete,
onUpdate,
onCategorized,
onReEvaluateRules,
}: CreateColumnsOptions): ColumnDef<DecryptedTransaction>[] {
return [
@ -141,6 +147,7 @@ export function createTransactionColumns({
accounts={accounts}
banks={banks}
onUpdate={onUpdate}
onCategorized={onCategorized}
className="relative -top-0.5 max-w-[150px] md:max-w-[180px]"
withoutChevronIcon
/>

View File

@ -16,15 +16,20 @@ import { VirtualItem, Virtualizer } from '@tanstack/react-virtual';
import axios from 'axios';
import { format, getYear, isWithinInterval, parseISO } from 'date-fns';
import {
type ReactNode,
createElement,
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { toast } from 'sonner';
import { single as reEvaluateSingle } from '@/actions/App/Http/Controllers/ReEvaluateTransactionRulesController';
import {
AutomateCategorizationDialog,
type AutomateCategorizationCandidate,
} from '@/components/automation-rules/automate-categorization-dialog';
import { BulkActionsBar } from '@/components/transactions/bulk-actions-bar';
import { EditTransactionDialog } from '@/components/transactions/edit-transaction-dialog';
import { TransactionActionsMenu } from '@/components/transactions/transaction-actions-menu';
@ -59,6 +64,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 { captureEvent } from '@/lib/posthog';
import { mergeReEvaluatedTransaction } from '@/lib/transaction-re-evaluation';
import { transactionSyncService } from '@/services/transaction-sync';
import { type Account, type Bank } from '@/types/account';
@ -298,6 +304,9 @@ export function TransactionList({
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const [isBulkUpdating, setIsBulkUpdating] = useState(false);
const [isReEvaluating, setIsReEvaluating] = useState(false);
const [automateDialogOpen, setAutomateDialogOpen] = useState(false);
const [automateCandidate, setAutomateCandidate] =
useState<AutomateCategorizationCandidate | null>(null);
const [displayedCount, setDisplayedCount] = useState(pageSize);
const [isLoadingMore, setIsLoadingMore] = useState(false);
@ -952,6 +961,42 @@ export function TransactionList({
}
}
const showAutomatizeToast = useCallback(
(
transaction: DecryptedTransaction,
category: Category,
source: 'transaction_table' | 'edit_transaction_modal',
) => {
const nextAutomateCandidate = { transaction, category };
setAutomateCandidate(nextAutomateCandidate);
toast.success(__('Transaction categorized'), {
closeButton: true,
duration: 12000,
action: {
label: createElement(
'span',
{
title: __(
'Automatize the categorization of future transactions like this one',
),
},
__('Automatize'),
),
onClick: () => {
captureEvent(
'automation_rule_toast_automatize_clicked',
{ source },
);
setAutomateCandidate(nextAutomateCandidate);
setAutomateDialogOpen(true);
},
},
});
},
[],
);
const columns = useMemo(() => {
const allColumns = createTransactionColumns({
categories,
@ -962,6 +1007,7 @@ export function TransactionList({
onEdit: setEditTransaction,
onDelete: setDeleteTransaction,
onUpdate: updateTransaction,
onCategorized: showAutomatizeToast,
onReEvaluateRules: handleReEvaluateRules,
});
@ -981,6 +1027,7 @@ export function TransactionList({
labels,
locale,
updateTransaction,
showAutomatizeToast,
handleReEvaluateRules,
hideColumns,
]);
@ -1324,6 +1371,7 @@ export function TransactionList({
open={!!editTransaction}
onOpenChange={(open) => !open && setEditTransaction(null)}
onSuccess={updateTransaction}
onCategorized={showAutomatizeToast}
onLabelCreated={handleLabelCreated}
mode="edit"
/>
@ -1342,6 +1390,13 @@ export function TransactionList({
mode="create"
/>
<AutomateCategorizationDialog
open={automateDialogOpen}
candidate={automateCandidate}
categories={categories}
onOpenChange={setAutomateDialogOpen}
/>
<AlertDialog
open={!!deleteTransaction}
onOpenChange={(open) => {

View File

@ -1,6 +1,8 @@
import type { AutomateCategorizationCandidate } from '@/components/automation-rules/automate-categorization-dialog';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { decrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
import { captureEvent } from '@/lib/posthog';
import { evaluateRules } from '@/lib/rule-engine';
import { transactionSyncService } from '@/services/transaction-sync';
import { type Account, type Bank } from '@/types/account';
@ -10,9 +12,18 @@ import {
type DecryptedTransaction,
type Transaction,
} from '@/types/transaction';
import { __ } from '@/utils/i18n';
import { usePage } from '@inertiajs/react';
import { parseISO } from 'date-fns';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
createElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { toast } from 'sonner';
export type AnimationState = 'idle' | 'exiting' | 'entering' | 'success';
@ -89,6 +100,9 @@ export function useCategorizeTransactions({
useState<Category | null>(null);
const [searchValue, setSearchValue] = useState('');
const [rulesDialogOpen, setRulesDialogOpen] = useState(false);
const [automateDialogOpen, setAutomateDialogOpen] = useState(false);
const [automateCandidate, setAutomateCandidate] =
useState<AutomateCategorizationCandidate | null>(null);
const [encryptionKey, setEncryptionKey] = useState<CryptoKey | null>(null);
const [categorizedCount, setCategorizedCount] = useState(0);
const commandInputRef = useRef<HTMLInputElement>(null);
@ -322,6 +336,11 @@ export function useCategorizeTransactions({
return;
}
const nextAutomateCandidate = {
transaction: currentTransaction,
category,
};
setLastSelectedCategory(category);
setAnimationState('exiting');
@ -333,6 +352,30 @@ export function useCategorizeTransactions({
updateCategoryUsageOrder(category.id);
setCategoryUsageOrder(getCategoryUsageOrder());
setCategorizedCount((prev) => prev + 1);
setAutomateCandidate(nextAutomateCandidate);
toast.success(__('Transaction categorized'), {
closeButton: true,
duration: 12000,
action: {
label: createElement(
'span',
{
title: __(
'Automatize the categorization of future transactions like this one',
),
},
__('Automatize'),
),
onClick: () => {
captureEvent(
'automation_rule_toast_automatize_clicked',
{ source: 'categorize_flow' },
);
setAutomateCandidate(nextAutomateCandidate);
setAutomateDialogOpen(true);
},
},
});
} catch (error) {
console.error('Failed to update transaction:', error);
setAnimationState('idle');
@ -375,6 +418,20 @@ export function useCategorizeTransactions({
}, 300);
}, [animationState]);
const handleAutomateDialogOpenChange = useCallback((open: boolean) => {
setAutomateDialogOpen(open);
if (!open) {
commandInputRef.current?.focus();
}
}, []);
const handleAutomateSaved = useCallback(async () => {
setAutomateDialogOpen(false);
await applyAutomationRulesToQueue();
commandInputRef.current?.focus();
}, [applyAutomationRulesToQueue]);
const handlePrevious = useCallback(() => {
if (animationState !== 'idle' || currentIndex === 0) {
return;
@ -406,11 +463,15 @@ export function useCategorizeTransactions({
setSearchValue,
rulesDialogOpen,
setRulesDialogOpen,
automateDialogOpen,
automateCandidate,
categorizedCount,
handleCategorySelect,
handleSkip,
handlePrevious,
handleRulesDialogClose,
handleAutomateDialogOpenChange,
handleAutomateSaved,
commandInputRef,
};
}

View File

@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest';
import {
addDescriptionMatchToRuleStructure,
buildJsonLogic,
createDescriptionCondition,
type RuleStructure,
} from './rule-builder-utils';
describe('createDescriptionCondition', () => {
it('creates a contains condition for transaction descriptions', () => {
expect(createDescriptionCondition(' Toast Coffee ')).toMatchObject({
field: 'description',
operator: 'contains',
value: 'Toast Coffee',
});
});
});
describe('addDescriptionMatchToRuleStructure', () => {
it('adds a new description group to a simple rule', () => {
const structure: RuleStructure = {
groupOperator: 'or',
groups: [
{
id: 'group-1',
operator: 'or',
conditions: [
{
id: 'condition-1',
field: 'bank_name',
operator: 'contains',
value: 'bank',
},
],
},
],
};
const updated = addDescriptionMatchToRuleStructure(
structure,
'Toast Coffee',
);
expect(updated.groupOperator).toBe('or');
expect(updated.groups).toHaveLength(2);
expect(updated.groups[1].conditions[0]).toMatchObject({
field: 'description',
operator: 'contains',
value: 'Toast Coffee',
});
expect(buildJsonLogic(updated)).toMatchObject({
or: [
{ in: ['bank', { var: 'bank_name' }] },
{ in: ['Toast Coffee', { var: 'description' }] },
],
});
});
it('preserves top-level AND semantics when adding a description match', () => {
const structure: RuleStructure = {
groupOperator: 'and',
groups: [
{
id: 'group-1',
operator: 'and',
conditions: [
{
id: 'condition-1',
field: 'bank_name',
operator: 'contains',
value: 'bank',
},
{
id: 'condition-2',
field: 'amount',
operator: 'less_than',
value: '0',
},
],
},
{
id: 'group-2',
operator: 'or',
conditions: [
{
id: 'condition-3',
field: 'account_name',
operator: 'contains',
value: 'checking',
},
],
},
],
};
const updated = addDescriptionMatchToRuleStructure(
structure,
'Toast Coffee',
);
const jsonLogic = buildJsonLogic(updated);
expect(updated.groupOperator).toBe('and');
expect(updated.groups).toHaveLength(3);
expect(jsonLogic).toMatchObject({
and: [
{
or: [
{ in: ['bank', { var: 'bank_name' }] },
{ in: ['Toast Coffee', { var: 'description' }] },
],
},
{
or: [
{ '<': [{ var: 'amount' }, 0] },
{ in: ['Toast Coffee', { var: 'description' }] },
],
},
{
or: [
{ in: ['checking', { var: 'account_name' }] },
{ in: ['Toast Coffee', { var: 'description' }] },
],
},
],
});
});
});

View File

@ -122,54 +122,67 @@ export function buildJsonLogic(structure: RuleStructure): JsonLogicRule {
return { [structure.groupOperator]: groupLogics };
}
function jsonLogicArgs(value: unknown): unknown[] | null {
return Array.isArray(value) ? value : null;
}
function jsonLogicVariable(value: unknown): string | null {
if (value && typeof value === 'object' && 'var' in value) {
return String(value.var);
}
return null;
}
function parseConditionFromJsonLogic(
jsonLogic: JsonLogicRule,
): Condition | null {
const id = crypto.randomUUID();
if ('in' in jsonLogic) {
const [value, varObj] = jsonLogic.in;
if (varObj && typeof varObj === 'object' && 'var' in varObj) {
const args = jsonLogicArgs(jsonLogic.in);
const field = args ? jsonLogicVariable(args[1]) : null;
if (args && field) {
return {
id,
field: varObj.var,
field,
operator: 'contains',
value: String(value),
value: String(args[0]),
};
}
}
if ('==' in jsonLogic) {
const [varObj, value] = jsonLogic['=='];
if (varObj && typeof varObj === 'object' && 'var' in varObj) {
if (value === null) {
const args = jsonLogicArgs(jsonLogic['==']);
const field = args ? jsonLogicVariable(args[0]) : null;
if (args && field) {
if (args[1] === null) {
return {
id,
field: varObj.var,
field,
operator: 'is_empty',
value: '',
};
}
return {
id,
field: varObj.var,
field,
operator: 'equals',
value: String(value),
value: String(args[1]),
};
}
}
if ('!=' in jsonLogic) {
const [varObj, value] = jsonLogic['!='];
if (
varObj &&
typeof varObj === 'object' &&
'var' in varObj &&
value === null
) {
const args = jsonLogicArgs(jsonLogic['!=']);
const field = args ? jsonLogicVariable(args[0]) : null;
if (args && field && args[1] === null) {
return {
id,
field: varObj.var,
field,
operator: 'is_not_empty',
value: '',
};
@ -177,25 +190,29 @@ function parseConditionFromJsonLogic(
}
if ('>' in jsonLogic) {
const [varObj, value] = jsonLogic['>'];
if (varObj && typeof varObj === 'object' && 'var' in varObj) {
const args = jsonLogicArgs(jsonLogic['>']);
const field = args ? jsonLogicVariable(args[0]) : null;
if (args && field) {
return {
id,
field: varObj.var,
field,
operator: 'greater_than',
value: String(value),
value: String(args[1]),
};
}
}
if ('<' in jsonLogic) {
const [varObj, value] = jsonLogic['<'];
if (varObj && typeof varObj === 'object' && 'var' in varObj) {
const args = jsonLogicArgs(jsonLogic['<']);
const field = args ? jsonLogicVariable(args[0]) : null;
if (args && field) {
return {
id,
field: varObj.var,
field,
operator: 'less_than',
value: String(value),
value: String(args[1]),
};
}
}
@ -322,15 +339,19 @@ export function parseJsonLogic(jsonLogic: JsonLogicRule): RuleStructure {
return defaultStructure;
}
export function createEmptyCondition(): Condition {
export function createDescriptionCondition(description: string): Condition {
return {
id: crypto.randomUUID(),
field: 'description',
operator: 'contains',
value: '',
value: description.trim(),
};
}
export function createEmptyCondition(): Condition {
return createDescriptionCondition('');
}
export function createEmptyGroup(): ConditionGroup {
return {
id: crypto.randomUUID(),
@ -339,6 +360,64 @@ export function createEmptyGroup(): ConditionGroup {
};
}
function cloneCondition(condition: Condition): Condition {
return {
...condition,
id: crypto.randomUUID(),
};
}
export function addDescriptionMatchToRuleStructure(
structure: RuleStructure,
description: string,
): RuleStructure {
const descriptionCondition = createDescriptionCondition(description);
const descriptionGroup: ConditionGroup = {
id: crypto.randomUUID(),
operator: 'or',
conditions: [descriptionCondition],
};
if (structure.groups.length === 0) {
return {
groups: [descriptionGroup],
groupOperator: 'or',
};
}
if (structure.groupOperator === 'or' || structure.groups.length === 1) {
return {
groups: [...structure.groups, descriptionGroup],
groupOperator: 'or',
};
}
return {
groups: structure.groups.flatMap((group) => {
const descriptionClone = cloneCondition(descriptionCondition);
if (group.operator === 'or') {
return [
{
...group,
conditions: [...group.conditions, descriptionClone],
},
];
}
return group.conditions.map((condition) => ({
id: crypto.randomUUID(),
operator: 'or' as const,
conditions: [
cloneCondition(condition),
cloneCondition(descriptionCondition),
],
}));
}),
groupOperator: 'and',
};
}
export function isValidRuleStructure(structure: RuleStructure): boolean {
return structure.groups.some((group) =>
group.conditions.some(

View File

@ -1,4 +1,5 @@
import { categorize as categorizeRoute } from '@/actions/App/Http/Controllers/TransactionController';
import { AutomateCategorizationDialog } from '@/components/automation-rules/automate-categorization-dialog';
import { AutomationRulesDialog } from '@/components/automation-rules/automation-rules-dialog';
import { CategorizerCard } from '@/components/transactions/categorizer-card';
import { CategorizerCommand } from '@/components/transactions/categorizer-command';
@ -51,10 +52,14 @@ export default function CategorizeTransactions({
setSearchValue,
rulesDialogOpen,
setRulesDialogOpen,
automateDialogOpen,
automateCandidate,
handleCategorySelect,
handleSkip,
handlePrevious,
handleRulesDialogClose,
handleAutomateDialogOpenChange,
handleAutomateSaved,
commandInputRef,
} = useCategorizeTransactions({
categories,
@ -64,6 +69,15 @@ export default function CategorizeTransactions({
});
const backHref = categorizeRoute.url()?.replace('/categorize', '') ?? '';
const automateDialog = (
<AutomateCategorizationDialog
open={automateDialogOpen}
candidate={automateCandidate}
categories={categories}
onOpenChange={handleAutomateDialogOpenChange}
onSaved={handleAutomateSaved}
/>
);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@ -83,7 +97,7 @@ export default function CategorizeTransactions({
handlePrevious();
}
}
if (e.key === 'Escape' && !rulesDialogOpen) {
if (e.key === 'Escape' && !rulesDialogOpen && !automateDialogOpen) {
e.preventDefault();
if (backHref) {
router.visit(backHref);
@ -100,6 +114,7 @@ export default function CategorizeTransactions({
handlePrevious,
currentIndex,
rulesDialogOpen,
automateDialogOpen,
setRulesDialogOpen,
backHref,
]);
@ -154,6 +169,8 @@ export default function CategorizeTransactions({
</div>
</div>
{automateDialog}
<style>{`
@keyframes bounce-in {
0% { opacity: 0; transform: scale(0.3); }
@ -196,6 +213,7 @@ export default function CategorizeTransactions({
</Link>
</div>
</div>
{automateDialog}
</>
);
}
@ -292,6 +310,7 @@ export default function CategorizeTransactions({
open={rulesDialogOpen}
onOpenChange={handleRulesDialogClose}
/>
{automateDialog}
</>
);
}

View File

@ -13,7 +13,14 @@ import {
import { VirtualItem, Virtualizer } from '@tanstack/react-virtual';
import axios from 'axios';
import { format, getYear, parseISO } from 'date-fns';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
createElement,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { toast } from 'sonner';
import {
@ -22,6 +29,10 @@ import {
status as reEvaluateStatus,
} from '@/actions/App/Http/Controllers/ReEvaluateTransactionRulesController';
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
import {
AutomateCategorizationDialog,
type AutomateCategorizationCandidate,
} from '@/components/automation-rules/automate-categorization-dialog';
import HeadingSmall from '@/components/heading-small';
import { BulkActionsBar } from '@/components/transactions/bulk-actions-bar';
import { EditTransactionDialog } from '@/components/transactions/edit-transaction-dialog';
@ -63,10 +74,11 @@ import { Spinner } from '@/components/ui/spinner';
import { TableCell, TableRow } from '@/components/ui/table';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import {
type CursorPaginatedResponse,
isCursorPaginatedResponse,
type CursorPaginatedResponse,
} from '@/lib/cursor-pagination';
import { consoleDebug } from '@/lib/debug';
import { captureEvent } from '@/lib/posthog';
import { mergeReEvaluatedTransaction } from '@/lib/transaction-re-evaluation';
import { cn } from '@/lib/utils';
import { transactionSyncService } from '@/services/transaction-sync';
@ -430,6 +442,9 @@ export default function Transactions({
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
const [isBulkUpdating, setIsBulkUpdating] = useState(false);
const [isReEvaluating, setIsReEvaluating] = useState(false);
const [automateDialogOpen, setAutomateDialogOpen] = useState(false);
const [automateCandidate, setAutomateCandidate] =
useState<AutomateCategorizationCandidate | null>(null);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [isSelectingAll, setIsSelectingAll] = useState(false);
const [isNavigating, setIsNavigating] = useState(false);
@ -751,6 +766,42 @@ export default function Transactions({
}
}
const showAutomatizeToast = useCallback(
(
transaction: DecryptedTransaction,
category: Category,
source: 'transaction_table' | 'edit_transaction_modal',
) => {
const nextAutomateCandidate = { transaction, category };
setAutomateCandidate(nextAutomateCandidate);
toast.success(__('Transaction categorized'), {
closeButton: true,
duration: 12000,
action: {
label: createElement(
'span',
{
title: __(
'Automatize the categorization of future transactions like this one',
),
},
__('Automatize'),
),
onClick: () => {
captureEvent(
'automation_rule_toast_automatize_clicked',
{ source },
);
setAutomateCandidate(nextAutomateCandidate);
setAutomateDialogOpen(true);
},
},
});
},
[],
);
const columns = useMemo(
() =>
createTransactionColumns({
@ -762,6 +813,7 @@ export default function Transactions({
onEdit: setEditTransaction,
onDelete: setDeleteTransaction,
onUpdate: updateTransaction,
onCategorized: showAutomatizeToast,
onReEvaluateRules: handleReEvaluateRules,
}),
[
@ -771,6 +823,7 @@ export default function Transactions({
labels,
locale,
updateTransaction,
showAutomatizeToast,
handleReEvaluateRules,
],
);
@ -1167,6 +1220,7 @@ export default function Transactions({
open={!!editTransaction}
onOpenChange={(open) => !open && setEditTransaction(null)}
onSuccess={updateTransaction}
onCategorized={showAutomatizeToast}
onLabelCreated={handleLabelCreated}
mode="edit"
/>
@ -1185,6 +1239,13 @@ export default function Transactions({
mode="create"
/>
<AutomateCategorizationDialog
open={automateDialogOpen}
candidate={automateCandidate}
categories={categories}
onOpenChange={setAutomateDialogOpen}
/>
<AlertDialog
open={!!deleteTransaction && !isBulkDeleteMode}
onOpenChange={(open) => {