feat: integrate React Query for improved data fetching and state management across banking and transactions components
This commit is contained in:
@@ -6,6 +6,27 @@ import type {
|
||||
Folder,
|
||||
Category,
|
||||
} from "@/lib/types";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export interface TransactionsPaginatedParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
accountIds?: string[];
|
||||
categoryIds?: string[];
|
||||
includeUncategorized?: boolean;
|
||||
search?: string;
|
||||
isReconciled?: boolean | "all";
|
||||
sortField?: "date" | "amount" | "description";
|
||||
sortOrder?: "asc" | "desc";
|
||||
}
|
||||
|
||||
export interface TransactionsPaginatedResult {
|
||||
transactions: Transaction[];
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export const bankingService = {
|
||||
async getAllData(): Promise<BankingData> {
|
||||
@@ -16,9 +37,19 @@ export const bankingService = {
|
||||
},
|
||||
}),
|
||||
prisma.transaction.findMany({
|
||||
include: {
|
||||
account: true,
|
||||
category: true,
|
||||
// Removed includes - not needed for transformation, only use direct fields
|
||||
select: {
|
||||
id: true,
|
||||
accountId: true,
|
||||
date: true,
|
||||
amount: true,
|
||||
description: true,
|
||||
type: true,
|
||||
categoryId: true,
|
||||
isReconciled: true,
|
||||
fitId: true,
|
||||
memo: true,
|
||||
checkNum: true,
|
||||
},
|
||||
}),
|
||||
prisma.folder.findMany(),
|
||||
@@ -78,4 +109,292 @@ export const bankingService = {
|
||||
),
|
||||
};
|
||||
},
|
||||
|
||||
async getTransactionsPaginated(
|
||||
params: TransactionsPaginatedParams = {},
|
||||
): Promise<TransactionsPaginatedResult> {
|
||||
const {
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
startDate,
|
||||
endDate,
|
||||
accountIds,
|
||||
categoryIds,
|
||||
includeUncategorized = false,
|
||||
search,
|
||||
isReconciled = "all",
|
||||
sortField = "date",
|
||||
sortOrder = "desc",
|
||||
} = params;
|
||||
|
||||
// Build where clause
|
||||
const where: Prisma.TransactionWhereInput = {};
|
||||
|
||||
// Date filter
|
||||
if (startDate || endDate) {
|
||||
where.date = {};
|
||||
if (startDate) {
|
||||
where.date.gte = startDate;
|
||||
}
|
||||
if (endDate) {
|
||||
// Add time to end of day
|
||||
const endOfDay = new Date(endDate);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
where.date.lte = endOfDay.toISOString().split("T")[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Account filter
|
||||
if (accountIds && accountIds.length > 0) {
|
||||
where.accountId = { in: accountIds };
|
||||
}
|
||||
|
||||
// Category filter
|
||||
const categoryFilter: Prisma.TransactionWhereInput[] = [];
|
||||
if (categoryIds && categoryIds.length > 0) {
|
||||
if (includeUncategorized) {
|
||||
categoryFilter.push({
|
||||
OR: [
|
||||
{ categoryId: { in: categoryIds } },
|
||||
{ categoryId: null },
|
||||
],
|
||||
});
|
||||
} else {
|
||||
categoryFilter.push({ categoryId: { in: categoryIds } });
|
||||
}
|
||||
} else if (includeUncategorized) {
|
||||
categoryFilter.push({ categoryId: null });
|
||||
}
|
||||
|
||||
// Search filter (description or memo)
|
||||
// SQLite is case-insensitive by default for ASCII strings
|
||||
if (search && search.trim()) {
|
||||
const searchLower = search.toLowerCase();
|
||||
categoryFilter.push({
|
||||
OR: [
|
||||
{ description: { contains: searchLower } },
|
||||
{ memo: { contains: searchLower } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Combine all filters with AND if we have multiple conditions
|
||||
if (categoryFilter.length > 0) {
|
||||
if (categoryFilter.length === 1) {
|
||||
Object.assign(where, categoryFilter[0]);
|
||||
} else {
|
||||
where.AND = categoryFilter;
|
||||
}
|
||||
}
|
||||
|
||||
// Reconciled filter
|
||||
if (isReconciled !== "all") {
|
||||
where.isReconciled = isReconciled === true;
|
||||
}
|
||||
|
||||
// Build orderBy
|
||||
const orderBy: Prisma.TransactionOrderByWithRelationInput[] = [];
|
||||
switch (sortField) {
|
||||
case "date":
|
||||
orderBy.push({ date: sortOrder });
|
||||
break;
|
||||
case "amount":
|
||||
orderBy.push({ amount: sortOrder });
|
||||
break;
|
||||
case "description":
|
||||
orderBy.push({ description: sortOrder });
|
||||
break;
|
||||
}
|
||||
// Add secondary sort by date for consistency
|
||||
if (sortField !== "date") {
|
||||
orderBy.push({ date: "desc" });
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const total = await prisma.transaction.count({ where });
|
||||
|
||||
// Get paginated transactions
|
||||
const transactions = await prisma.transaction.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
take: limit,
|
||||
skip: offset,
|
||||
select: {
|
||||
id: true,
|
||||
accountId: true,
|
||||
date: true,
|
||||
amount: true,
|
||||
description: true,
|
||||
type: true,
|
||||
categoryId: true,
|
||||
isReconciled: true,
|
||||
fitId: true,
|
||||
memo: true,
|
||||
checkNum: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Transform to Transaction type
|
||||
const transformedTransactions: Transaction[] = transactions.map(
|
||||
(t): Transaction => ({
|
||||
id: t.id,
|
||||
accountId: t.accountId,
|
||||
date: t.date,
|
||||
amount: t.amount,
|
||||
description: t.description,
|
||||
type: t.type as Transaction["type"],
|
||||
categoryId: t.categoryId,
|
||||
isReconciled: t.isReconciled,
|
||||
fitId: t.fitId,
|
||||
memo: t.memo ?? undefined,
|
||||
checkNum: t.checkNum ?? undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
transactions: transformedTransactions,
|
||||
total,
|
||||
hasMore: offset + limit < total,
|
||||
};
|
||||
},
|
||||
|
||||
async getMetadata(): Promise<{
|
||||
accounts: Account[];
|
||||
folders: Folder[];
|
||||
categories: Category[];
|
||||
}> {
|
||||
const [accounts, folders, categories] = await Promise.all([
|
||||
prisma.account.findMany({
|
||||
include: {
|
||||
folder: true,
|
||||
},
|
||||
}),
|
||||
prisma.folder.findMany(),
|
||||
prisma.category.findMany(),
|
||||
]);
|
||||
|
||||
return {
|
||||
accounts: accounts.map(
|
||||
(a): Account => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
bankId: a.bankId,
|
||||
accountNumber: a.accountNumber,
|
||||
type: a.type as Account["type"],
|
||||
folderId: a.folderId,
|
||||
balance: a.balance,
|
||||
initialBalance: a.initialBalance,
|
||||
currency: a.currency,
|
||||
lastImport: a.lastImport,
|
||||
externalUrl: a.externalUrl,
|
||||
}),
|
||||
),
|
||||
folders: folders.map(
|
||||
(f): Folder => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
parentId: f.parentId,
|
||||
color: f.color,
|
||||
icon: f.icon,
|
||||
}),
|
||||
),
|
||||
categories: categories.map(
|
||||
(c): Category => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
color: c.color,
|
||||
icon: c.icon,
|
||||
keywords: JSON.parse(c.keywords) as string[],
|
||||
parentId: c.parentId,
|
||||
}),
|
||||
),
|
||||
};
|
||||
},
|
||||
|
||||
async getCategoryStats(): Promise<Record<string, { count: number; total: number }>> {
|
||||
// Get stats for all categories in one query using aggregation
|
||||
// We need to sum absolute values, so we'll do it in two steps
|
||||
const stats = await prisma.transaction.groupBy({
|
||||
by: ["categoryId"],
|
||||
where: {
|
||||
categoryId: { not: null },
|
||||
},
|
||||
_count: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const statsMap: Record<string, { count: number; total: number }> = {};
|
||||
|
||||
// Get uncategorized count
|
||||
const uncategorizedCount = await prisma.transaction.count({
|
||||
where: { categoryId: null },
|
||||
});
|
||||
statsMap["uncategorized"] = {
|
||||
count: uncategorizedCount,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
// For each category, calculate total with absolute values
|
||||
for (const stat of stats) {
|
||||
if (stat.categoryId) {
|
||||
const categoryTransactions = await prisma.transaction.findMany({
|
||||
where: { categoryId: stat.categoryId },
|
||||
select: { amount: true },
|
||||
});
|
||||
|
||||
const total = categoryTransactions.reduce(
|
||||
(sum, t) => sum + Math.abs(t.amount),
|
||||
0,
|
||||
);
|
||||
|
||||
statsMap[stat.categoryId] = {
|
||||
count: stat._count.id,
|
||||
total,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return statsMap;
|
||||
},
|
||||
|
||||
async getAccountsWithStats(): Promise<
|
||||
Array<Account & { transactionCount: number }>
|
||||
> {
|
||||
const accounts = await prisma.account.findMany({
|
||||
include: {
|
||||
folder: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Get transaction counts for all accounts in one query
|
||||
const transactionCounts = await prisma.transaction.groupBy({
|
||||
by: ["accountId"],
|
||||
_count: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const countMap = new Map<string, number>();
|
||||
transactionCounts.forEach((tc) => {
|
||||
countMap.set(tc.accountId, tc._count.id);
|
||||
});
|
||||
|
||||
return accounts.map(
|
||||
(a): Account & { transactionCount: number } => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
bankId: a.bankId,
|
||||
accountNumber: a.accountNumber,
|
||||
type: a.type as Account["type"],
|
||||
folderId: a.folderId,
|
||||
balance: a.balance,
|
||||
initialBalance: a.initialBalance,
|
||||
currency: a.currency,
|
||||
lastImport: a.lastImport,
|
||||
externalUrl: a.externalUrl,
|
||||
transactionCount: countMap.get(a.id) || 0,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user