0a1f6b48a2
- 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
529 lines
23 KiB
TypeScript
529 lines
23 KiB
TypeScript
"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>
|
||
);
|
||
}
|