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:
parent
e71a743a0a
commit
aa36bb8a86
16
lang/es.json
16
lang/es.json
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue