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