"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): 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([]); const [actions, setActions] = useState({}); const [priority, setPriority] = useState(0); function addCondition() { setConditions([...conditions, { field: "merchant_normalized", operator: "contains", value: "" }]); } function updateCondition(i: number, patch: Partial) { 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 (

Rules

{applyResult && (
Applied: {applyResult.matched} condition matches across{" "} {applyResult.transactions_affected} transactions.
)} {showForm && (

New Rule

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" />
{conditions.map((cond, i) => { const isAmount = cond.field === "amount"; const ops = isAmount ? AMOUNT_OPS : TEXT_OPS; return (
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" />
); })} {conditions.length === 0 && (

No conditions — rule will match ALL transactions.

)}
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" />
{tags.map((tag) => { const selected = (actions.add_tag_ids || []).includes(tag.id); return ( ); })} {tags.length === 0 &&

No tags created yet.

}
setPriority(Number(e.target.value))} className="w-24 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm" />
)} {isLoading ? (

Loading rules...

) : rules.length === 0 ? (

No rules yet. Create one to auto-classify transactions.

) : (
{rules.map((rule) => { const conds = Array.isArray(rule.conditions) ? rule.conditions : []; const acts = rule.actions && typeof rule.actions === "object" ? (rule.actions as Actions) : {}; return (
{rule.name} priority: {rule.priority}

{conds.length > 0 ? conds.map(humanCondition).join(" AND ") : "(matches all)"}

{humanAction(acts, tagNames)}

); })}
)}
); }