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:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useRef, useEffect, Suspense } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule, useParticipants, useRecordPayment, useCurrentUser, useTrips, useAssignTransactionsToTrip } from "@/lib/hooks";
|
||||
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule, useParticipants, useRecordPayment, useCurrentUser, useTrips, useAssignTransactionsToTrip, useRules } from "@/lib/hooks";
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
import { SplitModal } from "@/components/split-modal";
|
||||
import { TagPicker } from "@/components/tag-picker";
|
||||
@@ -10,6 +10,7 @@ import { AddTransactionModal } from "@/components/add-transaction-modal";
|
||||
import { EditTransactionModal } from "@/components/edit-transaction-modal";
|
||||
import { CsvImportModal } from "@/components/csv-import-modal";
|
||||
import type { TransactionRow } from "@/lib/queries";
|
||||
import type { RuleRow } from "@/lib/hooks";
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleDateString("en-AU", {
|
||||
@@ -43,6 +44,28 @@ const TYPE_OPTIONS = [
|
||||
"debit", "credit", "payment", "refund", "fee", "interest", "transfer",
|
||||
].map((t) => ({ value: t, label: t }));
|
||||
|
||||
/** Tooltip text for a quick-action button: what the rule will actually do. */
|
||||
function describeActions(
|
||||
actions: RuleRow["actions"],
|
||||
tags: { id: number; name: string }[] = [],
|
||||
participants: { id: number; name: string }[] = []
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
if (actions.set_category) parts.push(`category → ${formatCategory(actions.set_category)}`);
|
||||
if (actions.set_merchant) parts.push(`merchant → ${actions.set_merchant}`);
|
||||
if (actions.add_tag_ids?.length) {
|
||||
const names = actions.add_tag_ids.map((id) => tags.find((t) => t.id === id)?.name ?? `tag#${id}`);
|
||||
parts.push(`tag: ${names.join(", ")}`);
|
||||
}
|
||||
if (actions.apply_split?.length) {
|
||||
const shares = actions.apply_split.map(
|
||||
(s) => `${participants.find((p) => p.id === s.participant_id)?.name ?? `#${s.participant_id}`} ${s.share_percent}%`
|
||||
);
|
||||
parts.push(`split: ${shares.join(" / ")}`);
|
||||
}
|
||||
return parts.join(" · ") || "no actions";
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: string }) {
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${TYPE_COLORS[type] || "bg-zinc-800 text-zinc-400"}`}>
|
||||
@@ -528,6 +551,10 @@ function TransactionsContent() {
|
||||
const bulkAction = useBulkAction();
|
||||
const { data: trips = [] } = useTrips();
|
||||
const assignToTrip = useAssignTransactionsToTrip();
|
||||
const { data: allRules = [] } = useRules();
|
||||
const { data: participants = [] } = useParticipants();
|
||||
const quickActions = allRules.filter((r) => r.manual_only);
|
||||
const [quickResult, setQuickResult] = useState<string | null>(null);
|
||||
|
||||
const toggleSelect = useCallback((id: number) => {
|
||||
setSelected((prev) => {
|
||||
@@ -787,6 +814,32 @@ function TransactionsContent() {
|
||||
>
|
||||
{bulkTripId === "remove" ? "Remove" : "Assign"}
|
||||
</button>
|
||||
{quickActions.length > 0 && (
|
||||
<div className="flex items-center gap-2 pl-3 ml-1 border-l border-zinc-700">
|
||||
{quickActions.map((rule) => (
|
||||
<button
|
||||
key={rule.id}
|
||||
disabled={bulkAction.isPending}
|
||||
title={describeActions(rule.actions, tags, participants)}
|
||||
onClick={() => {
|
||||
const count = selected.size;
|
||||
bulkAction.mutate(
|
||||
{ action: "apply_rule", ids: Array.from(selected), rule_id: rule.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSelected(new Set());
|
||||
setQuickResult(`${rule.name} applied to ${count} transaction${count !== 1 ? "s" : ""}`);
|
||||
},
|
||||
}
|
||||
);
|
||||
}}
|
||||
className="px-3 py-1 bg-amber-700/80 hover:bg-amber-600 disabled:opacity-50 rounded text-sm"
|
||||
>
|
||||
{rule.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSelected(new Set())}
|
||||
className="px-3 py-1 text-zinc-400 hover:text-white text-sm"
|
||||
@@ -796,6 +849,18 @@ function TransactionsContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{quickResult && (
|
||||
<div className="flex items-center gap-3 mb-3 px-3 py-2 bg-emerald-900/30 border border-emerald-700 rounded text-sm text-emerald-200">
|
||||
<span>{quickResult}</span>
|
||||
<a href="/rules" className="text-emerald-400 hover:text-emerald-300 underline">
|
||||
undo from Rules → Apply History
|
||||
</a>
|
||||
<button onClick={() => setQuickResult(null)} className="ml-auto text-zinc-400 hover:text-white">
|
||||
dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto border border-zinc-800 rounded-lg">
|
||||
<table className="w-full text-sm min-w-[900px]">
|
||||
|
||||
Reference in New Issue
Block a user