feat(rules): manual-only rules as one-click quick actions on selected transactions
ci / lint-test (push) Successful in 52s

A rule flagged manual_only never runs in the apply-all pass; instead it shows
as a button in the transactions bulk bar and applies its actions to the current
selection (conditions ignored — the selection is the condition). Recorded as a
rule_apply_run, so it reverts from Rules -> Apply History like any other run.

Motivation: tagging Home + splitting 50/50 with Sonu was two bulk actions every
time. Now it is one click, and any other combo can be defined the same way.

Extracts the action-application and snapshot logic from the apply route into
src/lib/rule-actions.ts so both callers share one implementation — splits upsert
rather than delete+reinsert, so settled flags survive.
This commit is contained in:
2026-07-25 23:02:26 +10:00
parent 856e1a51ab
commit e0b0fc91e0
10 changed files with 324 additions and 118 deletions
+13
View File
@@ -166,6 +166,7 @@ export function useBulkAction() {
merchant_normalized?: string;
splits?: { participant_id: number; share_percent: number }[];
tag_id?: number;
rule_id?: number;
}) => {
const res = await fetch("/api/transactions/bulk", {
method: "POST",
@@ -188,6 +189,16 @@ export function useBulkAction() {
if (variables.action === "tag" || variables.action === "untag") {
qc.invalidateQueries({ queryKey: ["tags"] });
}
// A quick action can touch category, tags and splits at once, and records
// a revertible run — refresh everything it could have changed.
if (variables.action === "apply_rule") {
qc.invalidateQueries({ queryKey: ["tags"] });
qc.invalidateQueries({ queryKey: ["splits"] });
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
qc.invalidateQueries({ queryKey: ["participant-balances"] });
qc.invalidateQueries({ queryKey: ["analytics"] });
qc.invalidateQueries({ queryKey: ["rule-runs"] });
}
},
});
}
@@ -459,6 +470,8 @@ export interface RuleRow {
conditions: { field: string; operator: string; value: string }[];
actions: { set_category?: string; add_tag_ids?: number[]; set_merchant?: string; apply_split?: { participant_id: number; share_percent: number }[] };
enabled: boolean;
/** Excluded from the apply-all run; fired by hand as a quick action instead. */
manual_only?: boolean;
priority: number;
created_at: string;
}