526 lines
22 KiB
TypeScript
526 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { AppShell } from "../../components/app-shell";
|
|
import { apiFetch } from "@/lib/api";
|
|
|
|
type RuleRow = {
|
|
id: string;
|
|
name: string;
|
|
priority: number;
|
|
isActive: boolean;
|
|
conditions: Record<string, unknown>;
|
|
actions: Record<string, unknown>;
|
|
};
|
|
|
|
type Suggestion = {
|
|
id: string;
|
|
name: string;
|
|
conditions: Record<string, unknown>;
|
|
actions: Record<string, unknown>;
|
|
confidence: number;
|
|
reason?: string;
|
|
matchCount?: number;
|
|
type?: string;
|
|
};
|
|
|
|
export default function RulesPage() {
|
|
const [rules, setRules] = useState<RuleRow[]>([]);
|
|
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
|
const [status, setStatus] = useState("Loading rules...");
|
|
const [showNew, setShowNew] = useState(false);
|
|
const [builderMode, setBuilderMode] = useState<"simple" | "advanced">("simple");
|
|
const [advancedConditions, setAdvancedConditions] = useState(`{
|
|
"all": [
|
|
{ "field": "description", "operator": "contains", "value": "coffee" },
|
|
{
|
|
"any": [
|
|
{ "field": "amount", "operator": ">", "value": 5 },
|
|
{ "field": "source", "operator": "equals", "value": "csv" }
|
|
]
|
|
}
|
|
]
|
|
}`);
|
|
const [form, setForm] = useState({
|
|
name: "",
|
|
priority: "",
|
|
textContains: "",
|
|
textNotContains: "",
|
|
textRegex: "",
|
|
amountGreater: "",
|
|
amountLess: "",
|
|
amountEquals: "",
|
|
sourceEquals: "",
|
|
categoryEquals: "",
|
|
dateAfter: "",
|
|
dateBefore: "",
|
|
setCategory: "",
|
|
clearCategory: false,
|
|
setNote: "",
|
|
appendNote: "",
|
|
clearNote: false,
|
|
hiddenAction: "none",
|
|
isActive: true
|
|
});
|
|
|
|
const load = async () => {
|
|
try {
|
|
const [rulesPayload, suggestionsPayload] = await Promise.all([
|
|
apiFetch<RuleRow[]>("/api/rules"),
|
|
apiFetch<Suggestion[]>("/api/rules/suggestions")
|
|
]);
|
|
if (rulesPayload.error) {
|
|
setStatus(rulesPayload.error?.message ?? "Unable to load rules.");
|
|
return;
|
|
}
|
|
setRules(rulesPayload.data);
|
|
setSuggestions(suggestionsPayload.data ?? []);
|
|
setStatus(rulesPayload.data.length ? "" : "No rules yet.");
|
|
} catch {
|
|
setStatus("Unable to load rules.");
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, []);
|
|
|
|
const onCreate = async () => {
|
|
let conditions: Record<string, unknown>;
|
|
if (builderMode === "advanced") {
|
|
try {
|
|
conditions = JSON.parse(advancedConditions) as Record<string, unknown>;
|
|
} catch {
|
|
setStatus("Advanced rule DSL must be valid JSON.");
|
|
return;
|
|
}
|
|
} else {
|
|
conditions = {
|
|
textContains: form.textContains || undefined,
|
|
textNotContains: form.textNotContains || undefined,
|
|
textRegex: form.textRegex || undefined,
|
|
amountGreaterThan: form.amountGreater ? Number(form.amountGreater) : undefined,
|
|
amountLessThan: form.amountLess ? Number(form.amountLess) : undefined,
|
|
amountEquals: form.amountEquals ? Number(form.amountEquals) : undefined,
|
|
sourceEquals: form.sourceEquals || undefined,
|
|
categoryEquals: form.categoryEquals || undefined,
|
|
dateAfter: form.dateAfter || undefined,
|
|
dateBefore: form.dateBefore || undefined
|
|
};
|
|
}
|
|
|
|
const payload = {
|
|
name: form.name || "Untitled rule",
|
|
priority: form.priority ? Number(form.priority) : undefined,
|
|
isActive: form.isActive,
|
|
conditions,
|
|
actions: {
|
|
setCategory: form.setCategory || undefined,
|
|
clearCategory: form.clearCategory || undefined,
|
|
setNote: form.setNote || undefined,
|
|
appendNote: form.appendNote || undefined,
|
|
clearNote: form.clearNote || undefined,
|
|
setHidden: form.hiddenAction === "hide" ? true : form.hiddenAction === "unhide" ? false : undefined
|
|
}
|
|
};
|
|
try {
|
|
const data = await apiFetch<RuleRow>("/api/rules", {
|
|
method: "POST",
|
|
body: JSON.stringify(payload)
|
|
});
|
|
if (data.error) {
|
|
setStatus(data.error?.message ?? "Unable to create rule.");
|
|
return;
|
|
}
|
|
setShowNew(false);
|
|
setForm({
|
|
name: "",
|
|
priority: "",
|
|
textContains: "",
|
|
textNotContains: "",
|
|
textRegex: "",
|
|
amountGreater: "",
|
|
amountLess: "",
|
|
amountEquals: "",
|
|
sourceEquals: "",
|
|
categoryEquals: "",
|
|
dateAfter: "",
|
|
dateBefore: "",
|
|
setCategory: "",
|
|
clearCategory: false,
|
|
setNote: "",
|
|
appendNote: "",
|
|
clearNote: false,
|
|
hiddenAction: "none",
|
|
isActive: true
|
|
});
|
|
setRules((prev) => [data.data, ...prev]);
|
|
setStatus("");
|
|
} catch {
|
|
setStatus("Unable to create rule.");
|
|
}
|
|
};
|
|
|
|
const onExecute = async (ruleId: string) => {
|
|
setStatus("Executing rule...");
|
|
const result = await apiFetch<{ applied: number; status: string }>(`/api/rules/${ruleId}/execute`, {
|
|
method: "POST",
|
|
});
|
|
if (result.error) {
|
|
setStatus(result.error.message ?? "Unable to execute rule.");
|
|
return;
|
|
}
|
|
setStatus(`Rule ${result.data.status}. Applied to ${result.data.applied ?? 0} transaction(s).`);
|
|
};
|
|
|
|
const onAcceptSuggestion = async (item: Suggestion) => {
|
|
setStatus("Saving suggested rule...");
|
|
const result = await apiFetch<RuleRow>("/api/rules", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
name: item.name,
|
|
conditions: item.conditions,
|
|
actions: item.actions,
|
|
isActive: true,
|
|
}),
|
|
});
|
|
if (result.error) {
|
|
setStatus(result.error.message ?? "Unable to save suggested rule.");
|
|
return;
|
|
}
|
|
setRules((prev) => [result.data, ...prev]);
|
|
setSuggestions((prev) => prev.filter((suggestion) => suggestion.id !== item.id));
|
|
setStatus("Suggested rule saved.");
|
|
};
|
|
|
|
return (
|
|
<AppShell title="Rules" subtitle="Priority-ordered rules with full transparency.">
|
|
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex flex-wrap items-center gap-2 text-xs font-medium text-muted-foreground">
|
|
<span className="px-4 py-2 rounded-full bg-secondary/50 border border-border">Active rules</span>
|
|
<span className="px-4 py-2 rounded-full bg-secondary/50 border border-border">Priority ordered</span>
|
|
<span className="px-4 py-2 rounded-full bg-secondary/50 border border-border">Auto applied</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowNew((prev) => !prev)}
|
|
className="rounded-full bg-primary px-4 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
>
|
|
{showNew ? "Close" : "New rule"}
|
|
</button>
|
|
</div>
|
|
<div className="grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
|
|
<div className="glass-panel p-6 rounded-2xl shadow-sm">
|
|
{showNew ? (
|
|
<div className="mb-6 rounded-xl border border-border bg-background/50 p-4">
|
|
<p className="text-sm font-bold text-foreground">Create a rule</p>
|
|
<div className="mt-4 inline-flex rounded-xl border border-border bg-background p-1 text-xs font-semibold">
|
|
<button
|
|
type="button"
|
|
onClick={() => setBuilderMode("simple")}
|
|
className={`rounded-lg px-3 py-1.5 ${builderMode === "simple" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"}`}
|
|
>
|
|
Simple
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setBuilderMode("advanced")}
|
|
className={`rounded-lg px-3 py-1.5 ${builderMode === "advanced" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"}`}
|
|
>
|
|
Advanced DSL
|
|
</button>
|
|
</div>
|
|
{builderMode === "advanced" ? (
|
|
<textarea
|
|
value={advancedConditions}
|
|
onChange={(event) => setAdvancedConditions(event.target.value)}
|
|
spellCheck={false}
|
|
className="mt-4 min-h-56 w-full rounded-xl border border-border bg-background px-3 py-2 font-mono text-xs text-foreground focus:border-primary focus:ring-primary"
|
|
/>
|
|
) : null}
|
|
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
|
<input
|
|
type="text"
|
|
value={form.name}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
|
placeholder="Rule name"
|
|
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
|
/>
|
|
<input
|
|
type="number"
|
|
value={form.priority}
|
|
onChange={(event) =>
|
|
setForm((prev) => ({ ...prev, priority: event.target.value }))
|
|
}
|
|
placeholder="Priority (optional)"
|
|
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
|
/>
|
|
{builderMode === "simple" ? (
|
|
<>
|
|
<input
|
|
type="text"
|
|
value={form.textContains}
|
|
onChange={(event) =>
|
|
setForm((prev) => ({ ...prev, textContains: event.target.value }))
|
|
}
|
|
placeholder="Description contains"
|
|
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={form.textNotContains}
|
|
onChange={(event) =>
|
|
setForm((prev) => ({ ...prev, textNotContains: event.target.value }))
|
|
}
|
|
placeholder="Description does not contain"
|
|
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={form.textRegex}
|
|
onChange={(event) =>
|
|
setForm((prev) => ({ ...prev, textRegex: event.target.value }))
|
|
}
|
|
placeholder="Description regex"
|
|
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
|
/>
|
|
</>
|
|
) : null}
|
|
<input
|
|
type="text"
|
|
value={form.setCategory}
|
|
onChange={(event) =>
|
|
setForm((prev) => ({ ...prev, setCategory: event.target.value }))
|
|
}
|
|
placeholder="Set category"
|
|
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={form.setNote}
|
|
onChange={(event) =>
|
|
setForm((prev) => ({ ...prev, setNote: event.target.value }))
|
|
}
|
|
placeholder="Set note"
|
|
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={form.appendNote}
|
|
onChange={(event) =>
|
|
setForm((prev) => ({ ...prev, appendNote: event.target.value }))
|
|
}
|
|
placeholder="Append note"
|
|
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
|
/>
|
|
{builderMode === "simple" ? (
|
|
<>
|
|
<input
|
|
type="number"
|
|
value={form.amountGreater}
|
|
onChange={(event) =>
|
|
setForm((prev) => ({ ...prev, amountGreater: event.target.value }))
|
|
}
|
|
placeholder="Amount greater than"
|
|
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
|
/>
|
|
<input
|
|
type="number"
|
|
value={form.amountLess}
|
|
onChange={(event) =>
|
|
setForm((prev) => ({ ...prev, amountLess: event.target.value }))
|
|
}
|
|
placeholder="Amount less than"
|
|
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
|
/>
|
|
<input
|
|
type="number"
|
|
value={form.amountEquals}
|
|
onChange={(event) =>
|
|
setForm((prev) => ({ ...prev, amountEquals: event.target.value }))
|
|
}
|
|
placeholder="Amount equals"
|
|
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={form.sourceEquals}
|
|
onChange={(event) =>
|
|
setForm((prev) => ({ ...prev, sourceEquals: event.target.value }))
|
|
}
|
|
placeholder="Source equals, e.g. plaid"
|
|
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={form.categoryEquals}
|
|
onChange={(event) =>
|
|
setForm((prev) => ({ ...prev, categoryEquals: event.target.value }))
|
|
}
|
|
placeholder="Current category equals"
|
|
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
|
/>
|
|
<input
|
|
type="date"
|
|
value={form.dateAfter}
|
|
onChange={(event) =>
|
|
setForm((prev) => ({ ...prev, dateAfter: event.target.value }))
|
|
}
|
|
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
|
/>
|
|
<input
|
|
type="date"
|
|
value={form.dateBefore}
|
|
onChange={(event) =>
|
|
setForm((prev) => ({ ...prev, dateBefore: event.target.value }))
|
|
}
|
|
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
|
/>
|
|
</>
|
|
) : null}
|
|
<select
|
|
value={form.hiddenAction}
|
|
onChange={(event) =>
|
|
setForm((prev) => ({ ...prev, hiddenAction: event.target.value }))
|
|
}
|
|
className="rounded-xl border border-border bg-background px-3 py-2 text-xs text-foreground focus:border-primary focus:ring-primary"
|
|
>
|
|
<option value="none">Do not change hidden state</option>
|
|
<option value="hide">Hide matching transactions</option>
|
|
<option value="unhide">Unhide matching transactions</option>
|
|
</select>
|
|
</div>
|
|
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.clearCategory}
|
|
onChange={(event) =>
|
|
setForm((prev) => ({ ...prev, clearCategory: event.target.checked }))
|
|
}
|
|
className="rounded border-border text-primary focus:ring-primary"
|
|
/>
|
|
Clear category
|
|
</label>
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.clearNote}
|
|
onChange={(event) =>
|
|
setForm((prev) => ({ ...prev, clearNote: event.target.checked }))
|
|
}
|
|
className="rounded border-border text-primary focus:ring-primary"
|
|
/>
|
|
Clear note
|
|
</label>
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={form.isActive}
|
|
onChange={(event) =>
|
|
setForm((prev) => ({ ...prev, isActive: event.target.checked }))
|
|
}
|
|
className="rounded border-border text-primary focus:ring-primary"
|
|
/>
|
|
Rule is active
|
|
</label>
|
|
</div>
|
|
<div className="mt-4 flex flex-wrap gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onCreate}
|
|
className="rounded-full bg-primary px-4 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
>
|
|
Save rule
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowNew(false)}
|
|
className="rounded-full border border-border bg-background px-4 py-2 text-xs font-semibold text-foreground hover:bg-secondary transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{status ? <p className="text-sm text-muted-foreground">{status}</p> : null}
|
|
{rules.length ? (
|
|
<div className="mt-4 space-y-4">
|
|
{rules.map((rule) => (
|
|
<div
|
|
key={rule.id}
|
|
className="rounded-xl border border-border bg-background/50 p-4"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="font-bold text-foreground">{rule.name}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Priority {rule.priority} - {rule.isActive ? "Active" : "Paused"}
|
|
</p>
|
|
</div>
|
|
<span className={`rounded-full border border-border px-2 py-1 text-xs font-medium ${rule.isActive ? "bg-primary/10 text-primary" : "bg-secondary text-muted-foreground"}`}>
|
|
{rule.isActive ? "Live" : "Paused"}
|
|
</span>
|
|
</div>
|
|
<div className="mt-3 text-xs text-muted-foreground">
|
|
<span className="font-semibold text-foreground">Conditions:</span> {JSON.stringify(rule.conditions)}
|
|
</div>
|
|
<div className="mt-1 text-xs text-muted-foreground">
|
|
<span className="font-semibold text-foreground">Actions:</span> {JSON.stringify(rule.actions)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => onExecute(rule.id)}
|
|
className="mt-3 rounded-full border border-border bg-background px-3 py-1.5 text-xs font-semibold text-foreground hover:bg-secondary transition-colors"
|
|
>
|
|
Run rule
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="glass-panel p-6 rounded-2xl shadow-sm">
|
|
<p className="text-xs uppercase tracking-[0.3em] text-muted-foreground font-bold">AI Suggestions</p>
|
|
<h2 className="mt-3 text-xl font-bold text-foreground">Rule suggestions</h2>
|
|
<div className="mt-4 space-y-4">
|
|
{suggestions.length ? (
|
|
suggestions.map((item) => (
|
|
<div
|
|
key={item.id}
|
|
className="rounded-xl border border-border bg-background/50 p-4"
|
|
>
|
|
<p className="font-bold text-foreground">{item.name}</p>
|
|
{item.reason ? (
|
|
<p className="mt-2 text-xs text-muted-foreground">{item.reason}</p>
|
|
) : null}
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
<span className="font-semibold text-foreground">Conditions:</span> {JSON.stringify(item.conditions)}
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
<span className="font-semibold text-foreground">Actions:</span> {JSON.stringify(item.actions)}
|
|
</p>
|
|
<p className="mt-2 text-xs font-medium text-primary">
|
|
Confidence: {(item.confidence * 100).toFixed(0)}%
|
|
{item.matchCount ? ` - ${item.matchCount} matches` : ""}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => onAcceptSuggestion(item)}
|
|
className="mt-3 rounded-full bg-primary px-3 py-1.5 text-xs font-bold text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
>
|
|
Accept suggestion
|
|
</button>
|
|
</div>
|
|
))
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">No suggestions yet.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AppShell>
|
|
);
|
|
}
|