feat(rules): preview what a rule would change before applying it
ci / lint-test (push) Successful in 34s
ci / lint-test (push) Successful in 34s
Selecting a rule now shows the transactions it would alter, so a subset can be ticked and applied rather than trusting a bulk run. The apply step takes explicit transaction ids (the existing bulk apply_rule path), so what you tick is exactly what changes - a rule whose conditions are too broad cannot reach further than the preview showed. Matches are split into 'would change' and 'already correct'. A merchant normalisation rule matching 400 rows where 380 already hold the right value is 20 changes and 380 rows of noise; only the 20 are listed. Preview is offered for every rule including manual_only quick actions, which previously had no way to see their reach at all. A rule with no conditions matches every transaction - that is how apply already behaves, so the preview reports it prominently rather than hiding it. Applies still snapshot to rule_apply_runs, so they remain revertable.
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRuleMatches, useApplyRuleToSelection, type RuleMatchChange } from "@/lib/hooks";
|
||||
import { formatCategory } from "@/lib/categories";
|
||||
|
||||
/**
|
||||
* Preview what a rule would do, then apply it to a chosen subset.
|
||||
*
|
||||
* The apply path takes explicit transaction ids rather than re-running the
|
||||
* conditions, so what you tick is exactly what changes — a rule whose
|
||||
* conditions are too broad cannot quietly reach further than the preview showed.
|
||||
*/
|
||||
|
||||
function fmt(amount: number, currency = "AUD") {
|
||||
return new Intl.NumberFormat("en-AU", { style: "currency", currency }).format(amount);
|
||||
}
|
||||
|
||||
function describe(c: RuleMatchChange) {
|
||||
if (c.field === "category") return `${formatCategory(c.from)} → ${formatCategory(c.to)}`;
|
||||
if (c.field === "merchant") return `${c.from ?? "—"} → ${c.to}`;
|
||||
if (c.field === "tags") return `+ tags`;
|
||||
return c.from ? `split ${c.from} → ${c.to}` : `split → ${c.to}`;
|
||||
}
|
||||
|
||||
export function RulePreviewModal({
|
||||
ruleId,
|
||||
ruleName,
|
||||
onClose,
|
||||
}: {
|
||||
ruleId: number;
|
||||
ruleName: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { data, isLoading, error } = useRuleMatches(ruleId);
|
||||
const apply = useApplyRuleToSelection();
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
|
||||
// Default to everything the rule would change — the common case is "yes, all
|
||||
// of these", with deselection as the exception.
|
||||
useEffect(() => {
|
||||
if (data?.transactions) setSelected(new Set(data.transactions.map((t) => t.id)));
|
||||
}, [data]);
|
||||
|
||||
const rows = data?.transactions ?? [];
|
||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||
const totalValue = useMemo(
|
||||
() => rows.filter((r) => selected.has(r.id))
|
||||
.reduce((a, r) => a + Number(r.amount_aud ?? r.amount), 0),
|
||||
[rows, selected]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-lg w-full max-w-4xl max-h-[85vh] flex flex-col">
|
||||
<div className="px-5 py-4 border-b border-zinc-800">
|
||||
<h2 className="text-lg font-medium">Preview: {ruleName}</h2>
|
||||
{data && (
|
||||
<p className="text-xs text-zinc-500 mt-1">
|
||||
{data.total_matched} matched · <span className="text-amber-400">{data.would_change} would change</span>
|
||||
{data.already_correct > 0 && ` · ${data.already_correct} already correct`}
|
||||
</p>
|
||||
)}
|
||||
{data?.rule.matches_everything && (
|
||||
<p className="text-xs text-red-400 mt-1">
|
||||
This rule has no conditions — it matches every transaction.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto px-5 py-3">
|
||||
{isLoading && <p className="text-sm text-zinc-500">Checking…</p>}
|
||||
{error && <p className="text-sm text-red-400">Failed to load matches.</p>}
|
||||
{data && rows.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">
|
||||
Nothing would change{data.already_correct > 0 && ` — all ${data.already_correct} matching transactions already have these values`}.
|
||||
</p>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-zinc-900">
|
||||
<tr className="border-b border-zinc-800 text-zinc-500">
|
||||
<th className="p-2 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={(e) =>
|
||||
setSelected(e.target.checked ? new Set(rows.map((r) => r.id)) : new Set())
|
||||
}
|
||||
/>
|
||||
</th>
|
||||
<th className="text-left p-2">Date</th>
|
||||
<th className="text-left p-2">Description</th>
|
||||
<th className="text-right p-2">Amount</th>
|
||||
<th className="text-left p-2">Would change</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-b border-zinc-800/40 hover:bg-zinc-800/30">
|
||||
<td className="p-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(r.id)}
|
||||
onChange={(e) => {
|
||||
const next = new Set(selected);
|
||||
if (e.target.checked) next.add(r.id); else next.delete(r.id);
|
||||
setSelected(next);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2 text-zinc-500 whitespace-nowrap">{String(r.transaction_date).slice(0, 10)}</td>
|
||||
<td className="p-2 text-zinc-300 max-w-[280px] truncate" title={r.description}>
|
||||
{r.effective_merchant || r.description}
|
||||
<span className="text-zinc-600 ml-2">{r.bank_name}</span>
|
||||
</td>
|
||||
<td className="p-2 text-right tabular-nums text-zinc-400">
|
||||
{fmt(Number(r.amount_aud ?? r.amount))}
|
||||
</td>
|
||||
<td className="p-2 text-amber-400">
|
||||
{r.changes.map((c, i) => <div key={i}>{describe(c)}</div>)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{data?.truncated && (
|
||||
<p className="text-xs text-zinc-500 mt-2">Showing the first {rows.length} — narrow the rule to see the rest.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3 border-t border-zinc-800 flex items-center justify-between">
|
||||
<span className="text-xs text-zinc-500">
|
||||
{done ?? `${selected.size} selected · ${fmt(totalValue)}`}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onClose} className="px-3 py-1.5 text-sm text-zinc-400 hover:text-white">
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
disabled={selected.size === 0 || apply.isPending}
|
||||
onClick={() =>
|
||||
apply.mutate(
|
||||
{ ruleId, ids: Array.from(selected) },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setDone(`Applied to ${selected.size} transaction${selected.size !== 1 ? "s" : ""}`);
|
||||
setSelected(new Set());
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
className="px-3 py-1.5 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-40 rounded text-sm"
|
||||
>
|
||||
{apply.isPending ? "Applying…" : `Apply to ${selected.size}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user