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:
@@ -0,0 +1,10 @@
|
|||||||
|
-- Manual-only rules ("quick actions"): a rule flagged manual_only is never
|
||||||
|
-- picked up by the bulk apply-all run. It exists to be fired by hand against a
|
||||||
|
-- selection of transactions from the transactions page, so its conditions are
|
||||||
|
-- irrelevant — the selection is the condition.
|
||||||
|
|
||||||
|
ALTER TABLE rules
|
||||||
|
ADD COLUMN IF NOT EXISTS manual_only BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rules_manual_only
|
||||||
|
ON rules (owner_id, manual_only) WHERE manual_only = true;
|
||||||
+10
-9
@@ -98,15 +98,16 @@ model transaction_tags {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model rules {
|
model rules {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
owner_id Int
|
owner_id Int
|
||||||
name String
|
name String
|
||||||
conditions Json @default("[]")
|
conditions Json @default("[]")
|
||||||
actions Json @default("{}")
|
actions Json @default("{}")
|
||||||
enabled Boolean @default(true)
|
enabled Boolean @default(true)
|
||||||
priority Int @default(0)
|
manual_only Boolean @default(false)
|
||||||
created_at DateTime @default(now())
|
priority Int @default(0)
|
||||||
updated_at DateTime @default(now()) @updatedAt
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @default(now()) @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
model budgets {
|
model budgets {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
|
|||||||
...(body.conditions !== undefined && { conditions: body.conditions }),
|
...(body.conditions !== undefined && { conditions: body.conditions }),
|
||||||
...(body.actions !== undefined && { actions: body.actions }),
|
...(body.actions !== undefined && { actions: body.actions }),
|
||||||
...(body.enabled !== undefined && { enabled: body.enabled }),
|
...(body.enabled !== undefined && { enabled: body.enabled }),
|
||||||
|
...(body.manual_only !== undefined && { manual_only: body.manual_only }),
|
||||||
...(body.priority !== undefined && { priority: body.priority }),
|
...(body.priority !== undefined && { priority: body.priority }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,15 +3,7 @@ import { getCurrentUser } from "@/lib/auth";
|
|||||||
import { queryRaw } from "@/lib/db";
|
import { queryRaw } from "@/lib/db";
|
||||||
import { getTransactions } from "@/lib/queries";
|
import { getTransactions } from "@/lib/queries";
|
||||||
import { evaluateCondition, type Condition, type Actions } from "@/lib/rules";
|
import { evaluateCondition, type Condition, type Actions } from "@/lib/rules";
|
||||||
|
import { applyRuleActions, captureSnapshot } from "@/lib/rule-actions";
|
||||||
interface SnapshotEntry {
|
|
||||||
transaction_id: number;
|
|
||||||
had_override: boolean;
|
|
||||||
prev_category_override: string | null;
|
|
||||||
prev_merchant_normalized: string | null;
|
|
||||||
prev_tag_ids: number[];
|
|
||||||
prev_splits: { participant_id: number; share_percent: number; settled: boolean }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
@@ -42,10 +34,16 @@ export async function POST(req: NextRequest) {
|
|||||||
const splitFrom = body.splitFrom || null;
|
const splitFrom = body.splitFrom || null;
|
||||||
const ruleId = body.ruleId || null;
|
const ruleId = body.ruleId || null;
|
||||||
|
|
||||||
|
// Manual-only rules ("quick actions") never take part in a condition-matched
|
||||||
|
// run — their conditions are typically empty, so they would match every
|
||||||
|
// transaction. They are fired from the transactions page against a selection.
|
||||||
const rules = await queryRaw<{ id: number; conditions: unknown; actions: unknown }>(
|
const rules = await queryRaw<{ id: number; conditions: unknown; actions: unknown }>(
|
||||||
ruleId
|
ruleId
|
||||||
? `SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND id = $2`
|
? `SELECT id, conditions, actions FROM rules
|
||||||
: `SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND enabled = true ORDER BY priority DESC`,
|
WHERE owner_id = $1 AND id = $2 AND manual_only = false`
|
||||||
|
: `SELECT id, conditions, actions FROM rules
|
||||||
|
WHERE owner_id = $1 AND enabled = true AND manual_only = false
|
||||||
|
ORDER BY priority DESC`,
|
||||||
ruleId ? [user.id, ruleId] : [user.id]
|
ruleId ? [user.id, ruleId] : [user.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -70,51 +68,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Capture before-state for all matched transactions (batched) ---
|
// --- Capture before-state for all matched transactions (batched) ---
|
||||||
const snapshot: SnapshotEntry[] = [];
|
const snapshot = await captureSnapshot(Array.from(matchedIds));
|
||||||
if (matchedIds.size > 0) {
|
|
||||||
const ids = Array.from(matchedIds);
|
|
||||||
const idList = ids.join(",");
|
|
||||||
|
|
||||||
const overrides = await queryRaw<{ transaction_id: number; category_override: string | null; merchant_normalized: string | null }>(
|
|
||||||
`SELECT transaction_id, category_override, merchant_normalized FROM transaction_overrides WHERE transaction_id = ANY($1::int[])`,
|
|
||||||
[ids]
|
|
||||||
);
|
|
||||||
const overrideMap = new Map(overrides.map((o) => [o.transaction_id, o]));
|
|
||||||
|
|
||||||
const tagRows = await queryRaw<{ transaction_id: number; tag_id: number }>(
|
|
||||||
`SELECT transaction_id, tag_id FROM transaction_tags WHERE transaction_id = ANY($1::int[])`,
|
|
||||||
[ids]
|
|
||||||
);
|
|
||||||
const tagMap = new Map<number, number[]>();
|
|
||||||
for (const row of tagRows) {
|
|
||||||
if (!tagMap.has(row.transaction_id)) tagMap.set(row.transaction_id, []);
|
|
||||||
tagMap.get(row.transaction_id)!.push(row.tag_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const splitRows = await queryRaw<{ transaction_id: number; participant_id: number; share_percent: number; settled: boolean }>(
|
|
||||||
`SELECT transaction_id, participant_id, share_percent, settled FROM transaction_splits WHERE transaction_id = ANY($1::int[])`,
|
|
||||||
[ids]
|
|
||||||
);
|
|
||||||
const splitMap = new Map<number, { participant_id: number; share_percent: number; settled: boolean }[]>();
|
|
||||||
for (const row of splitRows) {
|
|
||||||
if (!splitMap.has(row.transaction_id)) splitMap.set(row.transaction_id, []);
|
|
||||||
splitMap.get(row.transaction_id)!.push({ participant_id: row.participant_id, share_percent: row.share_percent, settled: row.settled });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const id of ids) {
|
|
||||||
const ov = overrideMap.get(id);
|
|
||||||
snapshot.push({
|
|
||||||
transaction_id: id,
|
|
||||||
had_override: !!ov,
|
|
||||||
prev_category_override: ov?.category_override ?? null,
|
|
||||||
prev_merchant_normalized: ov?.merchant_normalized ?? null,
|
|
||||||
prev_tag_ids: tagMap.get(id) ?? [],
|
|
||||||
prev_splits: splitMap.get(id) ?? [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void idList; // suppress unused warning
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Apply rules ---
|
// --- Apply rules ---
|
||||||
let matched = 0;
|
let matched = 0;
|
||||||
@@ -128,45 +82,11 @@ export async function POST(req: NextRequest) {
|
|||||||
matched++;
|
matched++;
|
||||||
affectedIds.add(tx.id);
|
affectedIds.add(tx.id);
|
||||||
|
|
||||||
if (actions.set_category || actions.set_merchant) {
|
// splitFrom holds the split back to transactions on/after that date;
|
||||||
await queryRaw(
|
// category/merchant/tags still apply to everything matched.
|
||||||
`INSERT INTO transaction_overrides (transaction_id, category_override, merchant_normalized)
|
await applyRuleActions(tx.id, actions, {
|
||||||
VALUES ($1, $2, $3)
|
skipSplit: !!splitFrom && tx.transaction_date < splitFrom,
|
||||||
ON CONFLICT (transaction_id) DO UPDATE SET
|
});
|
||||||
category_override = COALESCE($2, transaction_overrides.category_override),
|
|
||||||
merchant_normalized = COALESCE($3, transaction_overrides.merchant_normalized),
|
|
||||||
updated_at = NOW()`,
|
|
||||||
[tx.id, actions.set_category || null, actions.set_merchant || null]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actions.add_tag_ids?.length) {
|
|
||||||
for (const tagId of actions.add_tag_ids) {
|
|
||||||
await queryRaw(
|
|
||||||
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
|
||||||
[tx.id, tagId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actions.apply_split?.length) {
|
|
||||||
if (splitFrom && tx.transaction_date < splitFrom) continue;
|
|
||||||
// Remove only participants no longer in the rule's split, and upsert the
|
|
||||||
// rest — a plain delete+reinsert would reset settled flags on every run.
|
|
||||||
await queryRaw(
|
|
||||||
`DELETE FROM transaction_splits WHERE transaction_id = $1 AND participant_id != ALL($2::int[])`,
|
|
||||||
[tx.id, actions.apply_split.map((s) => s.participant_id)]
|
|
||||||
);
|
|
||||||
for (const s of actions.apply_split) {
|
|
||||||
await queryRaw(
|
|
||||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
|
||||||
VALUES ($1, $2, $3)
|
|
||||||
ON CONFLICT (transaction_id, participant_id)
|
|
||||||
DO UPDATE SET share_percent = EXCLUDED.share_percent`,
|
|
||||||
[tx.id, s.participant_id, s.share_percent]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ export async function GET(req: NextRequest) {
|
|||||||
conditions: unknown;
|
conditions: unknown;
|
||||||
actions: unknown;
|
actions: unknown;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
manual_only: boolean;
|
||||||
priority: number;
|
priority: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}>(
|
}>(
|
||||||
`SELECT id, name, conditions, actions, enabled, priority, created_at
|
`SELECT id, name, conditions, actions, enabled, manual_only, priority, created_at
|
||||||
FROM rules WHERE owner_id = $1 ORDER BY priority DESC, id ASC`,
|
FROM rules WHERE owner_id = $1 ORDER BY priority DESC, id ASC`,
|
||||||
[user.id]
|
[user.id]
|
||||||
);
|
);
|
||||||
@@ -26,7 +27,7 @@ export async function POST(req: NextRequest) {
|
|||||||
const user = await getCurrentUser(req);
|
const user = await getCurrentUser(req);
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
const { name, conditions, actions, enabled = true, priority = 0 } = await req.json();
|
const { name, conditions, actions, enabled = true, manual_only = false, priority = 0 } = await req.json();
|
||||||
if (!name) return NextResponse.json({ error: "name required" }, { status: 400 });
|
if (!name) return NextResponse.json({ error: "name required" }, { status: 400 });
|
||||||
|
|
||||||
const rule = await prisma.rules.create({
|
const rule = await prisma.rules.create({
|
||||||
@@ -36,6 +37,7 @@ export async function POST(req: NextRequest) {
|
|||||||
conditions: conditions ?? [],
|
conditions: conditions ?? [],
|
||||||
actions: actions ?? {},
|
actions: actions ?? {},
|
||||||
enabled,
|
enabled,
|
||||||
|
manual_only,
|
||||||
priority,
|
priority,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { prisma, queryRaw } from "@/lib/db";
|
import { prisma, queryRaw } from "@/lib/db";
|
||||||
import { assignTransactionsToTrip, canAccessTransactions } from "@/lib/queries";
|
import { assignTransactionsToTrip, canAccessTransactions } from "@/lib/queries";
|
||||||
import { getCurrentUser } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { applyRuleActions, captureSnapshot } from "@/lib/rule-actions";
|
||||||
|
import type { Actions } from "@/lib/rules";
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const user = await getCurrentUser(req);
|
const user = await getCurrentUser(req);
|
||||||
@@ -85,6 +87,37 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ updated: ids.length });
|
return NextResponse.json({ updated: ids.length });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quick action: fire a saved rule's actions at the current selection. The
|
||||||
|
// selection replaces the rule's conditions, which are not evaluated at all.
|
||||||
|
// Recorded as a rule_apply_run so it can be reverted like any other run.
|
||||||
|
if (action === "apply_rule") {
|
||||||
|
const { rule_id } = body as { rule_id?: number };
|
||||||
|
if (!rule_id) return NextResponse.json({ error: "rule_id required" }, { status: 400 });
|
||||||
|
|
||||||
|
const rules = await queryRaw<{ id: number; name: string; actions: unknown }>(
|
||||||
|
`SELECT id, name, actions FROM rules WHERE id = $1 AND owner_id = $2`,
|
||||||
|
[Number(rule_id), user.id]
|
||||||
|
);
|
||||||
|
if (!rules.length) return NextResponse.json({ error: "Rule not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const actions = (typeof rules[0].actions === "string"
|
||||||
|
? JSON.parse(rules[0].actions)
|
||||||
|
: rules[0].actions) as Actions;
|
||||||
|
|
||||||
|
const snapshot = await captureSnapshot(ids.map(Number));
|
||||||
|
for (const id of ids) {
|
||||||
|
await applyRuleActions(Number(id), actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = await queryRaw<{ id: number }>(
|
||||||
|
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot)
|
||||||
|
VALUES ($1, NULL, $2, $3, $4) RETURNING id`,
|
||||||
|
[user.id, ids.length, ids.length, JSON.stringify(snapshot)]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ updated: ids.length, run_id: run[0].id, rule: rules[0].name });
|
||||||
|
}
|
||||||
|
|
||||||
if (action === "assign_trip") {
|
if (action === "assign_trip") {
|
||||||
const { trip_id } = body as { ids: number[]; trip_id: number | null };
|
const { trip_id } = body as { ids: number[]; trip_id: number | null };
|
||||||
await assignTransactionsToTrip(trip_id, ids);
|
await assignTransactionsToTrip(trip_id, ids);
|
||||||
|
|||||||
+42
-11
@@ -86,6 +86,7 @@ export default function RulesPage() {
|
|||||||
const [conditions, setConditions] = useState<Condition[]>([]);
|
const [conditions, setConditions] = useState<Condition[]>([]);
|
||||||
const [actions, setActions] = useState<Actions>(EMPTY_ACTIONS);
|
const [actions, setActions] = useState<Actions>(EMPTY_ACTIONS);
|
||||||
const [priority, setPriority] = useState(0);
|
const [priority, setPriority] = useState(0);
|
||||||
|
const [manualOnly, setManualOnly] = useState(false);
|
||||||
|
|
||||||
function openNewForm() {
|
function openNewForm() {
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
@@ -93,15 +94,17 @@ export default function RulesPage() {
|
|||||||
setConditions([]);
|
setConditions([]);
|
||||||
setActions(EMPTY_ACTIONS);
|
setActions(EMPTY_ACTIONS);
|
||||||
setPriority(0);
|
setPriority(0);
|
||||||
|
setManualOnly(false);
|
||||||
setShowForm(true);
|
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);
|
setEditingId(rule.id);
|
||||||
setName(rule.name);
|
setName(rule.name);
|
||||||
setConditions(Array.isArray(rule.conditions) ? rule.conditions : []);
|
setConditions(Array.isArray(rule.conditions) ? rule.conditions : []);
|
||||||
setActions(rule.actions && typeof rule.actions === "object" ? rule.actions : EMPTY_ACTIONS);
|
setActions(rule.actions && typeof rule.actions === "object" ? rule.actions : EMPTY_ACTIONS);
|
||||||
setPriority(rule.priority);
|
setPriority(rule.priority);
|
||||||
|
setManualOnly(!!rule.manual_only);
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
}
|
}
|
||||||
@@ -141,7 +144,7 @@ export default function RulesPage() {
|
|||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const payload = { name, conditions, actions, enabled: true, priority };
|
const payload = { name, conditions, actions, enabled: true, manual_only: manualOnly, priority };
|
||||||
if (editingId !== null) {
|
if (editingId !== null) {
|
||||||
await updateRule.mutateAsync({ id: editingId, ...payload });
|
await updateRule.mutateAsync({ id: editingId, ...payload });
|
||||||
} else {
|
} 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"
|
className="w-24 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
@@ -475,21 +492,35 @@ export default function RulesPage() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<span className="font-medium text-sm">{rule.name}</span>
|
<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>
|
<span className="text-xs text-zinc-500">priority: {rule.priority}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-zinc-400">
|
<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>
|
||||||
<p className="text-xs text-zinc-500 mt-1">{humanAction(acts, tagNames, participantNames)}</p>
|
<p className="text-xs text-zinc-500 mt-1">{humanAction(acts, tagNames, participantNames)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
<button
|
{/* No Apply for quick actions: their conditions are empty, so a
|
||||||
onClick={() => handleApply(rule.id)}
|
bulk apply would hit every transaction. They run from the
|
||||||
disabled={applyRules.isPending}
|
transactions page against a selection instead. */}
|
||||||
className="text-xs text-emerald-400 hover:text-emerald-300 disabled:opacity-50"
|
{!rule.manual_only && (
|
||||||
>
|
<button
|
||||||
Apply
|
onClick={() => handleApply(rule.id)}
|
||||||
</button>
|
disabled={applyRules.isPending}
|
||||||
|
className="text-xs text-emerald-400 hover:text-emerald-300 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => updateRule.mutate({ id: rule.id, enabled: !rule.enabled })}
|
onClick={() => updateRule.mutate({ id: rule.id, enabled: !rule.enabled })}
|
||||||
className={`relative inline-flex h-5 w-9 rounded-full transition-colors ${
|
className={`relative inline-flex h-5 w-9 rounded-full transition-colors ${
|
||||||
@@ -503,7 +534,7 @@ export default function RulesPage() {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<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"
|
className="text-zinc-400 hover:text-white text-sm"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect, Suspense } from "react";
|
import { useState, useCallback, useRef, useEffect, Suspense } from "react";
|
||||||
import { useSearchParams } from "next/navigation";
|
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 { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
import { SplitModal } from "@/components/split-modal";
|
import { SplitModal } from "@/components/split-modal";
|
||||||
import { TagPicker } from "@/components/tag-picker";
|
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 { EditTransactionModal } from "@/components/edit-transaction-modal";
|
||||||
import { CsvImportModal } from "@/components/csv-import-modal";
|
import { CsvImportModal } from "@/components/csv-import-modal";
|
||||||
import type { TransactionRow } from "@/lib/queries";
|
import type { TransactionRow } from "@/lib/queries";
|
||||||
|
import type { RuleRow } from "@/lib/hooks";
|
||||||
|
|
||||||
function formatDate(d: string) {
|
function formatDate(d: string) {
|
||||||
return new Date(d).toLocaleDateString("en-AU", {
|
return new Date(d).toLocaleDateString("en-AU", {
|
||||||
@@ -43,6 +44,28 @@ const TYPE_OPTIONS = [
|
|||||||
"debit", "credit", "payment", "refund", "fee", "interest", "transfer",
|
"debit", "credit", "payment", "refund", "fee", "interest", "transfer",
|
||||||
].map((t) => ({ value: t, label: t }));
|
].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 }) {
|
function TypeBadge({ type }: { type: string }) {
|
||||||
return (
|
return (
|
||||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${TYPE_COLORS[type] || "bg-zinc-800 text-zinc-400"}`}>
|
<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 bulkAction = useBulkAction();
|
||||||
const { data: trips = [] } = useTrips();
|
const { data: trips = [] } = useTrips();
|
||||||
const assignToTrip = useAssignTransactionsToTrip();
|
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) => {
|
const toggleSelect = useCallback((id: number) => {
|
||||||
setSelected((prev) => {
|
setSelected((prev) => {
|
||||||
@@ -787,6 +814,32 @@ function TransactionsContent() {
|
|||||||
>
|
>
|
||||||
{bulkTripId === "remove" ? "Remove" : "Assign"}
|
{bulkTripId === "remove" ? "Remove" : "Assign"}
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={() => setSelected(new Set())}
|
onClick={() => setSelected(new Set())}
|
||||||
className="px-3 py-1 text-zinc-400 hover:text-white text-sm"
|
className="px-3 py-1 text-zinc-400 hover:text-white text-sm"
|
||||||
@@ -796,6 +849,18 @@ function TransactionsContent() {
|
|||||||
</div>
|
</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 */}
|
{/* Table */}
|
||||||
<div className="overflow-x-auto border border-zinc-800 rounded-lg">
|
<div className="overflow-x-auto border border-zinc-800 rounded-lg">
|
||||||
<table className="w-full text-sm min-w-[900px]">
|
<table className="w-full text-sm min-w-[900px]">
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ export function useBulkAction() {
|
|||||||
merchant_normalized?: string;
|
merchant_normalized?: string;
|
||||||
splits?: { participant_id: number; share_percent: number }[];
|
splits?: { participant_id: number; share_percent: number }[];
|
||||||
tag_id?: number;
|
tag_id?: number;
|
||||||
|
rule_id?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const res = await fetch("/api/transactions/bulk", {
|
const res = await fetch("/api/transactions/bulk", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -188,6 +189,16 @@ export function useBulkAction() {
|
|||||||
if (variables.action === "tag" || variables.action === "untag") {
|
if (variables.action === "tag" || variables.action === "untag") {
|
||||||
qc.invalidateQueries({ queryKey: ["tags"] });
|
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 }[];
|
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 }[] };
|
actions: { set_category?: string; add_tag_ids?: number[]; set_merchant?: string; apply_split?: { participant_id: number; share_percent: number }[] };
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
/** Excluded from the apply-all run; fired by hand as a quick action instead. */
|
||||||
|
manual_only?: boolean;
|
||||||
priority: number;
|
priority: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* Applying a rule's `actions` to transactions, and snapshotting the before-state
|
||||||
|
* so a run can be reverted.
|
||||||
|
*
|
||||||
|
* Shared by the two callers that write rule actions:
|
||||||
|
* - `POST /api/rules/apply` — condition-matched bulk run
|
||||||
|
* - `POST /api/transactions/bulk` with `action: "apply_rule"` — quick action
|
||||||
|
* fired against a hand-picked selection (conditions ignored)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
import type { Actions } from "@/lib/rules";
|
||||||
|
|
||||||
|
export interface SnapshotEntry {
|
||||||
|
transaction_id: number;
|
||||||
|
had_override: boolean;
|
||||||
|
prev_category_override: string | null;
|
||||||
|
prev_merchant_normalized: string | null;
|
||||||
|
prev_tag_ids: number[];
|
||||||
|
prev_splits: { participant_id: number; share_percent: number; settled: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply `actions` to one transaction.
|
||||||
|
*
|
||||||
|
* Tags are additive (ON CONFLICT DO NOTHING) — re-running never duplicates and
|
||||||
|
* never removes tags the rule doesn't mention. Splits replace the participant
|
||||||
|
* set but upsert shares, so `settled` flags survive a re-run; a plain
|
||||||
|
* delete+reinsert would silently un-settle reconciled splits.
|
||||||
|
*/
|
||||||
|
export async function applyRuleActions(
|
||||||
|
transactionId: number,
|
||||||
|
actions: Actions,
|
||||||
|
opts: { skipSplit?: boolean } = {}
|
||||||
|
): Promise<void> {
|
||||||
|
if (actions.set_category || actions.set_merchant) {
|
||||||
|
await queryRaw(
|
||||||
|
`INSERT INTO transaction_overrides (transaction_id, category_override, merchant_normalized)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (transaction_id) DO UPDATE SET
|
||||||
|
category_override = COALESCE($2, transaction_overrides.category_override),
|
||||||
|
merchant_normalized = COALESCE($3, transaction_overrides.merchant_normalized),
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[transactionId, actions.set_category || null, actions.set_merchant || null]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actions.add_tag_ids?.length) {
|
||||||
|
for (const tagId of actions.add_tag_ids) {
|
||||||
|
await queryRaw(
|
||||||
|
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||||
|
[transactionId, tagId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actions.apply_split?.length && !opts.skipSplit) {
|
||||||
|
await queryRaw(
|
||||||
|
`DELETE FROM transaction_splits WHERE transaction_id = $1 AND participant_id != ALL($2::int[])`,
|
||||||
|
[transactionId, actions.apply_split.map((s) => s.participant_id)]
|
||||||
|
);
|
||||||
|
for (const s of actions.apply_split) {
|
||||||
|
await queryRaw(
|
||||||
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (transaction_id, participant_id)
|
||||||
|
DO UPDATE SET share_percent = EXCLUDED.share_percent`,
|
||||||
|
[transactionId, s.participant_id, s.share_percent]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capture the overrides / tags / splits of `ids` before a run mutates them. */
|
||||||
|
export async function captureSnapshot(ids: number[]): Promise<SnapshotEntry[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
|
||||||
|
const overrides = await queryRaw<{
|
||||||
|
transaction_id: number;
|
||||||
|
category_override: string | null;
|
||||||
|
merchant_normalized: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT transaction_id, category_override, merchant_normalized
|
||||||
|
FROM transaction_overrides WHERE transaction_id = ANY($1::int[])`,
|
||||||
|
[ids]
|
||||||
|
);
|
||||||
|
const overrideMap = new Map(overrides.map((o) => [o.transaction_id, o]));
|
||||||
|
|
||||||
|
const tagRows = await queryRaw<{ transaction_id: number; tag_id: number }>(
|
||||||
|
`SELECT transaction_id, tag_id FROM transaction_tags WHERE transaction_id = ANY($1::int[])`,
|
||||||
|
[ids]
|
||||||
|
);
|
||||||
|
const tagMap = new Map<number, number[]>();
|
||||||
|
for (const row of tagRows) {
|
||||||
|
if (!tagMap.has(row.transaction_id)) tagMap.set(row.transaction_id, []);
|
||||||
|
tagMap.get(row.transaction_id)!.push(row.tag_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitRows = await queryRaw<{
|
||||||
|
transaction_id: number;
|
||||||
|
participant_id: number;
|
||||||
|
share_percent: number;
|
||||||
|
settled: boolean;
|
||||||
|
}>(
|
||||||
|
`SELECT transaction_id, participant_id, share_percent, settled
|
||||||
|
FROM transaction_splits WHERE transaction_id = ANY($1::int[])`,
|
||||||
|
[ids]
|
||||||
|
);
|
||||||
|
const splitMap = new Map<number, { participant_id: number; share_percent: number; settled: boolean }[]>();
|
||||||
|
for (const row of splitRows) {
|
||||||
|
if (!splitMap.has(row.transaction_id)) splitMap.set(row.transaction_id, []);
|
||||||
|
splitMap.get(row.transaction_id)!.push({
|
||||||
|
participant_id: row.participant_id,
|
||||||
|
share_percent: row.share_percent,
|
||||||
|
settled: row.settled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids.map((id) => {
|
||||||
|
const ov = overrideMap.get(id);
|
||||||
|
return {
|
||||||
|
transaction_id: id,
|
||||||
|
had_override: !!ov,
|
||||||
|
prev_category_override: ov?.category_override ?? null,
|
||||||
|
prev_merchant_normalized: ov?.merchant_normalized ?? null,
|
||||||
|
prev_tag_ids: tagMap.get(id) ?? [],
|
||||||
|
prev_splits: splitMap.get(id) ?? [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user