feat(rules): preview what a rule would change before applying it
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:
2026-07-26 15:08:29 +10:00
parent 31a8177958
commit cc852e7c6f
5 changed files with 375 additions and 3 deletions
+60
View File
@@ -530,6 +530,66 @@ export function useDeleteRule() {
});
}
export interface RuleMatchChange {
field: "category" | "merchant" | "tags" | "split";
from: string | null;
to: string;
}
export interface RuleMatchRow {
id: number;
transaction_date: string;
description: string;
amount: number;
amount_aud: number | null;
currency: string;
bank_name: string;
effective_merchant: string;
effective_category: string;
changes: RuleMatchChange[];
}
export interface RuleMatches {
rule: { id: number; name: string; matches_everything: boolean };
total_matched: number;
would_change: number;
already_correct: number;
transactions: RuleMatchRow[];
truncated: boolean;
}
/** Dry run — what a rule would change. Writes nothing. */
export function useRuleMatches(ruleId: number | null) {
return useQuery<RuleMatches>({
queryKey: ["rule-matches", ruleId],
enabled: ruleId != null,
queryFn: async () => {
const res = await fetch(`/api/rules/${ruleId}/matches`);
if (!res.ok) throw new Error("Failed to load rule matches");
return res.json();
},
});
}
/** Apply a rule to a hand-picked set of transactions (conditions ignored). */
export function useApplyRuleToSelection() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ ruleId, ids }: { ruleId: number; ids: number[] }) => {
const res = await fetch("/api/transactions/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "apply_rule", rule_id: ruleId, ids }),
});
if (!res.ok) throw new Error((await res.json()).error || "Failed to apply rule");
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["rule-matches"] });
qc.invalidateQueries({ queryKey: ["rules"] });
},
});
}
export function useApplyRules() {
const qc = useQueryClient();
return useMutation({