feat(rules): manual-only rules as one-click quick actions on selected transactions
ci / lint-test (push) Successful in 52s
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:
+42
-11
@@ -86,6 +86,7 @@ export default function RulesPage() {
|
||||
const [conditions, setConditions] = useState<Condition[]>([]);
|
||||
const [actions, setActions] = useState<Actions>(EMPTY_ACTIONS);
|
||||
const [priority, setPriority] = useState(0);
|
||||
const [manualOnly, setManualOnly] = useState(false);
|
||||
|
||||
function openNewForm() {
|
||||
setEditingId(null);
|
||||
@@ -93,15 +94,17 @@ export default function RulesPage() {
|
||||
setConditions([]);
|
||||
setActions(EMPTY_ACTIONS);
|
||||
setPriority(0);
|
||||
setManualOnly(false);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
function openEditForm(rule: { id: number; name: string; conditions: Condition[]; actions: Actions; priority: number }) {
|
||||
function openEditForm(rule: { id: number; name: string; conditions: Condition[]; actions: Actions; priority: number; manual_only?: boolean }) {
|
||||
setEditingId(rule.id);
|
||||
setName(rule.name);
|
||||
setConditions(Array.isArray(rule.conditions) ? rule.conditions : []);
|
||||
setActions(rule.actions && typeof rule.actions === "object" ? rule.actions : EMPTY_ACTIONS);
|
||||
setPriority(rule.priority);
|
||||
setManualOnly(!!rule.manual_only);
|
||||
setShowForm(true);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
@@ -141,7 +144,7 @@ export default function RulesPage() {
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const payload = { name, conditions, actions, enabled: true, priority };
|
||||
const payload = { name, conditions, actions, enabled: true, manual_only: manualOnly, priority };
|
||||
if (editingId !== null) {
|
||||
await updateRule.mutateAsync({ id: editingId, ...payload });
|
||||
} else {
|
||||
@@ -416,6 +419,20 @@ export default function RulesPage() {
|
||||
className="w-24 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-400 cursor-pointer select-none pb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={manualOnly}
|
||||
onChange={(e) => setManualOnly(e.target.checked)}
|
||||
className="accent-amber-500"
|
||||
/>
|
||||
<span>
|
||||
Quick action only
|
||||
<span className="block text-xs text-zinc-600">
|
||||
Never runs automatically — appears as a button on selected transactions
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
@@ -475,21 +492,35 @@ export default function RulesPage() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<span className="font-medium text-sm">{rule.name}</span>
|
||||
{rule.manual_only && (
|
||||
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-900/40 text-amber-300 border border-amber-800/60">
|
||||
Quick action
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-zinc-500">priority: {rule.priority}</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400">
|
||||
{conds.length > 0 ? conds.map((c) => humanCondition(c, tagNames)).join(" AND ") : "(matches all)"}
|
||||
{rule.manual_only
|
||||
? "(fired by hand on selected transactions)"
|
||||
: conds.length > 0
|
||||
? conds.map((c) => humanCondition(c, tagNames)).join(" AND ")
|
||||
: "(matches all)"}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">{humanAction(acts, tagNames, participantNames)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
onClick={() => handleApply(rule.id)}
|
||||
disabled={applyRules.isPending}
|
||||
className="text-xs text-emerald-400 hover:text-emerald-300 disabled:opacity-50"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
{/* No Apply for quick actions: their conditions are empty, so a
|
||||
bulk apply would hit every transaction. They run from the
|
||||
transactions page against a selection instead. */}
|
||||
{!rule.manual_only && (
|
||||
<button
|
||||
onClick={() => handleApply(rule.id)}
|
||||
disabled={applyRules.isPending}
|
||||
className="text-xs text-emerald-400 hover:text-emerald-300 disabled:opacity-50"
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => updateRule.mutate({ id: rule.id, enabled: !rule.enabled })}
|
||||
className={`relative inline-flex h-5 w-9 rounded-full transition-colors ${
|
||||
@@ -503,7 +534,7 @@ export default function RulesPage() {
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openEditForm({ id: rule.id, name: rule.name, conditions: conds as Condition[], actions: acts, priority: rule.priority })}
|
||||
onClick={() => openEditForm({ id: rule.id, name: rule.name, conditions: conds as Condition[], actions: acts, priority: rule.priority, manual_only: rule.manual_only })}
|
||||
className="text-zinc-400 hover:text-white text-sm"
|
||||
>
|
||||
Edit
|
||||
|
||||
Reference in New Issue
Block a user