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:
@@ -0,0 +1,33 @@
|
||||
export const CATEGORIES = [
|
||||
"groceries",
|
||||
"dining",
|
||||
"transport",
|
||||
"fuel",
|
||||
"shopping",
|
||||
"utilities",
|
||||
"entertainment",
|
||||
"travel",
|
||||
"health",
|
||||
"insurance",
|
||||
"subscriptions",
|
||||
"cash_advance",
|
||||
"government",
|
||||
"education",
|
||||
"rent",
|
||||
"transfers",
|
||||
"income",
|
||||
"personal_care",
|
||||
"pets",
|
||||
"gifts",
|
||||
"charity",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
export type Category = (typeof CATEGORIES)[number];
|
||||
|
||||
export function formatCategory(cat: string): string {
|
||||
return cat
|
||||
.split("_")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||
|
||||
function createPrisma() {
|
||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
|
||||
return new PrismaClient({ adapter });
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma || createPrisma();
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
|
||||
export async function queryRaw<T>(sql: string, params: unknown[] = []): Promise<T[]> {
|
||||
return prisma.$queryRawUnsafe<T[]>(sql, ...params);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { TransactionRow, StatementRow } from "./queries";
|
||||
|
||||
interface TransactionsResponse {
|
||||
data: TransactionRow[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
interface TransactionFilters {
|
||||
from?: string;
|
||||
to?: string;
|
||||
category?: string;
|
||||
bank_name?: string;
|
||||
search?: string;
|
||||
statement_id?: string;
|
||||
sort_by?: string;
|
||||
sort_dir?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
function buildParams(filters: TransactionFilters): string {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(filters).forEach(([key, val]) => {
|
||||
if (val !== undefined && val !== "") params.set(key, String(val));
|
||||
});
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function useTransactions(filters: TransactionFilters) {
|
||||
return useQuery<TransactionsResponse>({
|
||||
queryKey: ["transactions", filters],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/transactions?${buildParams(filters)}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTransaction(id: number) {
|
||||
return useQuery<TransactionRow>({
|
||||
queryKey: ["transaction", id],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/transactions/${id}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useStatements() {
|
||||
return useQuery<StatementRow[]>({
|
||||
queryKey: ["statements"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/statements");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useStatement(id: number) {
|
||||
return useQuery<StatementRow>({
|
||||
queryKey: ["statement", id],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/statements/${id}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBanks() {
|
||||
return useQuery<string[]>({
|
||||
queryKey: ["banks"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/merchants?type=banks");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTransaction() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
...data
|
||||
}: {
|
||||
id: number;
|
||||
category?: string;
|
||||
merchant_normalized?: string;
|
||||
notes?: string;
|
||||
}) => {
|
||||
const res = await fetch(`/api/transactions/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["transaction"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBulkAction() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body: {
|
||||
action: string;
|
||||
ids: number[];
|
||||
category?: string;
|
||||
merchant_normalized?: string;
|
||||
}) => {
|
||||
const res = await fetch("/api/transactions/bulk", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { queryRaw } from "./db";
|
||||
|
||||
export interface TransactionRow {
|
||||
id: number;
|
||||
statement_id: number;
|
||||
transaction_date: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
transaction_type: string;
|
||||
merchant_name: string | null;
|
||||
merchant_normalized: string | null;
|
||||
location: string | null;
|
||||
foreign_currency_amount: number | null;
|
||||
foreign_currency_code: string | null;
|
||||
category: string;
|
||||
row_index: number;
|
||||
created_at: string;
|
||||
// override fields
|
||||
category_override: string | null;
|
||||
merchant_override: string | null;
|
||||
notes: string | null;
|
||||
effective_category: string;
|
||||
effective_merchant: string;
|
||||
// statement context
|
||||
bank_name: string;
|
||||
}
|
||||
|
||||
export interface StatementRow {
|
||||
id: number;
|
||||
bank_name: string;
|
||||
card_name: string | null;
|
||||
account_number: string;
|
||||
account_type: string | null;
|
||||
billing_start_date: string | null;
|
||||
billing_end_date: string | null;
|
||||
total_amount_due: number;
|
||||
minimum_amount_due: number | null;
|
||||
payment_due_date: string;
|
||||
opening_balance: number | null;
|
||||
closing_balance: number | null;
|
||||
total_credits: number | null;
|
||||
total_debits: number | null;
|
||||
interest_charged: number | null;
|
||||
fees_charged: number | null;
|
||||
credit_limit: number | null;
|
||||
currency: string;
|
||||
tier_used: string | null;
|
||||
created_at: string;
|
||||
transaction_count: number;
|
||||
}
|
||||
|
||||
interface TransactionFilters {
|
||||
from?: string;
|
||||
to?: string;
|
||||
category?: string;
|
||||
bank_name?: string;
|
||||
search?: string;
|
||||
statement_id?: string;
|
||||
sort_by?: string;
|
||||
sort_dir?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export async function getTransactions(filters: TransactionFilters) {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let paramIdx = 1;
|
||||
|
||||
if (filters.from) {
|
||||
conditions.push(`t.transaction_date >= $${paramIdx++}`);
|
||||
params.push(filters.from);
|
||||
}
|
||||
if (filters.to) {
|
||||
conditions.push(`t.transaction_date <= $${paramIdx++}`);
|
||||
params.push(filters.to);
|
||||
}
|
||||
if (filters.category) {
|
||||
conditions.push(`COALESCE(o.category_override, t.category) = $${paramIdx++}`);
|
||||
params.push(filters.category);
|
||||
}
|
||||
if (filters.bank_name) {
|
||||
conditions.push(`s.bank_name = $${paramIdx++}`);
|
||||
params.push(filters.bank_name);
|
||||
}
|
||||
if (filters.search) {
|
||||
conditions.push(`(t.description ILIKE $${paramIdx} OR t.merchant_name ILIKE $${paramIdx})`);
|
||||
params.push(`%${filters.search}%`);
|
||||
paramIdx++;
|
||||
}
|
||||
if (filters.statement_id) {
|
||||
conditions.push(`t.statement_id = $${paramIdx++}`);
|
||||
params.push(Number(filters.statement_id));
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
|
||||
const sortCol = filters.sort_by === "amount" ? "t.amount" : "t.transaction_date";
|
||||
const sortDir = filters.sort_dir === "asc" ? "ASC" : "DESC";
|
||||
const limit = filters.limit || 50;
|
||||
const offset = filters.offset || 0;
|
||||
|
||||
// Count query
|
||||
const countSql = `
|
||||
SELECT COUNT(*)::int as total
|
||||
FROM transactions t
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
JOIN statements s ON s.id = t.statement_id
|
||||
${where}
|
||||
`;
|
||||
const countResult = await queryRaw<{ total: number }>(countSql, params);
|
||||
const total = countResult[0]?.total || 0;
|
||||
|
||||
// Data query
|
||||
const dataSql = `
|
||||
SELECT t.*,
|
||||
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
||||
COALESCE(o.category_override, t.category) as effective_category,
|
||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||
s.bank_name
|
||||
FROM transactions t
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
JOIN statements s ON s.id = t.statement_id
|
||||
${where}
|
||||
ORDER BY ${sortCol} ${sortDir}, t.row_index ASC
|
||||
LIMIT $${paramIdx++} OFFSET $${paramIdx++}
|
||||
`;
|
||||
params.push(limit, offset);
|
||||
|
||||
const data = await queryRaw<TransactionRow>(dataSql, params);
|
||||
|
||||
return { data, total, limit, offset };
|
||||
}
|
||||
|
||||
export async function getTransactionById(id: number) {
|
||||
const sql = `
|
||||
SELECT t.*,
|
||||
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
||||
COALESCE(o.category_override, t.category) as effective_category,
|
||||
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
|
||||
s.bank_name
|
||||
FROM transactions t
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
JOIN statements s ON s.id = t.statement_id
|
||||
WHERE t.id = $1
|
||||
`;
|
||||
const rows = await queryRaw<TransactionRow>(sql, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function getStatements() {
|
||||
const sql = `
|
||||
SELECT s.*,
|
||||
(SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count
|
||||
FROM statements s
|
||||
ORDER BY s.billing_end_date DESC NULLS LAST, s.created_at DESC
|
||||
`;
|
||||
return queryRaw<StatementRow>(sql);
|
||||
}
|
||||
|
||||
export async function getStatementById(id: number) {
|
||||
const sql = `
|
||||
SELECT s.*,
|
||||
(SELECT COUNT(*)::int FROM transactions t WHERE t.statement_id = s.id) as transaction_count
|
||||
FROM statements s
|
||||
WHERE s.id = $1
|
||||
`;
|
||||
const rows = await queryRaw<StatementRow>(sql, [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function getMerchantSuggestions(search: string) {
|
||||
const sql = `
|
||||
SELECT DISTINCT COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as merchant
|
||||
FROM transactions t
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
WHERE COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) ILIKE $1
|
||||
ORDER BY merchant
|
||||
LIMIT 20
|
||||
`;
|
||||
return queryRaw<{ merchant: string }>(sql, [`%${search}%`]);
|
||||
}
|
||||
|
||||
export async function getBankNames() {
|
||||
const sql = `SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`;
|
||||
return queryRaw<{ bank_name: string }>(sql);
|
||||
}
|
||||
Reference in New Issue
Block a user