1 Commits

Author SHA1 Message Date
siddharthd d455738732 feat(finance): Phase 6 — Budget & Analytics
Add monthly budgets per category with spend-vs-budget dashboard and 6-month trend table.
Includes upsert budget API, monthly analytics endpoint, inline budget editing, and route auth fixes.
2026-03-08 16:57:33 +11:00
12 changed files with 597 additions and 600 deletions
@@ -1,12 +0,0 @@
CREATE TABLE IF NOT EXISTS rules (
id SERIAL PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES participants(id),
name TEXT NOT NULL,
conditions JSONB NOT NULL DEFAULT '[]',
actions JSONB NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT true,
priority INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_rules_owner ON rules(owner_id);
@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS budgets (
id SERIAL PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES participants(id),
category TEXT NOT NULL,
month DATE NOT NULL,
amount_limit NUMERIC(10,2) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(owner_id, category, month)
);
CREATE INDEX IF NOT EXISTS idx_budgets_owner_month ON budgets(owner_id, month);
-12
View File
@@ -66,18 +66,6 @@ model transaction_tags {
@@id([transaction_id, tag_id]) @@id([transaction_id, tag_id])
} }
model rules {
id Int @id @default(autoincrement())
owner_id Int
name String
conditions Json @default("[]")
actions Json @default("{}")
enabled Boolean @default(true)
priority Int @default(0)
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
}
model budgets { model budgets {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
owner_id Int owner_id Int
+105
View File
@@ -0,0 +1,105 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { searchParams } = new URL(req.url);
const monthCount = Math.min(Math.max(Number(searchParams.get("months") || "6"), 1), 24);
const now = new Date();
const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const startDate = new Date(now.getFullYear(), now.getMonth() - monthCount + 1, 1);
const startStr = startDate.toISOString().slice(0, 10);
const endStr = endDate.toISOString().slice(0, 10);
const spendRows = await queryRaw<{
month: string;
category: string;
total_spent: number;
transaction_count: number;
}>(
`SELECT
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
COALESCE(o.category_override, t.category) as category,
SUM(t.amount)::numeric(12,2) as total_spent,
COUNT(*)::int as transaction_count
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
JOIN statements s ON s.id = t.statement_id
WHERE s.owner_id = $1
AND t.transaction_type = 'debit'
AND t.transaction_date >= $2
AND t.transaction_date < $3
GROUP BY 1, 2
ORDER BY 1 DESC, total_spent DESC`,
[user.id, startStr, endStr]
);
const budgetRows = await queryRaw<{ month: string; category: string; amount_limit: number }>(
`SELECT TO_CHAR(month, 'YYYY-MM') as month, category, amount_limit::numeric
FROM budgets
WHERE owner_id = $1 AND month >= $2::date AND month < $3::date`,
[user.id, startStr, endStr]
);
// Build month list (most recent first)
const months: string[] = [];
for (let i = monthCount - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`);
}
months.reverse();
const spendMap = new Map<string, number>();
const countMap = new Map<string, number>();
const budgetMap = new Map<string, number>();
for (const r of spendRows) {
spendMap.set(`${r.category}:${r.month}`, Number(r.total_spent));
countMap.set(`${r.category}:${r.month}`, r.transaction_count);
}
for (const r of budgetRows) {
budgetMap.set(`${r.category}:${r.month}`, Number(r.amount_limit));
}
const allCategories = new Set<string>();
for (const r of spendRows) allCategories.add(r.category);
for (const r of budgetRows) allCategories.add(r.category);
const rows = Array.from(allCategories)
.sort()
.map((cat) => {
const spent: Record<string, number> = {};
const budget: Record<string, number> = {};
const txCount: Record<string, number> = {};
for (const m of months) {
const s = spendMap.get(`${cat}:${m}`);
const b = budgetMap.get(`${cat}:${m}`);
const c = countMap.get(`${cat}:${m}`);
if (s !== undefined) spent[m] = s;
if (b !== undefined) budget[m] = b;
if (c !== undefined) txCount[m] = c;
}
return { category: cat, spent, budget, txCount };
});
const totals: Record<string, { spent: number; budget: number }> = {};
for (const m of months) {
let s = 0;
let b = 0;
for (const row of rows) {
s += row.spent[m] || 0;
b += row.budget[m] || 0;
}
totals[m] = {
spent: Math.round(s * 100) / 100,
budget: Math.round(b * 100) / 100,
};
}
return NextResponse.json({ months, rows, totals });
}
+18
View File
@@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { id } = await params;
const existing = await queryRaw<{ id: number }>(
`SELECT id FROM budgets WHERE id = $1 AND owner_id = $2`,
[Number(id), user.id]
);
if (!existing.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
await queryRaw(`DELETE FROM budgets WHERE id = $1`, [Number(id)]);
return NextResponse.json({ ok: true });
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { searchParams } = new URL(req.url);
const month = searchParams.get("month");
let monthDate: string;
if (month) {
monthDate = month.length === 7 ? `${month}-01` : month;
} else {
const now = new Date();
monthDate = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
}
const rows = await queryRaw<{ id: number; category: string; month: string; amount_limit: number }>(
`SELECT id, category, month::text, amount_limit::numeric FROM budgets WHERE owner_id = $1 AND month = $2::date`,
[user.id, monthDate]
);
return NextResponse.json(rows);
}
export async function POST(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { category, month, amount_limit } = await req.json();
if (!category || !month || amount_limit === undefined) {
return NextResponse.json({ error: "category, month, and amount_limit required" }, { status: 400 });
}
const monthDate = month.length === 7 ? `${month}-01` : month;
const rows = await queryRaw<{ id: number; category: string; month: string; amount_limit: number }>(
`INSERT INTO budgets (owner_id, category, month, amount_limit)
VALUES ($1, $2, $3::date, $4)
ON CONFLICT (owner_id, category, month) DO UPDATE SET amount_limit = $4, updated_at = NOW()
RETURNING id, category, month::text, amount_limit::numeric`,
[user.id, category, monthDate, amount_limit]
);
return NextResponse.json(rows[0], { status: 201 });
}
-44
View File
@@ -1,44 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw, prisma } from "@/lib/db";
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { id } = await params;
const body = await req.json();
const existing = await queryRaw<{ id: number }>(
`SELECT id FROM rules WHERE id = $1 AND owner_id = $2`,
[Number(id), user.id]
);
if (!existing.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
const updated = await prisma.rules.update({
where: { id: Number(id) },
data: {
...(body.name !== undefined && { name: body.name }),
...(body.conditions !== undefined && { conditions: body.conditions }),
...(body.actions !== undefined && { actions: body.actions }),
...(body.enabled !== undefined && { enabled: body.enabled }),
...(body.priority !== undefined && { priority: body.priority }),
},
});
return NextResponse.json(updated);
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { id } = await params;
const existing = await queryRaw<{ id: number }>(
`SELECT id FROM rules WHERE id = $1 AND owner_id = $2`,
[Number(id), user.id]
);
if (!existing.length) return NextResponse.json({ error: "Not found" }, { status: 404 });
await prisma.rules.delete({ where: { id: Number(id) } });
return NextResponse.json({ ok: true });
}
-115
View File
@@ -1,115 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
import { getTransactions } from "@/lib/queries";
interface Condition {
field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount";
operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals";
value: string;
}
interface Actions {
set_category?: string;
add_tag_ids?: number[];
set_merchant?: string;
}
interface TxFields {
effective_category: string;
effective_merchant: string;
description: string;
bank_name: string;
amount: number;
}
function evaluateCondition(cond: Condition, tx: TxFields): boolean {
if (cond.field === "amount") {
const numVal = Number(tx.amount);
const numCond = Number(cond.value);
switch (cond.operator) {
case "equals": return numVal === numCond;
case "not_equals": return numVal !== numCond;
case "gt": return numVal > numCond;
case "lt": return numVal < numCond;
default: return false;
}
}
let fieldVal: string;
switch (cond.field) {
case "merchant_normalized": fieldVal = tx.effective_merchant || ""; break;
case "description": fieldVal = tx.description || ""; break;
case "category": fieldVal = tx.effective_category || ""; break;
case "bank_name": fieldVal = tx.bank_name || ""; break;
default: return false;
}
const strVal = fieldVal.toLowerCase();
const strCond = cond.value.toLowerCase();
switch (cond.operator) {
case "contains": return strVal.includes(strCond);
case "equals": return strVal === strCond;
case "starts_with": return strVal.startsWith(strCond);
case "not_equals": return strVal !== strCond;
default: return false;
}
}
export async function POST(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const rules = await queryRaw<{ id: number; conditions: unknown; actions: unknown }>(
`SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND enabled = true ORDER BY priority DESC`,
[user.id]
);
if (!rules.length) return NextResponse.json({ matched: 0, transactions_affected: 0 });
const { data: transactions } = await getTransactions(user.id, { limit: 100000, offset: 0 });
let matched = 0;
const affectedIds = new Set<number>();
for (const rule of rules) {
const conditions = (typeof rule.conditions === "string"
? JSON.parse(rule.conditions)
: rule.conditions) as Condition[];
const actions = (typeof rule.actions === "string"
? JSON.parse(rule.actions)
: rule.actions) as Actions;
for (const tx of transactions) {
const allMatch =
conditions.length === 0 || conditions.every((c) => evaluateCondition(c, tx));
if (!allMatch) continue;
matched++;
affectedIds.add(tx.id);
if (actions.set_category || actions.set_merchant) {
await queryRaw(
`INSERT INTO transaction_overrides (transaction_id, category_override, merchant_normalized)
VALUES ($1, $2, $3)
ON CONFLICT (transaction_id) DO UPDATE SET
category_override = COALESCE($2, transaction_overrides.category_override),
merchant_normalized = COALESCE($3, transaction_overrides.merchant_normalized),
updated_at = NOW()`,
[tx.id, actions.set_category || null, actions.set_merchant || null]
);
}
if (actions.add_tag_ids?.length) {
for (const tagId of actions.add_tag_ids) {
await queryRaw(
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[tx.id, tagId]
);
}
}
}
}
return NextResponse.json({ matched, transactions_affected: affectedIds.size });
}
-43
View File
@@ -1,43 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw, prisma } from "@/lib/db";
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const rows = await queryRaw<{
id: number;
name: string;
conditions: unknown;
actions: unknown;
enabled: boolean;
priority: number;
created_at: string;
}>(
`SELECT id, name, conditions, actions, enabled, priority, created_at
FROM rules WHERE owner_id = $1 ORDER BY priority DESC, id ASC`,
[user.id]
);
return NextResponse.json(rows);
}
export async function POST(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { name, conditions, actions, enabled = true, priority = 0 } = await req.json();
if (!name) return NextResponse.json({ error: "name required" }, { status: 400 });
const rule = await prisma.rules.create({
data: {
owner_id: user.id,
name,
conditions: conditions ?? [],
actions: actions ?? {},
enabled,
priority,
},
});
return NextResponse.json(rule, { status: 201 });
}
+376 -2
View File
@@ -1,8 +1,382 @@
"use client";
import { useState } from "react";
import {
useBudgets,
useUpsertBudget,
useDeleteBudget,
useMonthlyAnalytics,
} from "@/lib/hooks";
import { CATEGORIES, formatCategory } from "@/lib/categories";
function formatMonth(m: string): string {
const [year, month] = m.split("-");
const date = new Date(Number(year), Number(month) - 1, 1);
return date.toLocaleString("default", { month: "long", year: "numeric" });
}
function prevMonth(m: string): string {
const [year, month] = m.split("-").map(Number);
const d = new Date(year, month - 2, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
function nextMonth(m: string): string {
const [year, month] = m.split("-").map(Number);
const d = new Date(year, month, 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
}
function currentMonthStr(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
function barColor(pct: number): string {
if (pct > 100) return "bg-red-500";
if (pct > 80) return "bg-yellow-400";
return "bg-emerald-500";
}
export default function BudgetPage() { export default function BudgetPage() {
const [selectedMonth, setSelectedMonth] = useState(currentMonthStr);
const [editingCategory, setEditingCategory] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const { data: budgets = [], isLoading: budgetsLoading } = useBudgets(selectedMonth);
const { data: analytics, isLoading: analyticsLoading } = useMonthlyAnalytics(6);
const upsertBudget = useUpsertBudget();
const deleteBudget = useDeleteBudget();
const budgetMap = new Map(budgets.map((b) => [b.category, b]));
// Categories with spend this month or a budget set
const currentMonthRows = analytics?.rows.filter((r) => r.spent[selectedMonth] !== undefined) || [];
const allCategories = new Set<string>([
...currentMonthRows.map((r) => r.category),
...budgets.map((b) => b.category),
]);
const tableRows = Array.from(allCategories)
.sort()
.map((cat) => {
const analyticsRow = analytics?.rows.find((r) => r.category === cat);
const spent = analyticsRow?.spent[selectedMonth] || 0;
const txCount = analyticsRow?.txCount[selectedMonth] || 0;
const budget = budgetMap.get(cat);
const limit = budget ? Number(budget.amount_limit) : null;
const remaining = limit !== null ? limit - spent : null;
const pct = limit !== null && limit > 0 ? (spent / limit) * 100 : null;
return { cat, spent, txCount, budget, limit, remaining, pct };
});
const totalBudgeted = budgets.reduce((s, b) => s + Number(b.amount_limit), 0);
const totalSpent = tableRows.reduce((s, r) => s + r.spent, 0);
const overBudgetCount = tableRows.filter((r) => r.pct !== null && r.pct > 100).length;
async function handleBudgetSave(cat: string) {
const val = parseFloat(editValue);
if (isNaN(val) || val < 0) return;
await upsertBudget.mutateAsync({ category: cat, month: selectedMonth, amount_limit: val });
setEditingCategory(null);
setEditValue("");
}
return ( return (
<div className="space-y-6">
{/* Month selector */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">Budget</h2>
<div className="flex items-center gap-3">
<button
onClick={() => setSelectedMonth(prevMonth(selectedMonth))}
className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none"
>
</button>
<span className="text-sm font-medium min-w-36 text-center">{formatMonth(selectedMonth)}</span>
<button
onClick={() => setSelectedMonth(nextMonth(selectedMonth))}
className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none"
>
</button>
</div>
</div>
{/* Summary cards */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<p className="text-xs text-zinc-500 mb-1">Total Budgeted</p>
<p className="text-2xl font-semibold">${totalBudgeted.toFixed(2)}</p>
</div>
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<p className="text-xs text-zinc-500 mb-1">Total Spent</p>
<p
className={`text-2xl font-semibold ${
totalBudgeted > 0 && totalSpent > totalBudgeted ? "text-red-400" : ""
}`}
>
${totalSpent.toFixed(2)}
</p>
</div>
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
<p className="text-xs text-zinc-500 mb-1">Over Budget</p>
<p
className={`text-2xl font-semibold ${
overBudgetCount > 0 ? "text-red-400" : "text-emerald-400"
}`}
>
{overBudgetCount} {overBudgetCount === 1 ? "category" : "categories"}
</p>
</div>
</div>
{/* Budget table for current month */}
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left px-4 py-3 text-xs text-zinc-500 font-medium">Category</th>
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium">Budget</th>
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium">Spent</th>
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium">Remaining</th>
<th className="px-4 py-3 text-xs text-zinc-500 font-medium w-36">Progress</th>
<th className="text-right px-4 py-3 text-xs text-zinc-500 font-medium"># Txns</th>
</tr>
</thead>
<tbody>
{budgetsLoading || analyticsLoading ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-zinc-500">
Loading...
</td>
</tr>
) : tableRows.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-zinc-500">
No spending data for this month. Set a budget for any category below.
</td>
</tr>
) : (
tableRows.map(({ cat, spent, txCount, budget, limit, remaining, pct }) => (
<tr key={cat} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
<td className="px-4 py-3 font-medium">{formatCategory(cat)}</td>
<td className="px-4 py-3 text-right">
{editingCategory === cat ? (
<div className="flex items-center justify-end gap-1">
<input
autoFocus
type="number"
min="0"
step="0.01"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleBudgetSave(cat);
if (e.key === "Escape") {
setEditingCategory(null);
setEditValue("");
}
}}
className="w-24 bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-right text-sm"
placeholder="0.00"
/>
<button
onClick={() => handleBudgetSave(cat)}
className="text-xs text-emerald-400 hover:text-emerald-300 px-1"
>
</button>
<button
onClick={() => {
setEditingCategory(null);
setEditValue("");
}}
className="text-xs text-zinc-500 hover:text-zinc-300 px-1"
>
</button>
</div>
) : (
<div className="flex items-center justify-end gap-1">
<button
onClick={() => {
setEditingCategory(cat);
setEditValue(limit?.toString() || "");
}}
className="text-zinc-300 hover:text-white underline-offset-2 hover:underline"
>
{limit !== null ? (
`$${limit.toFixed(2)}`
) : (
<span className="text-zinc-600 italic">Set...</span>
)}
</button>
{budget && (
<button
onClick={() => deleteBudget.mutate(budget.id)}
className="text-zinc-600 hover:text-red-400 text-xs ml-1"
>
×
</button>
)}
</div>
)}
</td>
<td className="px-4 py-3 text-right">${spent.toFixed(2)}</td>
<td
className={`px-4 py-3 text-right ${
remaining !== null
? remaining < 0
? "text-red-400"
: "text-emerald-400"
: "text-zinc-600"
}`}
>
{remaining !== null ? `$${remaining.toFixed(2)}` : "—"}
</td>
<td className="px-4 py-3">
{pct !== null ? (
<div className="flex items-center gap-2">
<div className="flex-1 bg-zinc-800 rounded-full h-2 overflow-hidden">
<div
className={`h-full rounded-full ${barColor(pct)}`}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
<span className="text-xs text-zinc-400 w-10 text-right">
{pct.toFixed(0)}%
</span>
</div>
) : (
<span className="text-zinc-600 text-xs"></span>
)}
</td>
<td className="px-4 py-3 text-right text-zinc-400">{txCount}</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Add budget for any category not yet shown */}
<div> <div>
<h2 className="text-xl font-semibold mb-4">Budget</h2> <p className="text-xs text-zinc-500 mb-2">Set budget for another category:</p>
<p className="text-zinc-500">Coming soon - monthly budgets and analytics.</p> <div className="flex flex-wrap gap-2">
{CATEGORIES.filter((c) => !allCategories.has(c)).map((cat) => (
<button
key={cat}
onClick={() => {
setEditingCategory(cat);
setEditValue("");
}}
className="px-3 py-1.5 text-xs rounded-lg border border-zinc-700 text-zinc-400 hover:border-zinc-500 hover:text-zinc-200"
>
{formatCategory(cat)}
</button>
))}
</div>
{editingCategory && !allCategories.has(editingCategory) && (
<div className="flex items-center gap-2 mt-3">
<span className="text-sm font-medium">{formatCategory(editingCategory)}</span>
<input
autoFocus
type="number"
min="0"
step="0.01"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleBudgetSave(editingCategory);
if (e.key === "Escape") {
setEditingCategory(null);
setEditValue("");
}
}}
className="w-28 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
placeholder="Budget amount"
/>
<button
onClick={() => handleBudgetSave(editingCategory)}
className="px-3 py-1.5 text-xs bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg"
>
Save
</button>
</div>
)}
</div>
{/* 6-month trend table */}
{analytics && analytics.months.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-zinc-400 mb-3">6-Month Trend</h3>
<div className="overflow-x-auto rounded-xl border border-zinc-700">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900">
<th className="text-left px-3 py-2 text-zinc-500 font-medium sticky left-0 bg-zinc-900 min-w-32">
Category
</th>
{analytics.months.map((m) => (
<th
key={m}
className="text-right px-3 py-2 text-zinc-500 font-medium whitespace-nowrap"
>
{m}
</th>
))}
</tr>
</thead>
<tbody>
{analytics.rows.map((row) => (
<tr key={row.category} className="border-b border-zinc-800/40 hover:bg-zinc-900/30">
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950">
{formatCategory(row.category)}
</td>
{analytics.months.map((m) => {
const spent = row.spent[m];
const budget = row.budget[m];
const overBudget =
spent !== undefined && budget !== undefined && spent > budget;
return (
<td
key={m}
className={`px-3 py-2 text-right tabular-nums ${
spent === undefined
? "text-zinc-700"
: overBudget
? "text-red-300 bg-red-950/40"
: "text-zinc-300"
}`}
>
{spent !== undefined ? `$${Number(spent).toFixed(0)}` : "—"}
</td>
);
})}
</tr>
))}
<tr className="border-t-2 border-zinc-700 font-semibold bg-zinc-900/50">
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Total</td>
{analytics.months.map((m) => {
const t = analytics.totals[m];
const over = t && t.budget > 0 && t.spent > t.budget;
return (
<td
key={m}
className={`px-3 py-2 text-right tabular-nums ${over ? "text-red-300" : ""}`}
>
${(t?.spent || 0).toFixed(0)}
</td>
);
})}
</tr>
</tbody>
</table>
</div>
</div>
)}
</div> </div>
); );
} }
+2 -323
View File
@@ -1,329 +1,8 @@
"use client";
import { useState } from "react";
import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useTags } 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" },
] 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: "<" },
];
type Condition = { field: string; operator: string; value: string };
type Actions = { set_category?: string; add_tag_ids?: number[]; set_merchant?: string };
function humanCondition(c: Condition): string {
const fieldLabel = FIELDS.find((f) => f.value === c.field)?.label || c.field;
const ops = [...TEXT_OPS, ...AMOUNT_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>): 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}`);
}
return parts.length ? "→ " + parts.join(", ") : "(no actions)";
}
export default function RulesPage() { export default function RulesPage() {
const { data: rules = [], isLoading } = useRules();
const { data: tags = [] } = useTags();
const createRule = useCreateRule();
const updateRule = useUpdateRule();
const deleteRule = useDeleteRule();
const applyRules = useApplyRules();
const tagNames = new Map(tags.map((t) => [t.id, t.name]));
const [showForm, setShowForm] = useState(false);
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>({});
const [priority, setPriority] = useState(0);
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));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
await createRule.mutateAsync({ name, conditions, actions, enabled: true, priority });
setName("");
setConditions([]);
setActions({});
setPriority(0);
setShowForm(false);
}
async function handleApply() {
const result = await applyRules.mutateAsync();
setApplyResult(result);
}
return ( 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">
<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={() => setShowForm(!showForm)}
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">New Rule</h3>
<div> <div>
<label className="block text-xs text-zinc-500 mb-1">Rule Name</label> <h2 className="text-xl font-semibold mb-4">Rules</h2>
<input <p className="text-zinc-500">Coming soon - auto-classify transactions with rules.</p>
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 ops = isAmount ? AMOUNT_OPS : TEXT_OPS;
return (
<div key={i} className="flex gap-2 mb-2 items-center">
<select
value={cond.field}
onChange={(e) =>
updateCondition(i, {
field: e.target.value,
operator: e.target.value === "amount" ? "equals" : "contains",
})
}
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>
<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 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={createRule.isPending}
className="px-6 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
>
{createRule.isPending ? "Creating..." : "Create Rule"}
</button>
</div>
</form>
)}
{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(humanCondition).join(" AND ") : "(matches all)"}
</p>
<p className="text-xs text-zinc-500 mt-1">{humanAction(acts, tagNames)}</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<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={() => {
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> </div>
); );
} }
+37 -47
View File
@@ -353,81 +353,71 @@ export function useCreateParticipant() {
}); });
} }
// --- Rules --- // --- Budgets & Analytics ---
export interface RuleRow { export interface BudgetRow {
id: number; id: number;
name: string; category: string;
conditions: { field: string; operator: string; value: string }[]; month: string;
actions: { set_category?: string; add_tag_ids?: number[]; set_merchant?: string }; amount_limit: number;
enabled: boolean;
priority: number;
created_at: string;
} }
export function useRules() { export interface MonthlyAnalyticsRow {
return useQuery<RuleRow[]>({ category: string;
queryKey: ["rules"], spent: Record<string, number>;
budget: Record<string, number>;
txCount: Record<string, number>;
}
export interface MonthlyAnalytics {
months: string[];
rows: MonthlyAnalyticsRow[];
totals: Record<string, { spent: number; budget: number }>;
}
export function useBudgets(month: string) {
return useQuery<BudgetRow[]>({
queryKey: ["budgets", month],
queryFn: async () => { queryFn: async () => {
const res = await fetch("/api/rules"); const res = await fetch(`/api/budgets?month=${month}`);
return res.json(); return res.json();
}, },
}); });
} }
export function useCreateRule() { export function useUpsertBudget() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (data: Omit<RuleRow, "id" | "created_at">) => { mutationFn: async (data: { category: string; month: string; amount_limit: number }) => {
const res = await fetch("/api/rules", { const res = await fetch("/api/budgets", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
if (!res.ok) throw new Error("Failed to create rule"); if (!res.ok) throw new Error("Failed to save budget");
return res.json(); return res.json();
}, },
onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }), onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }),
}); });
} }
export function useUpdateRule() { export function useDeleteBudget() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, ...data }: Partial<RuleRow> & { id: number }) => {
const res = await fetch(`/api/rules/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Failed to update rule");
return res.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }),
});
}
export function useDeleteRule() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (id: number) => { mutationFn: async (id: number) => {
await fetch(`/api/rules/${id}`, { method: "DELETE" }); await fetch(`/api/budgets/${id}`, { method: "DELETE" });
}, },
onSuccess: () => qc.invalidateQueries({ queryKey: ["rules"] }), onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }),
}); });
} }
export function useApplyRules() { export function useMonthlyAnalytics(months?: number) {
const qc = useQueryClient(); const m = months || 6;
return useMutation({ return useQuery<MonthlyAnalytics>({
mutationFn: async () => { queryKey: ["analytics", "monthly", m],
const res = await fetch("/api/rules/apply", { method: "POST" }); queryFn: async () => {
if (!res.ok) throw new Error("Failed to apply rules"); const res = await fetch(`/api/analytics/monthly?months=${m}`);
return res.json() as Promise<{ matched: number; transactions_affected: number }>; return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["rules"] });
}, },
}); });
} }