feat(finance): Phase 5 — Rules Engine

Add rules engine with CRUD API, condition/action evaluation, and apply-all endpoint.
UI: rule builder form with field/operator/value conditions, tag multi-select, apply button with result stats.
This commit is contained in:
2026-03-08 16:48:35 +11:00
parent 93450f7caa
commit 31cffbe1bb
9 changed files with 653 additions and 8 deletions
+324 -3
View File
@@ -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>
);
}