Files
finance-app/src/app/rules/page.tsx
T
siddharthd 0a1f6b48a2 feat(ui): mobile-responsive sidebar + rules improvements
- Sidebar: hidden on mobile, opens as slide-out drawer with hamburger
  toggle; auto-closes on navigation; desktop layout unchanged
- Layout: responsive padding accounting for mobile header bar
- Rules: add tag as a condition field (has/not-has tag)
- Rules: apply a single rule via per-rule Apply button
- Rules: splits-from defaults to 2026-01-09
2026-03-21 08:33:08 +11:00

529 lines
23 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";
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 [name, setName] = useState("");
const [conditions, setConditions] = useState<Condition[]>([]);
const [actions, setActions] = useState<Actions>(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<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, 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-xl font-semibold">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>
<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>
<span className="text-xs text-zinc-500">priority: {rule.priority}</span>
</div>
<p className="text-xs text-zinc-400">
{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">
<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 })}
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>
)}
</div>
);
}