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.
This commit is contained in:
2026-03-11 12:05:35 +11:00
parent 7b3fd4b65f
commit a8743ba7df
+97 -3
View File
@@ -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 (
<div className="fixed bottom-4 right-4 z-50 bg-zinc-800 border border-zinc-600 rounded-xl shadow-2xl p-4 w-80 text-sm">
<p className="text-zinc-200 font-medium mb-1">Save as rule?</p>
<p className="text-zinc-400 text-xs mb-3">
Automatically apply this {field === "category" ? "category" : "merchant name"} to future matching transactions.
</p>
<div className="bg-zinc-900 rounded-lg px-3 py-2 text-xs text-zinc-300 mb-3 space-y-1">
<p><span className="text-zinc-500">If</span> description contains <span className="text-white">"{conditions[0].value}"</span></p>
<p>
<span className="text-zinc-500">Then</span>{" "}
{field === "category"
? <>set category <span className="text-white">{formatCategory(newValue)}</span></>
: <>set merchant <span className="text-white">{newValue}</span></>}
</p>
</div>
<div className="flex gap-2">
<button
onClick={save}
disabled={saving}
className="flex-1 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white rounded-lg py-1.5 font-medium transition-colors"
>
{saving ? "Saving…" : "Save rule"}
</button>
<button
onClick={onDone}
className="flex-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-200 rounded-lg py-1.5 transition-colors"
>
Dismiss
</button>
</div>
</div>
);
}
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() {
<div className="relative">
<InlineEdit
value={t.effective_merchant || ""}
onSave={(val) => 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 && (
<span className="absolute -left-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 bg-blue-500 rounded-full" title="Manually overridden" />
@@ -432,7 +514,10 @@ function TransactionsContent() {
<div className="relative">
<InlineEdit
value={t.effective_category}
onSave={(val) => 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 && (
<SaveAsRulePrompt
tx={rulePrompt.tx}
field={rulePrompt.field}
newValue={rulePrompt.newValue}
onDone={() => setRulePrompt(null)}
/>
)}
{/* Pagination */}
{data && data.total > filters.limit && (
<div className="flex items-center justify-between mt-4">