merge: Phase 5 (Rules Engine) + Phase 6 (Budget & Analytics)
Resolve additive conflicts in schema.prisma and hooks.ts — both models and all hooks retained.
This commit is contained in:
@@ -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);
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
+376
-2
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -431,3 +431,72 @@ export function useApplyRules() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Budgets & Analytics ---
|
||||||
|
|
||||||
|
export interface BudgetRow {
|
||||||
|
id: number;
|
||||||
|
category: string;
|
||||||
|
month: string;
|
||||||
|
amount_limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonthlyAnalyticsRow {
|
||||||
|
category: string;
|
||||||
|
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 () => {
|
||||||
|
const res = await fetch(`/api/budgets?month=${month}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpsertBudget() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (data: { category: string; month: string; amount_limit: number }) => {
|
||||||
|
const res = await fetch("/api/budgets", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to save budget");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteBudget() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: number) => {
|
||||||
|
await fetch(`/api/budgets/${id}`, { method: "DELETE" });
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMonthlyAnalytics(months?: number) {
|
||||||
|
const m = months || 6;
|
||||||
|
return useQuery<MonthlyAnalytics>({
|
||||||
|
queryKey: ["analytics", "monthly", m],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`/api/analytics/monthly?months=${m}`);
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user