diff --git a/lang/es.json b/lang/es.json index 3e8ccda4..190d454e 100644 --- a/lang/es.json +++ b/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
Whisper Money": "Bienvenido a
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?", diff --git a/resources/js/components/automation-rules/automate-categorization-dialog.tsx b/resources/js/components/automation-rules/automate-categorization-dialog.tsx new file mode 100644 index 00000000..5042dd50 --- /dev/null +++ b/resources/js/components/automation-rules/automate-categorization-dialog.tsx @@ -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('choose'); + const [selectedRule, setSelectedRule] = useState( + 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 ( + + + + + {step === 'create' + ? __('Create Automation Rule') + : step === 'edit' + ? __('Edit Automation Rule') + : __('Automatize Categorization')} + + + {__( + 'Create or update a rule so future transactions with this description are categorized automatically.', + )} + + + +
+
+ {__('Transaction')} +
+
+ {candidate.category.name} +
+
+ {description} +
+
+ + {step === 'choose' && ( +
+

+ {__('What do you want to do?')} +

+ + +
+ )} + + {step === 'select-rule' && ( +
+ +
+ {rules.map((rule) => ( + + ))} +
+
+ )} + + {step === 'create' && ( + setStep('choose')} + onSuccess={handleSaved} + /> + )} + + {step === 'edit' && selectedRule && editInitialStructure && ( + setStep('select-rule')} + onSuccess={handleSaved} + /> + )} +
+
+ ); +} diff --git a/resources/js/components/automation-rules/automation-rule-form.tsx b/resources/js/components/automation-rules/automation-rule-form.tsx new file mode 100644 index 00000000..17b71348 --- /dev/null +++ b/resources/js/components/automation-rules/automation-rule-form.tsx @@ -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( + initialRuleStructure ?? { + groups: [createEmptyGroup()], + groupOperator: 'or', + }, + ); + const [categoryId, setCategoryId] = useState(initialCategoryId); + const [labelIds, setLabelIds] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + const [errors, setErrors] = useState>({}); + + 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) => { + 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 ( +
+
+ {__('Title')} + setTitle(e.target.value)} + placeholder={__('Rule title')} + required + /> + + {errors.title && ( +

{errors.title}

+ )} +
+ + + +
+

{__('Actions')}

+ +
+
+ + {__('Set Category')} + + {categoryId && ( + + )} +
+ +
+ +
+ {__('Add Labels')} + +
+ + {actionError && ( +

{actionError}

+ )} +
+ +
+ + +
+ + ); +} diff --git a/resources/js/components/automation-rules/create-automation-rule-dialog.tsx b/resources/js/components/automation-rules/create-automation-rule-dialog.tsx index bd66e7a4..680d64d1 100644 --- a/resources/js/components/automation-rules/create-automation-rule-dialog.tsx +++ b/resources/js/components/automation-rules/create-automation-rule-dialog.tsx @@ -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({ - groups: [createEmptyGroup()], - groupOperator: 'or', - }); - const [categoryId, setCategoryId] = useState(''); - const [labelIds, setLabelIds] = useState([]); - const [isSubmitting, setIsSubmitting] = useState(false); - const [errors, setErrors] = useState>({}); - - 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); - }, - onFinish: () => { - setIsSubmitting(false); - }, - }, - ); - } catch (error) { - console.error('Failed to create automation rule:', error); - setIsSubmitting(false); - } - }; return ( - - {__('Create Rule')} - + {trigger ?? ( + + {__('Create Rule')} + + )} @@ -137,100 +55,19 @@ export function CreateAutomationRuleDialog({ )} -
-
- {__('Title')} - setTitle(e.target.value)} - placeholder={__('Rule title')} - required - /> - - {errors.title && ( -

- {errors.title} -

- )} -
- - - -
-

{__('Actions')}

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

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

