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 { useState, useCallback, Suspense } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
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 { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
import { SplitModal } from "@/components/split-modal";
|
import { SplitModal } from "@/components/split-modal";
|
||||||
import { TagPicker } from "@/components/tag-picker";
|
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({
|
function InlineEdit({
|
||||||
value,
|
value,
|
||||||
onSave,
|
onSave,
|
||||||
@@ -159,6 +233,11 @@ function TransactionsContent() {
|
|||||||
const [bulkCategory, setBulkCategory] = useState("");
|
const [bulkCategory, setBulkCategory] = useState("");
|
||||||
const [bulkTagId, setBulkTagId] = useState("");
|
const [bulkTagId, setBulkTagId] = useState("");
|
||||||
const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string } | null>(null);
|
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, isLoading } = useTransactions(filters);
|
||||||
const { data: banks } = useBanks();
|
const { data: banks } = useBanks();
|
||||||
@@ -410,7 +489,10 @@ function TransactionsContent() {
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<InlineEdit
|
<InlineEdit
|
||||||
value={t.effective_merchant || ""}
|
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 && (
|
{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" />
|
<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">
|
<div className="relative">
|
||||||
<InlineEdit
|
<InlineEdit
|
||||||
value={t.effective_category}
|
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"
|
type="select"
|
||||||
options={categoryOptions}
|
options={categoryOptions}
|
||||||
/>
|
/>
|
||||||
@@ -483,6 +568,15 @@ function TransactionsContent() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{rulePrompt && (
|
||||||
|
<SaveAsRulePrompt
|
||||||
|
tx={rulePrompt.tx}
|
||||||
|
field={rulePrompt.field}
|
||||||
|
newValue={rulePrompt.newValue}
|
||||||
|
onDone={() => setRulePrompt(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{data && data.total > filters.limit && (
|
{data && data.total > filters.limit && (
|
||||||
<div className="flex items-center justify-between mt-4">
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user