2026-02-24 21:47:18 +00:00

300 lines
12 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { AppShell } from "../../components/app-shell";
type ApiResponse<T> = {
data: T;
meta: { timestamp: string; version: "v1" };
error: null | { message: string; code?: string };
};
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;
};
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 [form, setForm] = useState({
name: "",
priority: "",
textContains: "",
amountGreater: "",
amountLess: "",
setCategory: "",
setHidden: false,
isActive: true
});
const load = async () => {
const userId = localStorage.getItem("ledgerone_user_id");
const query = userId ? `?user_id=${encodeURIComponent(userId)}` : "";
try {
const [rulesRes, suggestionsRes] = await Promise.all([
fetch(`/api/rules${query}`),
fetch(`/api/rules/suggestions${query}`)
]);
const rulesPayload = (await rulesRes.json()) as ApiResponse<RuleRow[]>;
const suggestionsPayload = (await suggestionsRes.json()) as ApiResponse<Suggestion[]>;
if (!rulesRes.ok || 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 () => {
const userId = localStorage.getItem("ledgerone_user_id");
if (!userId) {
setStatus("Missing user id.");
return;
}
const payload = {
userId,
name: form.name || "Untitled rule",
priority: form.priority ? Number(form.priority) : undefined,
isActive: form.isActive,
conditions: {
textContains: form.textContains || undefined,
amountGreaterThan: form.amountGreater ? Number(form.amountGreater) : undefined,
amountLessThan: form.amountLess ? Number(form.amountLess) : undefined
},
actions: {
setCategory: form.setCategory || undefined,
setHidden: form.setHidden
}
};
try {
const res = await fetch("/api/rules", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = (await res.json()) as ApiResponse<RuleRow>;
if (!res.ok || data.error) {
setStatus(data.error?.message ?? "Unable to create rule.");
return;
}
setShowNew(false);
setForm({
name: "",
priority: "",
textContains: "",
amountGreater: "",
amountLess: "",
setCategory: "",
setHidden: false,
isActive: true
});
setRules((prev) => [data.data, ...prev]);
setStatus("");
} catch {
setStatus("Unable to create rule.");
}
};
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 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"
/>
<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.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="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"
/>
</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.setHidden}
onChange={(event) =>
setForm((prev) => ({ ...prev, setHidden: event.target.checked }))
}
className="rounded border-border text-primary focus:ring-primary"
/>
Hide matching transactions
</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>
</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">Pattern-based rule ideas</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>
<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)}%
</p>
</div>
))
) : (
<p className="text-sm text-muted-foreground">No suggestions yet.</p>
)}
</div>
</div>
</div>
</AppShell>
);
}