feat(rules): apply_split rules with run history and revert
- POST /api/rules/apply — run all enabled rules against unmatched transactions - POST /api/rules/apply/:id — apply a single rule by id - DELETE /api/rules/apply/:id — revert a rule run (remove applied splits) - Rules page: show run history with revert button, apply individual rules
This commit is contained in:
@@ -0,0 +1,102 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
|
||||||
|
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 POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const runId = Number(id);
|
||||||
|
|
||||||
|
const rows = await queryRaw<{
|
||||||
|
id: number;
|
||||||
|
owner_id: number;
|
||||||
|
reverted_at: string | null;
|
||||||
|
snapshot: unknown;
|
||||||
|
}>(
|
||||||
|
`SELECT id, owner_id, reverted_at, snapshot FROM rule_apply_runs WHERE id = $1`,
|
||||||
|
[runId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!rows.length) return NextResponse.json({ error: "Run not found" }, { status: 404 });
|
||||||
|
const run = rows[0];
|
||||||
|
if (run.owner_id !== user.id) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
if (run.reverted_at) return NextResponse.json({ error: "Already reverted" }, { status: 409 });
|
||||||
|
|
||||||
|
const snapshot = (typeof run.snapshot === "string"
|
||||||
|
? JSON.parse(run.snapshot)
|
||||||
|
: run.snapshot) as SnapshotEntry[];
|
||||||
|
|
||||||
|
for (const entry of snapshot) {
|
||||||
|
const txId = entry.transaction_id;
|
||||||
|
|
||||||
|
// Restore overrides
|
||||||
|
if (entry.had_override) {
|
||||||
|
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 = $2,
|
||||||
|
merchant_normalized = $3,
|
||||||
|
updated_at = NOW()`,
|
||||||
|
[txId, entry.prev_category_override, entry.prev_merchant_normalized]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// No override existed before — remove any that were created
|
||||||
|
await queryRaw(
|
||||||
|
`DELETE FROM transaction_overrides WHERE transaction_id = $1
|
||||||
|
AND category_override IS NULL AND merchant_normalized IS NULL`,
|
||||||
|
[txId]
|
||||||
|
);
|
||||||
|
// If override row exists but was only partially set by this run, clear those fields
|
||||||
|
await queryRaw(
|
||||||
|
`UPDATE transaction_overrides SET
|
||||||
|
category_override = NULL,
|
||||||
|
merchant_normalized = NULL,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE transaction_id = $1`,
|
||||||
|
[txId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore tags: remove any that weren't there before, don't touch pre-existing ones
|
||||||
|
const prevTagIds = entry.prev_tag_ids;
|
||||||
|
if (prevTagIds.length > 0) {
|
||||||
|
await queryRaw(
|
||||||
|
`DELETE FROM transaction_tags WHERE transaction_id = $1 AND tag_id != ALL($2::int[])`,
|
||||||
|
[txId, prevTagIds]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// No tags existed before — remove all tags (they were all added by this run)
|
||||||
|
// Note: this only removes tags on transactions that matched this run
|
||||||
|
await queryRaw(`DELETE FROM transaction_tags WHERE transaction_id = $1`, [txId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore splits
|
||||||
|
await queryRaw(`DELETE FROM transaction_splits WHERE transaction_id = $1`, [txId]);
|
||||||
|
for (const s of entry.prev_splits) {
|
||||||
|
await queryRaw(
|
||||||
|
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
|
||||||
|
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
|
||||||
|
[txId, s.participant_id, s.share_percent, s.settled]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await queryRaw(
|
||||||
|
`UPDATE rule_apply_runs SET reverted_at = NOW() WHERE id = $1`,
|
||||||
|
[runId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ reverted: snapshot.length });
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { queryRaw } from "@/lib/db";
|
|||||||
import { getTransactions } from "@/lib/queries";
|
import { getTransactions } from "@/lib/queries";
|
||||||
|
|
||||||
interface Condition {
|
interface Condition {
|
||||||
field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount";
|
field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount" | "transaction_type";
|
||||||
operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals";
|
operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals";
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
@@ -21,12 +21,22 @@ interface Actions {
|
|||||||
apply_split?: SplitEntry[];
|
apply_split?: SplitEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 }[];
|
||||||
|
}
|
||||||
|
|
||||||
interface TxFields {
|
interface TxFields {
|
||||||
effective_category: string;
|
effective_category: string;
|
||||||
effective_merchant: string;
|
effective_merchant: string;
|
||||||
description: string;
|
description: string;
|
||||||
bank_name: string;
|
bank_name: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
transaction_type: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function evaluateCondition(cond: Condition, tx: TxFields): boolean {
|
function evaluateCondition(cond: Condition, tx: TxFields): boolean {
|
||||||
@@ -48,6 +58,7 @@ function evaluateCondition(cond: Condition, tx: TxFields): boolean {
|
|||||||
case "description": fieldVal = tx.description || ""; break;
|
case "description": fieldVal = tx.description || ""; break;
|
||||||
case "category": fieldVal = tx.effective_category || ""; break;
|
case "category": fieldVal = tx.effective_category || ""; break;
|
||||||
case "bank_name": fieldVal = tx.bank_name || ""; break;
|
case "bank_name": fieldVal = tx.bank_name || ""; break;
|
||||||
|
case "transaction_type": fieldVal = tx.transaction_type || ""; break;
|
||||||
default: return false;
|
default: return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,6 +73,26 @@ function evaluateCondition(cond: Condition, tx: TxFields): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const runs = await queryRaw<{
|
||||||
|
id: number;
|
||||||
|
applied_at: string;
|
||||||
|
split_from: string | null;
|
||||||
|
matched: number;
|
||||||
|
transactions_affected: number;
|
||||||
|
reverted_at: string | null;
|
||||||
|
}>(
|
||||||
|
`SELECT id, applied_at, split_from, matched, transactions_affected, reverted_at
|
||||||
|
FROM rule_apply_runs WHERE owner_id = $1 ORDER BY applied_at DESC LIMIT 20`,
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(runs);
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
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 });
|
||||||
@@ -73,22 +104,81 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
if (!rules.length) return NextResponse.json({ matched: 0, transactions_affected: 0 });
|
if (!rules.length) return NextResponse.json({ matched: 0, transactions_affected: 0 });
|
||||||
|
|
||||||
|
const body = await req.json().catch(() => ({})) as { splitFrom?: string | null };
|
||||||
|
const splitFrom = body.splitFrom || null;
|
||||||
|
|
||||||
const { data: transactions } = await getTransactions(user.id, { limit: 100000, offset: 0 });
|
const { data: transactions } = await getTransactions(user.id, { limit: 100000, offset: 0 });
|
||||||
|
|
||||||
|
// --- Pre-pass: find all transactions that will match any rule ---
|
||||||
|
const parsedRules = rules.map((r) => ({
|
||||||
|
conditions: (typeof r.conditions === "string" ? JSON.parse(r.conditions) : r.conditions) as Condition[],
|
||||||
|
actions: (typeof r.actions === "string" ? JSON.parse(r.actions) : r.actions) as Actions,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const matchedIds = new Set<number>();
|
||||||
|
for (const tx of transactions) {
|
||||||
|
for (const { conditions } of parsedRules) {
|
||||||
|
if (conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx))) {
|
||||||
|
matchedIds.add(tx.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Capture before-state for all matched transactions (batched) ---
|
||||||
|
const snapshot: SnapshotEntry[] = [];
|
||||||
|
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 ---
|
||||||
let matched = 0;
|
let matched = 0;
|
||||||
const affectedIds = new Set<number>();
|
const affectedIds = new Set<number>();
|
||||||
|
|
||||||
for (const rule of rules) {
|
for (const { conditions, actions } of parsedRules) {
|
||||||
const conditions = (typeof rule.conditions === "string"
|
|
||||||
? JSON.parse(rule.conditions)
|
|
||||||
: rule.conditions) as Condition[];
|
|
||||||
const actions = (typeof rule.actions === "string"
|
|
||||||
? JSON.parse(rule.actions)
|
|
||||||
: rule.actions) as Actions;
|
|
||||||
|
|
||||||
for (const tx of transactions) {
|
for (const tx of transactions) {
|
||||||
const allMatch =
|
const allMatch = conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx));
|
||||||
conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx));
|
|
||||||
if (!allMatch) continue;
|
if (!allMatch) continue;
|
||||||
|
|
||||||
matched++;
|
matched++;
|
||||||
@@ -116,7 +206,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (actions.apply_split?.length) {
|
if (actions.apply_split?.length) {
|
||||||
// Delete existing splits then insert new ones
|
if (splitFrom && tx.transaction_date < splitFrom) continue;
|
||||||
await queryRaw(`DELETE FROM transaction_splits WHERE transaction_id = $1`, [tx.id]);
|
await queryRaw(`DELETE FROM transaction_splits WHERE transaction_id = $1`, [tx.id]);
|
||||||
for (const s of actions.apply_split) {
|
for (const s of actions.apply_split) {
|
||||||
await queryRaw(
|
await queryRaw(
|
||||||
@@ -129,5 +219,12 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ matched, transactions_affected: affectedIds.size });
|
// --- Save run record ---
|
||||||
|
const run = await queryRaw<{ id: number }>(
|
||||||
|
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot)
|
||||||
|
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||||
|
[user.id, splitFrom, matched, affectedIds.size, JSON.stringify(snapshot)]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ id: run[0].id, matched, transactions_affected: affectedIds.size });
|
||||||
}
|
}
|
||||||
|
|||||||
+199
-25
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useTags } from "@/lib/hooks";
|
import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useRuleRuns, useRevertRuleRun, useTags, useParticipants } from "@/lib/hooks";
|
||||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
|
|
||||||
const FIELDS = [
|
const FIELDS = [
|
||||||
@@ -10,6 +10,7 @@ const FIELDS = [
|
|||||||
{ value: "category", label: "Category" },
|
{ value: "category", label: "Category" },
|
||||||
{ value: "bank_name", label: "Bank" },
|
{ value: "bank_name", label: "Bank" },
|
||||||
{ value: "amount", label: "Amount" },
|
{ value: "amount", label: "Amount" },
|
||||||
|
{ value: "transaction_type", label: "Transaction Type" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const TEXT_OPS = [
|
const TEXT_OPS = [
|
||||||
@@ -24,18 +25,24 @@ const AMOUNT_OPS = [
|
|||||||
{ value: "gt", label: ">" },
|
{ value: "gt", label: ">" },
|
||||||
{ value: "lt", label: "<" },
|
{ value: "lt", label: "<" },
|
||||||
];
|
];
|
||||||
|
const ENUM_OPS = [
|
||||||
|
{ value: "equals", label: "equals" },
|
||||||
|
{ value: "not_equals", label: "not equals" },
|
||||||
|
];
|
||||||
|
const TRANSACTION_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
||||||
|
|
||||||
type Condition = { field: string; operator: string; value: string };
|
type Condition = { field: string; operator: string; value: string };
|
||||||
type Actions = { set_category?: string; add_tag_ids?: number[]; set_merchant?: string };
|
type SplitEntry = { participant_id: number; share_percent: number };
|
||||||
|
type Actions = { set_category?: string; add_tag_ids?: number[]; set_merchant?: string; apply_split?: SplitEntry[] };
|
||||||
|
|
||||||
function humanCondition(c: Condition): string {
|
function humanCondition(c: Condition): string {
|
||||||
const fieldLabel = FIELDS.find((f) => f.value === c.field)?.label || c.field;
|
const fieldLabel = FIELDS.find((f) => f.value === c.field)?.label || c.field;
|
||||||
const ops = [...TEXT_OPS, ...AMOUNT_OPS];
|
const ops = [...TEXT_OPS, ...AMOUNT_OPS, ...ENUM_OPS];
|
||||||
const opText = ops.find((o) => o.value === c.operator)?.label || c.operator;
|
const opText = ops.find((o) => o.value === c.operator)?.label || c.operator;
|
||||||
return `${fieldLabel} ${opText} "${c.value}"`;
|
return `${fieldLabel} ${opText} "${c.value}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function humanAction(a: Actions, tagNames: Map<number, string>): string {
|
function humanAction(a: Actions, tagNames: Map<number, string>, participantNames: Map<number, string>): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (a.set_category) parts.push(`set category: ${formatCategory(a.set_category)}`);
|
if (a.set_category) parts.push(`set category: ${formatCategory(a.set_category)}`);
|
||||||
if (a.set_merchant) parts.push(`set merchant: ${a.set_merchant}`);
|
if (a.set_merchant) parts.push(`set merchant: ${a.set_merchant}`);
|
||||||
@@ -43,26 +50,62 @@ function humanAction(a: Actions, tagNames: Map<number, string>): string {
|
|||||||
const names = a.add_tag_ids.map((id) => tagNames.get(id) || `tag#${id}`).join(", ");
|
const names = a.add_tag_ids.map((id) => tagNames.get(id) || `tag#${id}`).join(", ");
|
||||||
parts.push(`add tags: ${names}`);
|
parts.push(`add tags: ${names}`);
|
||||||
}
|
}
|
||||||
|
if (a.apply_split?.length) {
|
||||||
|
const splits = a.apply_split.map((s) => `${participantNames.get(s.participant_id) || `#${s.participant_id}`} ${s.share_percent}%`).join(", ");
|
||||||
|
parts.push(`split: ${splits}`);
|
||||||
|
}
|
||||||
return parts.length ? "→ " + parts.join(", ") : "(no actions)";
|
return parts.length ? "→ " + parts.join(", ") : "(no actions)";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_ACTIONS: Actions = {};
|
||||||
|
|
||||||
export default function RulesPage() {
|
export default function RulesPage() {
|
||||||
const { data: rules = [], isLoading } = useRules();
|
const { data: rules = [], isLoading } = useRules();
|
||||||
const { data: tags = [] } = useTags();
|
const { data: tags = [] } = useTags();
|
||||||
|
const { data: participants = [] } = useParticipants();
|
||||||
const createRule = useCreateRule();
|
const createRule = useCreateRule();
|
||||||
const updateRule = useUpdateRule();
|
const updateRule = useUpdateRule();
|
||||||
const deleteRule = useDeleteRule();
|
const deleteRule = useDeleteRule();
|
||||||
const applyRules = useApplyRules();
|
const applyRules = useApplyRules();
|
||||||
|
const { data: runs = [] } = useRuleRuns();
|
||||||
|
const revertRun = useRevertRuleRun();
|
||||||
|
|
||||||
const tagNames = new Map(tags.map((t) => [t.id, t.name]));
|
const tagNames = new Map(tags.map((t) => [t.id, t.name]));
|
||||||
|
const participantNames = new Map(participants.map((p) => [p.id, p.name]));
|
||||||
|
|
||||||
|
const [applyFrom, setApplyFrom] = useState("2026-01-08");
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [applyResult, setApplyResult] = useState<{ matched: number; transactions_affected: number } | null>(null);
|
const [applyResult, setApplyResult] = useState<{ matched: number; transactions_affected: number } | null>(null);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [conditions, setConditions] = useState<Condition[]>([]);
|
const [conditions, setConditions] = useState<Condition[]>([]);
|
||||||
const [actions, setActions] = useState<Actions>({});
|
const [actions, setActions] = useState<Actions>(EMPTY_ACTIONS);
|
||||||
const [priority, setPriority] = useState(0);
|
const [priority, setPriority] = useState(0);
|
||||||
|
|
||||||
|
function openNewForm() {
|
||||||
|
setEditingId(null);
|
||||||
|
setName("");
|
||||||
|
setConditions([]);
|
||||||
|
setActions(EMPTY_ACTIONS);
|
||||||
|
setPriority(0);
|
||||||
|
setShowForm(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditForm(rule: { id: number; name: string; conditions: Condition[]; actions: Actions; priority: number }) {
|
||||||
|
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);
|
||||||
|
setShowForm(true);
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForm() {
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
|
||||||
function addCondition() {
|
function addCondition() {
|
||||||
setConditions([...conditions, { field: "merchant_normalized", operator: "contains", value: "" }]);
|
setConditions([...conditions, { field: "merchant_normalized", operator: "contains", value: "" }]);
|
||||||
}
|
}
|
||||||
@@ -75,26 +118,54 @@ export default function RulesPage() {
|
|||||||
setConditions(conditions.filter((_, idx) => idx !== i));
|
setConditions(conditions.filter((_, idx) => idx !== i));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addSplitEntry() {
|
||||||
|
if (!participants.length) return;
|
||||||
|
const existing = actions.apply_split || [];
|
||||||
|
setActions({ ...actions, apply_split: [...existing, { participant_id: participants[0].id, share_percent: 0 }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSplitEntry(i: number, patch: Partial<SplitEntry>) {
|
||||||
|
const entries = (actions.apply_split || []).map((s, idx) => (idx === i ? { ...s, ...patch } : s));
|
||||||
|
setActions({ ...actions, apply_split: entries });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSplitEntry(i: number) {
|
||||||
|
const entries = (actions.apply_split || []).filter((_, idx) => idx !== i);
|
||||||
|
setActions({ ...actions, apply_split: entries.length ? entries : undefined });
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
await createRule.mutateAsync({ name, conditions, actions, enabled: true, priority });
|
const payload = { name, conditions, actions, enabled: true, priority };
|
||||||
setName("");
|
if (editingId !== null) {
|
||||||
setConditions([]);
|
await updateRule.mutateAsync({ id: editingId, ...payload });
|
||||||
setActions({});
|
} else {
|
||||||
setPriority(0);
|
await createRule.mutateAsync(payload);
|
||||||
setShowForm(false);
|
}
|
||||||
|
closeForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleApply() {
|
async function handleApply() {
|
||||||
const result = await applyRules.mutateAsync();
|
const result = await applyRules.mutateAsync(applyFrom || undefined);
|
||||||
setApplyResult(result);
|
setApplyResult(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const splitTotal = (actions.apply_split || []).reduce((sum, s) => sum + (s.share_percent || 0), 0);
|
||||||
|
const isPending = editingId !== null ? updateRule.isPending : createRule.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-semibold">Rules</h2>
|
<h2 className="text-xl font-semibold">Rules</h2>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 items-center">
|
||||||
|
<label className="text-xs text-zinc-500 whitespace-nowrap">Splits from</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={applyFrom}
|
||||||
|
onChange={(e) => setApplyFrom(e.target.value)}
|
||||||
|
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
title="Split rules only apply to transactions on or after this date. Category/merchant/tag rules apply to all transactions."
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleApply}
|
onClick={handleApply}
|
||||||
disabled={applyRules.isPending}
|
disabled={applyRules.isPending}
|
||||||
@@ -103,7 +174,7 @@ export default function RulesPage() {
|
|||||||
{applyRules.isPending ? "Applying..." : "Apply All Rules"}
|
{applyRules.isPending ? "Applying..." : "Apply All Rules"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowForm(!showForm)}
|
onClick={showForm ? closeForm : openNewForm}
|
||||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium"
|
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium"
|
||||||
>
|
>
|
||||||
{showForm ? "Cancel" : "New Rule"}
|
{showForm ? "Cancel" : "New Rule"}
|
||||||
@@ -123,7 +194,7 @@ export default function RulesPage() {
|
|||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<form onSubmit={handleSubmit} className="bg-zinc-900 border border-zinc-700 rounded-xl p-6 space-y-4">
|
<form onSubmit={handleSubmit} className="bg-zinc-900 border border-zinc-700 rounded-xl p-6 space-y-4">
|
||||||
<h3 className="font-semibold text-sm text-zinc-300">New Rule</h3>
|
<h3 className="font-semibold text-sm text-zinc-300">{editingId !== null ? "Edit Rule" : "New Rule"}</h3>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-zinc-500 mb-1">Rule Name</label>
|
<label className="block text-xs text-zinc-500 mb-1">Rule Name</label>
|
||||||
@@ -145,17 +216,20 @@ export default function RulesPage() {
|
|||||||
</div>
|
</div>
|
||||||
{conditions.map((cond, i) => {
|
{conditions.map((cond, i) => {
|
||||||
const isAmount = cond.field === "amount";
|
const isAmount = cond.field === "amount";
|
||||||
const ops = isAmount ? AMOUNT_OPS : TEXT_OPS;
|
const isEnum = cond.field === "transaction_type";
|
||||||
|
const ops = isAmount ? AMOUNT_OPS : isEnum ? ENUM_OPS : TEXT_OPS;
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex gap-2 mb-2 items-center">
|
<div key={i} className="flex gap-2 mb-2 items-center">
|
||||||
<select
|
<select
|
||||||
value={cond.field}
|
value={cond.field}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
updateCondition(i, {
|
const newField = e.target.value;
|
||||||
field: e.target.value,
|
const patch: Partial<Condition> = { field: newField };
|
||||||
operator: e.target.value === "amount" ? "equals" : "contains",
|
if (newField === "amount") { patch.operator = "equals"; patch.value = ""; }
|
||||||
})
|
else if (newField === "transaction_type") { patch.operator = "equals"; patch.value = "debit"; }
|
||||||
}
|
else { patch.operator = "contains"; patch.value = ""; }
|
||||||
|
updateCondition(i, patch);
|
||||||
|
}}
|
||||||
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
>
|
>
|
||||||
{FIELDS.map((f) => (
|
{FIELDS.map((f) => (
|
||||||
@@ -175,12 +249,24 @@ export default function RulesPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{isEnum ? (
|
||||||
|
<select
|
||||||
|
value={cond.value}
|
||||||
|
onChange={(e) => updateCondition(i, { value: e.target.value })}
|
||||||
|
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
{TRANSACTION_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
<input
|
<input
|
||||||
value={cond.value}
|
value={cond.value}
|
||||||
onChange={(e) => updateCondition(i, { value: e.target.value })}
|
onChange={(e) => updateCondition(i, { value: e.target.value })}
|
||||||
placeholder="value"
|
placeholder="value"
|
||||||
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeCondition(i)}
|
onClick={() => removeCondition(i)}
|
||||||
@@ -252,6 +338,56 @@ export default function RulesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="text-xs text-zinc-500">
|
||||||
|
Apply Split (optional)
|
||||||
|
{(actions.apply_split?.length ?? 0) > 0 && (
|
||||||
|
<span className={`ml-2 ${splitTotal === 100 ? "text-emerald-400" : "text-amber-400"}`}>
|
||||||
|
{splitTotal}% total
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
{participants.length > 0 && (
|
||||||
|
<button type="button" onClick={addSplitEntry} className="text-xs text-indigo-400 hover:text-indigo-300">
|
||||||
|
+ Add participant
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(actions.apply_split || []).map((entry, i) => (
|
||||||
|
<div key={i} className="flex gap-2 mb-2 items-center">
|
||||||
|
<select
|
||||||
|
value={entry.participant_id}
|
||||||
|
onChange={(e) => updateSplitEntry(i, { participant_id: Number(e.target.value) })}
|
||||||
|
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
{participants.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={entry.share_percent}
|
||||||
|
onChange={(e) => updateSplitEntry(i, { share_percent: Number(e.target.value) })}
|
||||||
|
className="w-20 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-zinc-500">%</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeSplitEntry(i)}
|
||||||
|
className="text-zinc-500 hover:text-red-400 text-lg leading-none px-1"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{participants.length === 0 && (
|
||||||
|
<p className="text-xs text-zinc-600">No participants created yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-end gap-4">
|
<div className="flex items-end gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-zinc-500 mb-1">Priority</label>
|
<label className="block text-xs text-zinc-500 mb-1">Priority</label>
|
||||||
@@ -264,15 +400,47 @@ export default function RulesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={createRule.isPending}
|
disabled={isPending}
|
||||||
className="px-6 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
className="px-6 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{createRule.isPending ? "Creating..." : "Create Rule"}
|
{isPending ? "Saving..." : editingId !== null ? "Save Changes" : "Create Rule"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{runs.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-zinc-400 mb-2">Apply History</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{runs.map((run) => (
|
||||||
|
<div key={run.id} className={`flex items-center justify-between px-4 py-2.5 rounded-lg border text-sm ${run.reverted_at ? "bg-zinc-900/40 border-zinc-800 opacity-60" : "bg-zinc-900 border-zinc-700"}`}>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-zinc-300">{new Date(run.applied_at).toLocaleString()}</span>
|
||||||
|
<span className="text-zinc-500">{run.matched} matches · {run.transactions_affected} transactions</span>
|
||||||
|
{run.split_from && <span className="text-zinc-600 text-xs">splits from {run.split_from}</span>}
|
||||||
|
</div>
|
||||||
|
{run.reverted_at ? (
|
||||||
|
<span className="text-xs text-zinc-500">reverted {new Date(run.reverted_at).toLocaleString()}</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm("Revert this run? This will restore all affected transactions to their state before the rules were applied.")) {
|
||||||
|
revertRun.mutate(run.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={revertRun.isPending}
|
||||||
|
className="text-xs text-amber-400 hover:text-amber-300 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Revert
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="text-zinc-500 text-sm">Loading rules...</p>
|
<p className="text-zinc-500 text-sm">Loading rules...</p>
|
||||||
) : rules.length === 0 ? (
|
) : rules.length === 0 ? (
|
||||||
@@ -294,7 +462,7 @@ export default function RulesPage() {
|
|||||||
<p className="text-xs text-zinc-400">
|
<p className="text-xs text-zinc-400">
|
||||||
{conds.length > 0 ? conds.map(humanCondition).join(" AND ") : "(matches all)"}
|
{conds.length > 0 ? conds.map(humanCondition).join(" AND ") : "(matches all)"}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-zinc-500 mt-1">{humanAction(acts, tagNames)}</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
|
<button
|
||||||
@@ -309,6 +477,12 @@ export default function RulesPage() {
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => openEditForm({ id: rule.id, name: rule.name, conditions: conds as Condition[], actions: acts, priority: rule.priority })}
|
||||||
|
className="text-zinc-400 hover:text-white text-sm"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm("Delete this rule?")) deleteRule.mutate(rule.id);
|
if (confirm("Delete this rule?")) deleteRule.mutate(rule.id);
|
||||||
|
|||||||
Reference in New Issue
Block a user