"use client"; import { useState } from "react"; import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useRuleRuns, useRevertRuleRun, useTags, useParticipants } 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" }, { value: "transaction_type", label: "Transaction Type" }, { value: "tag", label: "Tag" }, ] 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: "<" }, ]; 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 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, tagNames?: Map): string { const fieldLabel = FIELDS.find((f) => f.value === c.field)?.label || c.field; if (c.field === "tag") { const tagName = tagNames?.get(Number(c.value)) || `tag#${c.value}`; return `Tag ${c.operator === "not_equals" ? "is not" : "is"} "${tagName}"`; } const ops = [...TEXT_OPS, ...AMOUNT_OPS, ...ENUM_OPS]; const opText = ops.find((o) => o.value === c.operator)?.label || c.operator; return `${fieldLabel} ${opText} "${c.value}"`; } function humanAction(a: Actions, tagNames: Map, participantNames: 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}`); } 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)"; } const EMPTY_ACTIONS: Actions = {}; export default function RulesPage() { const { data: rules = [], isLoading } = useRules(); const { data: tags = [] } = useTags(); const { data: participants = [] } = useParticipants(); const createRule = useCreateRule(); const updateRule = useUpdateRule(); const deleteRule = useDeleteRule(); const applyRules = useApplyRules(); const { data: runs = [] } = useRuleRuns(); const revertRun = useRevertRuleRun(); 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-09"); const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState(null); const [applyResult, setApplyResult] = useState<{ matched: number; transactions_affected: number } | null>(null); const [name, setName] = useState(""); const [conditions, setConditions] = useState([]); const [actions, setActions] = useState(EMPTY_ACTIONS); 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() { 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)); } 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) { 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) { e.preventDefault(); const payload = { name, conditions, actions, enabled: true, priority }; if (editingId !== null) { await updateRule.mutateAsync({ id: editingId, ...payload }); } else { await createRule.mutateAsync(payload); } closeForm(); } async function handleApply(ruleId?: number) { const result = await applyRules.mutateAsync({ splitFrom: applyFrom || undefined, ruleId }); 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 (

Rules

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." />
{applyResult && (
Applied: {applyResult.matched} condition matches across{" "} {applyResult.transactions_affected} transactions.
)} {showForm && (

{editingId !== null ? "Edit Rule" : "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 isEnum = cond.field === "transaction_type"; const isTag = cond.field === "tag"; const ops = isAmount ? AMOUNT_OPS : (isEnum || isTag) ? ENUM_OPS : TEXT_OPS; return (
{isTag ? ( ) : isEnum ? ( ) : ( 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.

}
{participants.length > 0 && ( )}
{(actions.apply_split || []).map((entry, i) => (
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" /> %
))} {participants.length === 0 && (

No participants 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" />
)} {runs.length > 0 && (

Apply History

{runs.map((run) => (
{new Date(run.applied_at).toLocaleString()} {run.matched} matches · {run.transactions_affected} transactions {run.split_from && splits from {run.split_from}}
{run.reverted_at ? ( reverted {new Date(run.reverted_at).toLocaleString()} ) : ( )}
))}
)} {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((c) => humanCondition(c, tagNames)).join(" AND ") : "(matches all)"}

{humanAction(acts, tagNames, participantNames)}

); })}
)}
); }