Compare commits
1 Commits
main
...
925ed114a6
| Author | SHA1 | Date | |
|---|---|---|---|
| 925ed114a6 |
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS rules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
owner_id INTEGER NOT NULL REFERENCES participants(id),
|
||||
name TEXT NOT NULL,
|
||||
conditions JSONB NOT NULL DEFAULT '[]',
|
||||
actions JSONB NOT NULL DEFAULT '{}',
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rules_owner ON rules(owner_id);
|
||||
@@ -65,3 +65,27 @@ model transaction_tags {
|
||||
|
||||
@@id([transaction_id, tag_id])
|
||||
}
|
||||
|
||||
model rules {
|
||||
id Int @id @default(autoincrement())
|
||||
owner_id Int
|
||||
name String
|
||||
conditions Json @default("[]")
|
||||
actions Json @default("{}")
|
||||
enabled Boolean @default(true)
|
||||
priority Int @default(0)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
}
|
||||
|
||||
model budgets {
|
||||
id Int @id @default(autoincrement())
|
||||
owner_id Int
|
||||
category String
|
||||
month DateTime @db.Date
|
||||
amount_limit Decimal @db.Decimal(10, 2)
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
@@unique([owner_id, category, month])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw, prisma } from "@/lib/db";
|
||||
|
||||
export async function PATCH(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 body = await req.json();
|
||||
|
||||
const existing = await queryRaw<{ id: number }>(
|
||||
`SELECT id FROM rules WHERE id = $1 AND owner_id = $2`,
|
||||
[Number(id), user.id]
|
||||
);
|
||||
if (!existing.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const updated = await prisma.rules.update({
|
||||
where: { id: Number(id) },
|
||||
data: {
|
||||
...(body.name !== undefined && { name: body.name }),
|
||||
...(body.conditions !== undefined && { conditions: body.conditions }),
|
||||
...(body.actions !== undefined && { actions: body.actions }),
|
||||
...(body.enabled !== undefined && { enabled: body.enabled }),
|
||||
...(body.priority !== undefined && { priority: body.priority }),
|
||||
},
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
||||
export async function DELETE(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 existing = await queryRaw<{ id: number }>(
|
||||
`SELECT id FROM rules WHERE id = $1 AND owner_id = $2`,
|
||||
[Number(id), user.id]
|
||||
);
|
||||
if (!existing.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
await prisma.rules.delete({ where: { id: Number(id) } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { getTransactions } from "@/lib/queries";
|
||||
|
||||
interface Condition {
|
||||
field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount";
|
||||
operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals";
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Actions {
|
||||
set_category?: string;
|
||||
add_tag_ids?: number[];
|
||||
set_merchant?: string;
|
||||
}
|
||||
|
||||
interface TxFields {
|
||||
effective_category: string;
|
||||
effective_merchant: string;
|
||||
description: string;
|
||||
bank_name: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
function evaluateCondition(cond: Condition, tx: TxFields): boolean {
|
||||
if (cond.field === "amount") {
|
||||
const numVal = Number(tx.amount);
|
||||
const numCond = Number(cond.value);
|
||||
switch (cond.operator) {
|
||||
case "equals": return numVal === numCond;
|
||||
case "not_equals": return numVal !== numCond;
|
||||
case "gt": return numVal > numCond;
|
||||
case "lt": return numVal < numCond;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
let fieldVal: string;
|
||||
switch (cond.field) {
|
||||
case "merchant_normalized": fieldVal = tx.effective_merchant || ""; break;
|
||||
case "description": fieldVal = tx.description || ""; break;
|
||||
case "category": fieldVal = tx.effective_category || ""; break;
|
||||
case "bank_name": fieldVal = tx.bank_name || ""; break;
|
||||
default: return false;
|
||||
}
|
||||
|
||||
const strVal = fieldVal.toLowerCase();
|
||||
const strCond = cond.value.toLowerCase();
|
||||
switch (cond.operator) {
|
||||
case "contains": return strVal.includes(strCond);
|
||||
case "equals": return strVal === strCond;
|
||||
case "starts_with": return strVal.startsWith(strCond);
|
||||
case "not_equals": return strVal !== strCond;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
const rules = await queryRaw<{ id: number; conditions: unknown; actions: unknown }>(
|
||||
`SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND enabled = true ORDER BY priority DESC`,
|
||||
[user.id]
|
||||
);
|
||||
|
||||
if (!rules.length) return NextResponse.json({ matched: 0, transactions_affected: 0 });
|
||||
|
||||
const { data: transactions } = await getTransactions(user.id, { limit: 100000, offset: 0 });
|
||||
|
||||
let matched = 0;
|
||||
const affectedIds = new Set<number>();
|
||||
|
||||
for (const rule of rules) {
|
||||
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) {
|
||||
const allMatch =
|
||||
conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx));
|
||||
if (!allMatch) continue;
|
||||
|
||||
matched++;
|
||||
affectedIds.add(tx.id);
|
||||
|
||||
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()`,
|
||||
[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]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ matched, transactions_affected: affectedIds.size });
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw, prisma } from "@/lib/db";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
const rows = await queryRaw<{
|
||||
id: number;
|
||||
name: string;
|
||||
conditions: unknown;
|
||||
actions: unknown;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
created_at: string;
|
||||
}>(
|
||||
`SELECT id, name, conditions, actions, enabled, priority, created_at
|
||||
FROM rules WHERE owner_id = $1 ORDER BY priority DESC, id ASC`,
|
||||
[user.id]
|
||||
);
|
||||
return NextResponse.json(rows);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
const { name, conditions, actions, enabled = true, priority = 0 } = await req.json();
|
||||
if (!name) return NextResponse.json({ error: "name required" }, { status: 400 });
|
||||
|
||||
const rule = await prisma.rules.create({
|
||||
data: {
|
||||
owner_id: user.id,
|
||||
name,
|
||||
conditions: conditions ?? [],
|
||||
actions: actions ?? {},
|
||||
enabled,
|
||||
priority,
|
||||
},
|
||||
});
|
||||
return NextResponse.json(rule, { status: 201 });
|
||||
}
|
||||
+324
-3
@@ -1,8 +1,329 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useTags } from "@/lib/hooks";
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
|
||||
const FIELDS = [
|
||||
{ value: "merchant_normalized", label: "Merchant" },
|
||||
{ value: "description", label: "Description" },
|
||||
{ value: "category", label: "Category" },
|
||||
{ value: "bank_name", label: "Bank" },
|
||||
{ value: "amount", label: "Amount" },
|
||||
] as const;
|
||||
|
||||
const TEXT_OPS = [
|
||||
{ value: "contains", label: "contains" },
|
||||
{ value: "equals", label: "equals" },
|
||||
{ value: "starts_with", label: "starts with" },
|
||||
{ value: "not_equals", label: "not equals" },
|
||||
];
|
||||
const AMOUNT_OPS = [
|
||||
{ value: "equals", label: "=" },
|
||||
{ value: "not_equals", label: "≠" },
|
||||
{ value: "gt", label: ">" },
|
||||
{ value: "lt", label: "<" },
|
||||
];
|
||||
|
||||
type Condition = { field: string; operator: string; value: string };
|
||||
type Actions = { set_category?: string; add_tag_ids?: number[]; set_merchant?: string };
|
||||
|
||||
function humanCondition(c: Condition): string {
|
||||
const fieldLabel = FIELDS.find((f) => f.value === c.field)?.label || c.field;
|
||||
const ops = [...TEXT_OPS, ...AMOUNT_OPS];
|
||||
const opText = ops.find((o) => o.value === c.operator)?.label || c.operator;
|
||||
return `${fieldLabel} ${opText} "${c.value}"`;
|
||||
}
|
||||
|
||||
function humanAction(a: Actions, tagNames: Map<number, string>): string {
|
||||
const parts: string[] = [];
|
||||
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.add_tag_ids?.length) {
|
||||
const names = a.add_tag_ids.map((id) => tagNames.get(id) || `tag#${id}`).join(", ");
|
||||
parts.push(`add tags: ${names}`);
|
||||
}
|
||||
return parts.length ? "→ " + parts.join(", ") : "(no actions)";
|
||||
}
|
||||
|
||||
export default function RulesPage() {
|
||||
const { data: rules = [], isLoading } = useRules();
|
||||
const { data: tags = [] } = useTags();
|
||||
const createRule = useCreateRule();
|
||||
const updateRule = useUpdateRule();
|
||||
const deleteRule = useDeleteRule();
|
||||
const applyRules = useApplyRules();
|
||||
|
||||
const tagNames = new Map(tags.map((t) => [t.id, t.name]));
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [applyResult, setApplyResult] = useState<{ matched: number; transactions_affected: number } | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [conditions, setConditions] = useState<Condition[]>([]);
|
||||
const [actions, setActions] = useState<Actions>({});
|
||||
const [priority, setPriority] = useState(0);
|
||||
|
||||
function addCondition() {
|
||||
setConditions([...conditions, { field: "merchant_normalized", operator: "contains", value: "" }]);
|
||||
}
|
||||
|
||||
function updateCondition(i: number, patch: Partial<Condition>) {
|
||||
setConditions(conditions.map((c, idx) => (idx === i ? { ...c, ...patch } : c)));
|
||||
}
|
||||
|
||||
function removeCondition(i: number) {
|
||||
setConditions(conditions.filter((_, idx) => idx !== i));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
await createRule.mutateAsync({ name, conditions, actions, enabled: true, priority });
|
||||
setName("");
|
||||
setConditions([]);
|
||||
setActions({});
|
||||
setPriority(0);
|
||||
setShowForm(false);
|
||||
}
|
||||
|
||||
async function handleApply() {
|
||||
const result = await applyRules.mutateAsync();
|
||||
setApplyResult(result);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">Rules</h2>
|
||||
<p className="text-zinc-500">Coming soon - auto-classify transactions with rules.</p>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Rules</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={applyRules.isPending}
|
||||
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{applyRules.isPending ? "Applying..." : "Apply All Rules"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
{showForm ? "Cancel" : "New Rule"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{applyResult && (
|
||||
<div className="p-4 bg-emerald-900/30 border border-emerald-700 rounded-lg text-sm">
|
||||
Applied: <strong>{applyResult.matched}</strong> condition matches across{" "}
|
||||
<strong>{applyResult.transactions_affected}</strong> transactions.
|
||||
<button onClick={() => setApplyResult(null)} className="ml-4 text-zinc-400 hover:text-white">
|
||||
dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showForm && (
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Rule Name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
placeholder="e.g. Tag Woolworths as groceries"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-xs text-zinc-500">Conditions (ALL must match)</label>
|
||||
<button type="button" onClick={addCondition} className="text-xs text-indigo-400 hover:text-indigo-300">
|
||||
+ Add condition
|
||||
</button>
|
||||
</div>
|
||||
{conditions.map((cond, i) => {
|
||||
const isAmount = cond.field === "amount";
|
||||
const ops = isAmount ? AMOUNT_OPS : TEXT_OPS;
|
||||
return (
|
||||
<div key={i} className="flex gap-2 mb-2 items-center">
|
||||
<select
|
||||
value={cond.field}
|
||||
onChange={(e) =>
|
||||
updateCondition(i, {
|
||||
field: e.target.value,
|
||||
operator: e.target.value === "amount" ? "equals" : "contains",
|
||||
})
|
||||
}
|
||||
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
{FIELDS.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={cond.operator}
|
||||
onChange={(e) => updateCondition(i, { operator: e.target.value })}
|
||||
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
{ops.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
value={cond.value}
|
||||
onChange={(e) => updateCondition(i, { value: e.target.value })}
|
||||
placeholder="value"
|
||||
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeCondition(i)}
|
||||
className="text-zinc-500 hover:text-red-400 text-lg leading-none px-1"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{conditions.length === 0 && (
|
||||
<p className="text-xs text-zinc-600">No conditions — rule will match ALL transactions.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Set Category (optional)</label>
|
||||
<select
|
||||
value={actions.set_category || ""}
|
||||
onChange={(e) => setActions({ ...actions, set_category: e.target.value || undefined })}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">— no change —</option>
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{formatCategory(c)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Set Merchant (optional)</label>
|
||||
<input
|
||||
value={actions.set_merchant || ""}
|
||||
onChange={(e) => setActions({ ...actions, set_merchant: e.target.value || undefined })}
|
||||
placeholder="Normalized name"
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Add Tags (optional)</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => {
|
||||
const selected = (actions.add_tag_ids || []).includes(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const ids = actions.add_tag_ids || [];
|
||||
setActions({
|
||||
...actions,
|
||||
add_tag_ids: selected ? ids.filter((id) => id !== tag.id) : [...ids, tag.id],
|
||||
});
|
||||
}}
|
||||
className={`px-2 py-1 rounded text-xs border transition-colors ${
|
||||
selected ? "border-transparent text-white" : "border-zinc-700 text-zinc-400"
|
||||
}`}
|
||||
style={selected ? { backgroundColor: tag.color } : {}}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{tags.length === 0 && <p className="text-xs text-zinc-600">No tags created yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Priority</label>
|
||||
<input
|
||||
type="number"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(Number(e.target.value))}
|
||||
className="w-24 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createRule.isPending}
|
||||
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"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-zinc-500 text-sm">Loading rules...</p>
|
||||
) : rules.length === 0 ? (
|
||||
<p className="text-zinc-500 text-sm">No rules yet. Create one to auto-classify transactions.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{rules.map((rule) => {
|
||||
const conds = Array.isArray(rule.conditions) ? rule.conditions : [];
|
||||
const acts =
|
||||
rule.actions && typeof rule.actions === "object" ? (rule.actions as Actions) : {};
|
||||
return (
|
||||
<div key={rule.id} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<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>
|
||||
<span className="text-xs text-zinc-500">priority: {rule.priority}</span>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400">
|
||||
{conds.length > 0 ? conds.map(humanCondition).join(" AND ") : "(matches all)"}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 mt-1">{humanAction(acts, tagNames)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
onClick={() => updateRule.mutate({ id: rule.id, enabled: !rule.enabled })}
|
||||
className={`relative inline-flex h-5 w-9 rounded-full transition-colors ${
|
||||
rule.enabled ? "bg-indigo-600" : "bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform mt-0.5 ${
|
||||
rule.enabled ? "translate-x-4" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm("Delete this rule?")) deleteRule.mutate(rule.id);
|
||||
}}
|
||||
className="text-zinc-500 hover:text-red-400 text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -352,3 +352,82 @@ export function useCreateParticipant() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Rules ---
|
||||
|
||||
export interface RuleRow {
|
||||
id: number;
|
||||
name: string;
|
||||
conditions: { field: string; operator: string; value: string }[];
|
||||
actions: { set_category?: string; add_tag_ids?: number[]; set_merchant?: string };
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export function useRules() {
|
||||
return useQuery<RuleRow[]>({
|
||||
queryKey: ["rules"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/rules");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateRule() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: Omit<RuleRow, "id" | "created_at">) => {
|
||||
const res = await fetch("/api/rules", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to create rule");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRule() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, ...data }: Partial<RuleRow> & { id: number }) => {
|
||||
const res = await fetch(`/api/rules/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to update rule");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRule() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
await fetch(`/api/rules/${id}`, { method: "DELETE" });
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useApplyRules() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/rules/apply", { method: "POST" });
|
||||
if (!res.ok) throw new Error("Failed to apply rules");
|
||||
return res.json() as Promise<{ matched: number; transactions_affected: number }>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["rules"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user