Files
finance-app/src/app/rules/page.tsx
T
siddharthd 31cffbe1bb 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.
2026-03-08 16:56:13 +11:00

330 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 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>
);
}