- )} -
- -
- - -
- + setOpen(false)} + onSuccess={() => { + setOpen(false); + onSuccess?.(); + }} + />
); diff --git a/resources/js/components/automation-rules/edit-automation-rule-dialog.tsx b/resources/js/components/automation-rules/edit-automation-rule-dialog.tsx index 0aa4d651..319e69bf 100644 --- a/resources/js/components/automation-rules/edit-automation-rule-dialog.tsx +++ b/resources/js/components/automation-rules/edit-automation-rule-dialog.tsx @@ -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({ - groups: [createEmptyGroup()], - groupOperator: 'and', - }); - const [categoryId, setCategoryId] = useState(''); - const [labelIds, setLabelIds] = useState([]); - const [isSubmitting, setIsSubmitting] = useState(false); - const [errors, setErrors] = useState>({}); - - 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); - }, - onFinish: () => { - setIsSubmitting(false); - }, - }, - ); - } catch (error) { - console.error('Failed to update automation rule:', error); - setIsSubmitting(false); - } - }; - return ( @@ -139,100 +42,18 @@ export function EditAutomationRuleDialog({ )} -
-
- - setTitle(e.target.value)} - placeholder={__('Rule title')} - required - /> - - {errors.title && ( -

- {errors.title} -

- )} -
- - - -
-

{__('Actions')}

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

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

- )} -
- -
- - -
- + onOpenChange(false)} + onSuccess={() => { + onOpenChange(false); + onSuccess?.(); + }} + />
); diff --git a/resources/js/components/transactions/category-cell.tsx b/resources/js/components/transactions/category-cell.tsx index d72a25b5..49b19639 100644 --- a/resources/js/components/transactions/category-cell.tsx +++ b/resources/js/components/transactions/category-cell.tsx @@ -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 { diff --git a/resources/js/components/transactions/edit-transaction-dialog.tsx b/resources/js/components/transactions/edit-transaction-dialog.tsx index adbf3752..40256a50 100644 --- a/resources/js/components/transactions/edit-transaction-dialog.tsx +++ b/resources/js/components/transactions/edit-transaction-dialog.tsx @@ -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 diff --git a/resources/js/components/transactions/transaction-columns.tsx b/resources/js/components/transactions/transaction-columns.tsx index d1524be8..9bd0b977 100644 --- a/resources/js/components/transactions/transaction-columns.tsx +++ b/resources/js/components/transactions/transaction-columns.tsx @@ -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[] { 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 /> diff --git a/resources/js/components/transactions/transaction-list.tsx b/resources/js/components/transactions/transaction-list.tsx index fdff7a91..85acd58c 100644 --- a/resources/js/components/transactions/transaction-list.tsx +++ b/resources/js/components/transactions/transaction-list.tsx @@ -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(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" /> + + { diff --git a/resources/js/hooks/use-categorize-transactions.ts b/resources/js/hooks/use-categorize-transactions.ts index e24ea3c6..3f443cfe 100644 --- a/resources/js/hooks/use-categorize-transactions.ts +++ b/resources/js/hooks/use-categorize-transactions.ts @@ -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(null); const [searchValue, setSearchValue] = useState(''); const [rulesDialogOpen, setRulesDialogOpen] = useState(false); + const [automateDialogOpen, setAutomateDialogOpen] = useState(false); + const [automateCandidate, setAutomateCandidate] = + useState(null); const [encryptionKey, setEncryptionKey] = useState(null); const [categorizedCount, setCategorizedCount] = useState(0); const commandInputRef = useRef(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, }; } diff --git a/resources/js/lib/rule-builder-utils.test.ts b/resources/js/lib/rule-builder-utils.test.ts new file mode 100644 index 00000000..2c9608c9 --- /dev/null +++ b/resources/js/lib/rule-builder-utils.test.ts @@ -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' }] }, + ], + }, + ], + }); + }); +}); diff --git a/resources/js/lib/rule-builder-utils.ts b/resources/js/lib/rule-builder-utils.ts index bae67cb3..2d262610 100644 --- a/resources/js/lib/rule-builder-utils.ts +++ b/resources/js/lib/rule-builder-utils.ts @@ -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( diff --git a/resources/js/pages/transactions/categorize.tsx b/resources/js/pages/transactions/categorize.tsx index 691e7d87..43d7f9de 100644 --- a/resources/js/pages/transactions/categorize.tsx +++ b/resources/js/pages/transactions/categorize.tsx @@ -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 = ( + + ); 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({ + {automateDialog} +