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,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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user