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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user