feat: initial finance SPA — phases 1 & 2

Next.js 16 personal finance dashboard connected to postgres-personal.

Phase 1 (Foundation):
- API routes: GET /api/transactions (paginated, filterable, sortable),
  GET /api/statements, GET /api/merchants
- Transactions data table with date/category/bank/search filters, pagination, sort
- Statements card grid with period, due date, amount, transaction count
- Sidebar layout with nav for all planned sections

Phase 2 (Normalisation):
- PATCH /api/transactions/[id] — upsert transaction_overrides
- POST /api/transactions/bulk — bulk categorize/normalize
- Inline click-to-edit category (22 options) and merchant name
- Blue dot override indicator, bulk action bar
- Effective values via COALESCE(override, llm_value) pattern

Stack: Next.js 16 (App Router, standalone), Prisma 7.x + @prisma/adapter-pg,
TanStack Query, Tailwind CSS. Auth via Traefik chain-oauth@file.
This commit is contained in:
2026-03-07 23:31:40 +11:00
parent 28207b42b5
commit 35a5be97b0
31 changed files with 2243 additions and 99 deletions
+77
View File
@@ -0,0 +1,77 @@
"use client";
import Link from "next/link";
import { useStatements } from "@/lib/hooks";
function formatDate(d: string | null) {
if (!d) return "-";
return new Date(d).toLocaleDateString("en-AU", {
day: "2-digit",
month: "short",
year: "numeric",
});
}
function formatCurrency(amount: number | null, currency = "AUD") {
if (amount === null || amount === undefined) return "-";
return new Intl.NumberFormat("en-AU", {
style: "currency",
currency,
}).format(amount);
}
export default function StatementsPage() {
const { data: statements, isLoading } = useStatements();
return (
<div>
<h2 className="text-xl font-semibold mb-4">Statements</h2>
{isLoading ? (
<p className="text-zinc-500">Loading...</p>
) : !statements?.length ? (
<p className="text-zinc-500">No statements found</p>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{statements.map((s) => (
<div
key={s.id}
className="border border-zinc-800 rounded-lg p-4 bg-zinc-900/50 hover:border-zinc-700 transition-colors"
>
<div className="flex items-center justify-between mb-2">
<h3 className="font-medium">{s.bank_name}</h3>
<span className="text-xs text-zinc-500">{s.currency}</span>
</div>
{s.card_name && (
<p className="text-sm text-zinc-400 mb-2">{s.card_name}</p>
)}
<div className="text-sm text-zinc-400 space-y-1">
<p>Account: {s.account_number}</p>
<p>
Period: {formatDate(s.billing_start_date)} - {formatDate(s.billing_end_date)}
</p>
<p>Due: {formatDate(s.payment_due_date)}</p>
</div>
<div className="mt-3 pt-3 border-t border-zinc-800 flex items-center justify-between">
<div>
<p className="text-lg font-semibold text-red-400">
{formatCurrency(s.total_amount_due, s.currency)}
</p>
<p className="text-xs text-zinc-500">
{s.transaction_count} transactions
</p>
</div>
<Link
href={`/transactions?statement_id=${s.id}`}
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 rounded text-sm transition-colors"
>
View
</Link>
</div>
</div>
))}
</div>
)}
</div>
);
}