Files
finance-app/src/app/rules/page.tsx
T
siddharthd cc852e7c6f
ci / lint-test (push) Successful in 34s
feat(rules): preview what a rule would change before applying it
Selecting a rule now shows the transactions it would alter, so a subset can be
ticked and applied rather than trusting a bulk run. The apply step takes explicit
transaction ids (the existing bulk apply_rule path), so what you tick is exactly
what changes - a rule whose conditions are too broad cannot reach further than
the preview showed.

Matches are split into 'would change' and 'already correct'. A merchant
normalisation rule matching 400 rows where 380 already hold the right value is 20
changes and 380 rows of noise; only the 20 are listed.

Preview is offered for every rule including manual_only quick actions, which
previously had no way to see their reach at all. A rule with no conditions
matches every transaction - that is how apply already behaves, so the preview
reports it prominently rather than hiding it.

Applies still snapshot to rule_apply_runs, so they remain revertable.
2026-07-26 15:08:29 +10:00

578 lines
25 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, useRuleRuns, useRevertRuleRun, useTags, useParticipants } from "@/lib/hooks";
import { CATEGORIES, formatCategory } from "@/lib/categories";
import { RulePreviewModal } from "@/components/rule-preview-modal";
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<number, string>): 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<number, string>, participantNames: 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}`);
}
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<number | null>(null);
const [applyResult, setApplyResult] = useState<{ matched: number; transactions_affected: number } | null>(null);
const [preview, setPreview] = useState<{ id: number; name: string } | null>(null);
const [name, setName] = useState("");
const [conditions, setConditions] = useState<Condition[]>([]);
const [actions, setActions] = useState<Actions>(EMPTY_ACTIONS);
const [priority, setPriority] = useState(0);
const [manualOnly, setManualOnly] = useState(false);
function openNewForm() {
setEditingId(null);
setName("");
setConditions([]);
setActions(EMPTY_ACTIONS);
setPriority(0);
setManualOnly(false);
setShowForm(true);
}
function openEditForm(rule: { id: number; name: string; conditions: Condition[]; actions: Actions; priority: number; manual_only?: boolean }) {
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);
setManualOnly(!!rule.manual_only);
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<Condition>) {
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<SplitEntry>) {
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, manual_only: manualOnly, 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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-display">Rules</h2>
<div className="flex gap-2 items-center">
<label className="text-xs text-zinc-500 whitespace-nowrap">Splits from</label>
<input
type="date"
value={applyFrom}
onChange={(e) => 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."
/>
<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={showForm ? closeForm : openNewForm}
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">{editingId !== null ? "Edit Rule" : "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 isEnum = cond.field === "transaction_type";
const isTag = cond.field === "tag";
const ops = isAmount ? AMOUNT_OPS : (isEnum || isTag) ? ENUM_OPS : TEXT_OPS;
return (
<div key={i} className="flex gap-2 mb-2 items-center">
<select
value={cond.field}
onChange={(e) => {
const newField = e.target.value;
const patch: Partial<Condition> = { field: newField };
if (newField === "amount") { patch.operator = "equals"; patch.value = ""; }
else if (newField === "transaction_type") { patch.operator = "equals"; patch.value = "debit"; }
else if (newField === "tag") { patch.operator = "equals"; patch.value = tags[0] ? String(tags[0].id) : ""; }
else { patch.operator = "contains"; patch.value = ""; }
updateCondition(i, patch);
}}
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>
{isTag ? (
<select
value={cond.value}
onChange={(e) => updateCondition(i, { value: e.target.value })}
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
{tags.map((t) => (
<option key={t.id} value={String(t.id)}>{t.name}</option>
))}
{tags.length === 0 && <option value="">No tags</option>}
</select>
) : isEnum ? (
<select
value={cond.value}
onChange={(e) => updateCondition(i, { value: e.target.value })}
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
{TRANSACTION_TYPES.map((t) => (
<option key={t} value={t}>{t}</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>
<div className="flex items-center justify-between mb-2">
<label className="text-xs text-zinc-500">
Apply Split (optional)
{(actions.apply_split?.length ?? 0) > 0 && (
<span className={`ml-2 ${splitTotal === 100 ? "text-emerald-400" : "text-amber-400"}`}>
{splitTotal}% total
</span>
)}
</label>
{participants.length > 0 && (
<button type="button" onClick={addSplitEntry} className="text-xs text-indigo-400 hover:text-indigo-300">
+ Add participant
</button>
)}
</div>
{(actions.apply_split || []).map((entry, i) => (
<div key={i} className="flex gap-2 mb-2 items-center">
<select
value={entry.participant_id}
onChange={(e) => updateSplitEntry(i, { participant_id: Number(e.target.value) })}
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
{participants.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
<input
type="number"
min={0}
max={100}
value={entry.share_percent}
onChange={(e) => 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"
/>
<span className="text-xs text-zinc-500">%</span>
<button
type="button"
onClick={() => removeSplitEntry(i)}
className="text-zinc-500 hover:text-red-400 text-lg leading-none px-1"
>
×
</button>
</div>
))}
{participants.length === 0 && (
<p className="text-xs text-zinc-600">No participants created yet.</p>
)}
</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>
<label className="flex items-center gap-2 text-sm text-zinc-400 cursor-pointer select-none pb-2">
<input
type="checkbox"
checked={manualOnly}
onChange={(e) => setManualOnly(e.target.checked)}
className="accent-amber-500"
/>
<span>
Quick action only
<span className="block text-xs text-zinc-600">
Never runs automatically appears as a button on selected transactions
</span>
</span>
</label>
<button
type="submit"
disabled={isPending}
className="px-6 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
>
{isPending ? "Saving..." : editingId !== null ? "Save Changes" : "Create Rule"}
</button>
</div>
</form>
)}
{runs.length > 0 && (
<div>
<h3 className="text-sm font-medium text-zinc-400 mb-2">Apply History</h3>
<div className="space-y-2">
{runs.map((run) => (
<div key={run.id} className={`flex items-center justify-between px-4 py-2.5 rounded-lg border text-sm ${run.reverted_at ? "bg-zinc-900/40 border-zinc-800 opacity-60" : "bg-zinc-900 border-zinc-700"}`}>
<div className="flex items-center gap-4">
<span className="text-zinc-300">{new Date(run.applied_at).toLocaleString()}</span>
<span className="text-zinc-500">{run.matched} matches · {run.transactions_affected} transactions</span>
{run.split_from && <span className="text-zinc-600 text-xs">splits from {run.split_from}</span>}
</div>
{run.reverted_at ? (
<span className="text-xs text-zinc-500">reverted {new Date(run.reverted_at).toLocaleString()}</span>
) : (
<button
onClick={() => {
if (confirm("Revert this run? This will restore all affected transactions to their state before the rules were applied.")) {
revertRun.mutate(run.id);
}
}}
disabled={revertRun.isPending}
className="text-xs text-amber-400 hover:text-amber-300 disabled:opacity-50"
>
Revert
</button>
)}
</div>
))}
</div>
</div>
)}
{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>
{rule.manual_only && (
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-900/40 text-amber-300 border border-amber-800/60">
Quick action
</span>
)}
<span className="text-xs text-zinc-500">priority: {rule.priority}</span>
</div>
<p className="text-xs text-zinc-400">
{rule.manual_only
? "(fired by hand on selected transactions)"
: conds.length > 0
? conds.map((c) => humanCondition(c, tagNames)).join(" AND ")
: "(matches all)"}
</p>
<p className="text-xs text-zinc-500 mt-1">{humanAction(acts, tagNames, participantNames)}</p>
</div>
<div className="flex items-center gap-3 shrink-0">
{/* Preview is safe for every rule, including quick actions:
it writes nothing, and the apply step takes the ids you
tick rather than re-running the conditions. */}
<button
onClick={() => setPreview({ id: rule.id, name: rule.name })}
className="text-xs text-indigo-400 hover:text-indigo-300"
>
Preview
</button>
{/* No blind Apply for quick actions: their conditions are
empty, so a bulk apply would hit every transaction. */}
{!rule.manual_only && (
<button
onClick={() => handleApply(rule.id)}
disabled={applyRules.isPending}
className="text-xs text-emerald-400 hover:text-emerald-300 disabled:opacity-50"
>
Apply
</button>
)}
<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={() => openEditForm({ id: rule.id, name: rule.name, conditions: conds as Condition[], actions: acts, priority: rule.priority, manual_only: rule.manual_only })}
className="text-zinc-400 hover:text-white text-sm"
>
Edit
</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>
)}
{preview && (
<RulePreviewModal
ruleId={preview.id}
ruleName={preview.name}
onClose={() => setPreview(null)}
/>
)}
</div>
);
}