From a8743ba7dfe3b8ae9a764d41632903aac722078d Mon Sep 17 00:00:00 2001 From: siddharthd Date: Wed, 11 Mar 2026 12:05:35 +1100 Subject: [PATCH] feat(transactions): save-as-rule prompt after merchant/category edit After changing a merchant name or category inline, a toast-style prompt appears offering to save it as a rule. Shows a preview of the condition and action before saving. Dismissable without creating a rule. --- src/app/transactions/page.tsx | 100 +++++++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 3 deletions(-) diff --git a/src/app/transactions/page.tsx b/src/app/transactions/page.tsx index aacc346..64cf66c 100644 --- a/src/app/transactions/page.tsx +++ b/src/app/transactions/page.tsx @@ -2,7 +2,7 @@ import { useState, useCallback, Suspense } from "react"; import { useSearchParams } from "next/navigation"; -import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement } from "@/lib/hooks"; +import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule } from "@/lib/hooks"; import { CATEGORIES, formatCategory } from "@/lib/categories"; import { SplitModal } from "@/components/split-modal"; import { TagPicker } from "@/components/tag-picker"; @@ -71,6 +71,80 @@ function EditableTypeBadge({ type, onSave }: { type: string; onSave: (t: string) ); } +// Prompt shown after a merchant/category edit — offers to save it as a rule +function SaveAsRulePrompt({ + tx, + field, + newValue, + onDone, +}: { + tx: { id: number; effective_merchant: string; description: string; bank_name: string }; + field: "category" | "merchant"; + newValue: string; + onDone: () => void; +}) { + const createRule = useCreateRule(); + const [saving, setSaving] = useState(false); + + // Build a sensible default rule from the transaction context + const merchantMatch = tx.effective_merchant || tx.description; + const defaultName = + field === "category" + ? `${merchantMatch} → ${formatCategory(newValue)}` + : `Rename ${tx.effective_merchant || tx.description} → ${newValue}`; + + const conditions = [ + { + field: "description", + operator: "contains", + value: (tx.effective_merchant || tx.description).split(" ")[0] ?? "", + }, + ]; + const actions = + field === "category" + ? { set_category: newValue } + : { set_merchant: newValue }; + + async function save() { + setSaving(true); + await createRule.mutateAsync({ name: defaultName, conditions, actions, enabled: true, priority: 0 }); + onDone(); + } + + return ( +
+

Save as rule?

+

+ Automatically apply this {field === "category" ? "category" : "merchant name"} to future matching transactions. +

+
+

If description contains "{conditions[0].value}"

+

+ Then{" "} + {field === "category" + ? <>set category → {formatCategory(newValue)} + : <>set merchant → {newValue}} +

+
+
+ + +
+
+ ); +} + function InlineEdit({ value, onSave, @@ -159,6 +233,11 @@ function TransactionsContent() { const [bulkCategory, setBulkCategory] = useState(""); const [bulkTagId, setBulkTagId] = useState(""); const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string } | null>(null); + const [rulePrompt, setRulePrompt] = useState<{ + tx: { id: number; effective_merchant: string; description: string; bank_name: string }; + field: "category" | "merchant"; + newValue: string; + } | null>(null); const { data, isLoading } = useTransactions(filters); const { data: banks } = useBanks(); @@ -410,7 +489,10 @@ function TransactionsContent() {
updateTxn.mutate({ id: t.id, merchant_normalized: val })} + onSave={(val) => { + updateTxn.mutate({ id: t.id, merchant_normalized: val }); + setRulePrompt({ tx: t, field: "merchant", newValue: val }); + }} /> {t.merchant_override && ( @@ -432,7 +514,10 @@ function TransactionsContent() {
updateTxn.mutate({ id: t.id, category: val })} + onSave={(val) => { + updateTxn.mutate({ id: t.id, category: val }); + setRulePrompt({ tx: t, field: "category", newValue: val }); + }} type="select" options={categoryOptions} /> @@ -483,6 +568,15 @@ function TransactionsContent() { /> )} + {rulePrompt && ( + setRulePrompt(null)} + /> + )} + {/* Pagination */} {data && data.total > filters.limit && (