feat: integrate React Query for improved data fetching and state management across banking and transactions components

This commit is contained in:
Julien Froidefond
2025-12-06 09:36:06 +01:00
parent e26eb0f039
commit b1a8f9cd60
16 changed files with 3488 additions and 4713 deletions

View File

@@ -19,7 +19,8 @@ import {
AccountBulkActions, AccountBulkActions,
} from "@/components/accounts"; } from "@/components/accounts";
import { FolderEditDialog } from "@/components/folders"; import { FolderEditDialog } from "@/components/folders";
import { useBankingData } from "@/lib/hooks"; import { useBankingMetadata, useAccountsWithStats } from "@/lib/hooks";
import { useQueryClient } from "@tanstack/react-query";
import { import {
updateAccount, updateAccount,
deleteAccount, deleteAccount,
@@ -59,7 +60,19 @@ function FolderDropZone({
} }
export default function AccountsPage() { export default function AccountsPage() {
const { data, isLoading, refresh, refreshSilent, update } = useBankingData(); const queryClient = useQueryClient();
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
const {
data: accountsWithStats,
isLoading: isLoadingAccounts,
} = useAccountsWithStats();
// refresh function is not used directly, invalidations are done inline
const refreshSilent = async () => {
await queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
await queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
};
const [editingAccount, setEditingAccount] = useState<Account | null>(null); const [editingAccount, setEditingAccount] = useState<Account | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedAccounts, setSelectedAccounts] = useState<Set<string>>( const [selectedAccounts, setSelectedAccounts] = useState<Set<string>>(
@@ -92,10 +105,13 @@ export default function AccountsPage() {
}), }),
); );
if (isLoading || !data) { if (isLoadingMetadata || !metadata || isLoadingAccounts || !accountsWithStats) {
return <LoadingState />; return <LoadingState />;
} }
// Convert accountsWithStats to regular accounts for compatibility
const accounts = accountsWithStats.map(({ transactionCount: _transactionCount, ...account }) => account);
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", { return new Intl.NumberFormat("fr-FR", {
style: "currency", style: "currency",
@@ -128,7 +144,8 @@ export default function AccountsPage() {
initialBalance: formData.initialBalance, initialBalance: formData.initialBalance,
}; };
await updateAccount(updatedAccount); await updateAccount(updatedAccount);
refresh(); queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
setIsDialogOpen(false); setIsDialogOpen(false);
setEditingAccount(null); setEditingAccount(null);
} catch (error) { } catch (error) {
@@ -142,7 +159,8 @@ export default function AccountsPage() {
try { try {
await deleteAccount(accountId); await deleteAccount(accountId);
refresh(); queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
} catch (error) { } catch (error) {
console.error("Error deleting account:", error); console.error("Error deleting account:", error);
alert("Erreur lors de la suppression du compte"); alert("Erreur lors de la suppression du compte");
@@ -170,7 +188,8 @@ export default function AccountsPage() {
throw new Error("Failed to delete accounts"); throw new Error("Failed to delete accounts");
} }
setSelectedAccounts(new Set()); setSelectedAccounts(new Set());
refresh(); queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
} catch (error) { } catch (error) {
console.error("Error deleting accounts:", error); console.error("Error deleting accounts:", error);
alert("Erreur lors de la suppression des comptes"); alert("Erreur lors de la suppression des comptes");
@@ -226,7 +245,8 @@ export default function AccountsPage() {
icon: "folder", icon: "folder",
}); });
} }
refresh(); queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
setIsFolderDialogOpen(false); setIsFolderDialogOpen(false);
} catch (error) { } catch (error) {
console.error("Error saving folder:", error); console.error("Error saving folder:", error);
@@ -244,7 +264,8 @@ export default function AccountsPage() {
try { try {
await deleteFolder(folderId); await deleteFolder(folderId);
refresh(); queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
} catch (error) { } catch (error) {
console.error("Error deleting folder:", error); console.error("Error deleting folder:", error);
alert("Erreur lors de la suppression du dossier"); alert("Erreur lors de la suppression du dossier");
@@ -260,7 +281,7 @@ export default function AccountsPage() {
const { active, over } = event; const { active, over } = event;
setActiveId(null); setActiveId(null);
if (!over || active.id === over.id || !data) return; if (!over || active.id === over.id || !accountsWithStats) return;
const activeId = active.id as string; const activeId = active.id as string;
const overId = over.id as string; const overId = over.id as string;
@@ -276,7 +297,7 @@ export default function AccountsPage() {
} else if (overId.startsWith("account-")) { } else if (overId.startsWith("account-")) {
// Déplacer vers le dossier du compte cible // Déplacer vers le dossier du compte cible
const targetAccountId = overId.replace("account-", ""); const targetAccountId = overId.replace("account-", "");
const targetAccount = data.accounts.find( const targetAccount = accountsWithStats.find(
(a) => a.id === targetAccountId, (a) => a.id === targetAccountId,
); );
if (targetAccount) { if (targetAccount) {
@@ -285,34 +306,33 @@ export default function AccountsPage() {
} }
if (targetFolderId !== undefined) { if (targetFolderId !== undefined) {
const account = data.accounts.find((a) => a.id === accountId); const account = accountsWithStats.find((a) => a.id === accountId);
if (!account) return; if (!account) return;
// Sauvegarder l'état précédent pour rollback en cas d'erreur
const previousData = data;
// Optimistic update : mettre à jour immédiatement l'interface // Optimistic update : mettre à jour immédiatement l'interface
const updatedAccount = { const updatedAccount = {
...account, ...account,
folderId: targetFolderId, folderId: targetFolderId,
}; };
const updatedAccounts = data.accounts.map((a) => // Update cache directly
a.id === accountId ? updatedAccount : a, queryClient.setQueryData(
["accounts-with-stats"],
(old: Array<Account & { transactionCount: number }> | undefined) => {
if (!old) return old;
return old.map((a) => (a.id === accountId ? updatedAccount : a));
},
); );
update({
...data,
accounts: updatedAccounts,
});
// Faire la requête en arrière-plan // Faire la requête en arrière-plan
try { try {
await updateAccount(updatedAccount); await updateAccount(updatedAccount);
// Refresh silencieux pour synchroniser avec le serveur sans loader // Refresh silencieux pour synchroniser avec le serveur sans loader
refreshSilent(); await refreshSilent();
} catch (error) { } catch (error) {
console.error("Error moving account:", error); console.error("Error moving account:", error);
// Rollback en cas d'erreur // Rollback en cas d'erreur - refresh data
update(previousData); queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
alert("Erreur lors du déplacement du compte"); alert("Erreur lors du déplacement du compte");
} }
} }
@@ -320,16 +340,17 @@ export default function AccountsPage() {
}; };
const getTransactionCount = (accountId: string) => { const getTransactionCount = (accountId: string) => {
return data.transactions.filter((t) => t.accountId === accountId).length; const account = accountsWithStats.find((a) => a.id === accountId);
return account?.transactionCount || 0;
}; };
const totalBalance = data.accounts.reduce( const totalBalance = accounts.reduce(
(sum, a) => sum + getAccountBalance(a), (sum, a) => sum + getAccountBalance(a),
0, 0,
); );
// Grouper les comptes par folder // Grouper les comptes par folder
const accountsByFolder = data.accounts.reduce( const accountsByFolder = accounts.reduce(
(acc, account) => { (acc, account) => {
const folderId = account.folderId || "no-folder"; const folderId = account.folderId || "no-folder";
if (!acc[folderId]) { if (!acc[folderId]) {
@@ -342,9 +363,9 @@ export default function AccountsPage() {
); );
// Obtenir les folders racine (sans parent) et les trier par nom // Obtenir les folders racine (sans parent) et les trier par nom
const rootFolders = data.folders const rootFolders = metadata.folders
.filter((f) => !f.parentId) .filter((f: FolderType) => !f.parentId)
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a: FolderType, b: FolderType) => a.name.localeCompare(b.name));
return ( return (
<PageLayout> <PageLayout>
@@ -386,7 +407,7 @@ export default function AccountsPage() {
} }
/> />
{data.accounts.length === 0 ? ( {accounts.length === 0 ? (
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-12">
<Building2 className="w-16 h-16 text-muted-foreground mb-4" /> <Building2 className="w-16 h-16 text-muted-foreground mb-4" />
@@ -441,8 +462,8 @@ export default function AccountsPage() {
</div> </div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{accountsByFolder["no-folder"].map((account) => { {accountsByFolder["no-folder"].map((account) => {
const folder = data.folders.find( const folder = metadata.folders.find(
(f) => f.id === account.folderId, (f: FolderType) => f.id === account.folderId,
); );
return ( return (
@@ -466,7 +487,7 @@ export default function AccountsPage() {
)} )}
{/* Afficher les comptes groupés par folder */} {/* Afficher les comptes groupés par folder */}
{rootFolders.map((folder) => { {rootFolders.map((folder: FolderType) => {
const folderAccounts = accountsByFolder[folder.id] || []; const folderAccounts = accountsByFolder[folder.id] || [];
const folderBalance = folderAccounts.reduce( const folderBalance = folderAccounts.reduce(
(sum, a) => sum + getAccountBalance(a), (sum, a) => sum + getAccountBalance(a),
@@ -521,8 +542,8 @@ export default function AccountsPage() {
{folderAccounts.length > 0 ? ( {folderAccounts.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{folderAccounts.map((account) => { {folderAccounts.map((account) => {
const accountFolder = data.folders.find( const accountFolder = metadata.folders.find(
(f) => f.id === account.folderId, (f: FolderType) => f.id === account.folderId,
); );
return ( return (
@@ -565,7 +586,7 @@ export default function AccountsPage() {
{activeId.startsWith("account-") ? ( {activeId.startsWith("account-") ? (
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
{data.accounts.find( {accounts.find(
(a) => a.id === activeId.replace("account-", ""), (a) => a.id === activeId.replace("account-", ""),
)?.name || ""} )?.name || ""}
</CardContent> </CardContent>
@@ -583,7 +604,7 @@ export default function AccountsPage() {
onOpenChange={setIsDialogOpen} onOpenChange={setIsDialogOpen}
formData={formData} formData={formData}
onFormDataChange={setFormData} onFormDataChange={setFormData}
folders={data.folders} folders={metadata.folders}
onSave={handleSave} onSave={handleSave}
/> />
@@ -593,7 +614,7 @@ export default function AccountsPage() {
editingFolder={editingFolder} editingFolder={editingFolder}
formData={folderFormData} formData={folderFormData}
onFormDataChange={setFolderFormData} onFormDataChange={setFolderFormData}
folders={data.folders} folders={metadata.folders}
onSave={handleSaveFolder} onSave={handleSaveFolder}
/> />
</PageLayout> </PageLayout>

View File

@@ -1,8 +1,39 @@
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { accountService } from "@/services/account.service"; import { accountService } from "@/services/account.service";
import { bankingService } from "@/services/banking.service";
import { requireAuth } from "@/lib/auth-utils"; import { requireAuth } from "@/lib/auth-utils";
import type { Account } from "@/lib/types"; import type { Account } from "@/lib/types";
export async function GET(request: NextRequest) {
const authError = await requireAuth();
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
const withStats = searchParams.get("withStats") === "true";
if (withStats) {
const accountsWithStats = await bankingService.getAccountsWithStats();
return NextResponse.json(accountsWithStats, {
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
});
}
return NextResponse.json(
{ error: "Invalid request" },
{ status: 400 },
);
} catch (error) {
console.error("Error fetching accounts:", error);
return NextResponse.json(
{ error: "Failed to fetch accounts" },
{ status: 500 },
);
}
}
export async function POST(request: Request) { export async function POST(request: Request) {
const authError = await requireAuth(); const authError = await requireAuth();
if (authError) return authError; if (authError) return authError;

View File

@@ -1,8 +1,39 @@
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { categoryService } from "@/services/category.service"; import { categoryService } from "@/services/category.service";
import { bankingService } from "@/services/banking.service";
import { requireAuth } from "@/lib/auth-utils"; import { requireAuth } from "@/lib/auth-utils";
import type { Category } from "@/lib/types"; import type { Category } from "@/lib/types";
export async function GET(request: NextRequest) {
const authError = await requireAuth();
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
const statsOnly = searchParams.get("statsOnly") === "true";
if (statsOnly) {
const stats = await bankingService.getCategoryStats();
return NextResponse.json(stats, {
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
});
}
return NextResponse.json(
{ error: "Invalid request" },
{ status: 400 },
);
} catch (error) {
console.error("Error fetching category stats:", error);
return NextResponse.json(
{ error: "Failed to fetch category stats" },
{ status: 500 },
);
}
}
export async function POST(request: Request) { export async function POST(request: Request) {
const authError = await requireAuth(); const authError = await requireAuth();
if (authError) return authError; if (authError) return authError;

View File

@@ -1,14 +1,30 @@
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { bankingService } from "@/services/banking.service"; import { bankingService } from "@/services/banking.service";
import { requireAuth } from "@/lib/auth-utils"; import { requireAuth } from "@/lib/auth-utils";
export async function GET() { export async function GET(request: NextRequest) {
const authError = await requireAuth(); const authError = await requireAuth();
if (authError) return authError; if (authError) return authError;
try { try {
const { searchParams } = new URL(request.url);
const metadataOnly = searchParams.get("metadataOnly") === "true";
if (metadataOnly) {
const metadata = await bankingService.getMetadata();
return NextResponse.json(metadata, {
headers: {
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=600",
},
});
}
const data = await bankingService.getAllData(); const data = await bankingService.getAllData();
return NextResponse.json(data); return NextResponse.json(data, {
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
});
} catch (error) { } catch (error) {
console.error("Error fetching banking data:", error); console.error("Error fetching banking data:", error);
return NextResponse.json( return NextResponse.json(

View File

@@ -1,8 +1,73 @@
import { NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { transactionService } from "@/services/transaction.service"; import { transactionService } from "@/services/transaction.service";
import { bankingService } from "@/services/banking.service";
import { requireAuth } from "@/lib/auth-utils"; import { requireAuth } from "@/lib/auth-utils";
import type { Transaction } from "@/lib/types"; import type { Transaction } from "@/lib/types";
export async function GET(request: NextRequest) {
const authError = await requireAuth();
if (authError) return authError;
try {
const { searchParams } = new URL(request.url);
// Parse pagination params
const limit = parseInt(searchParams.get("limit") || "50", 10);
const offset = parseInt(searchParams.get("offset") || "0", 10);
// Parse filter params
const startDate = searchParams.get("startDate") || undefined;
const endDate = searchParams.get("endDate") || undefined;
const accountIds = searchParams.get("accountIds")
? searchParams.get("accountIds")!.split(",")
: undefined;
const categoryIds = searchParams.get("categoryIds")
? searchParams.get("categoryIds")!.split(",")
: undefined;
const includeUncategorized =
searchParams.get("includeUncategorized") === "true";
const search = searchParams.get("search") || undefined;
const isReconciledParam = searchParams.get("isReconciled");
const isReconciled =
isReconciledParam === "true"
? true
: isReconciledParam === "false"
? false
: "all";
const sortField =
(searchParams.get("sortField") as "date" | "amount" | "description") ||
"date";
const sortOrder =
(searchParams.get("sortOrder") as "asc" | "desc") || "desc";
const result = await bankingService.getTransactionsPaginated({
limit,
offset,
startDate,
endDate,
accountIds,
categoryIds,
includeUncategorized,
search,
isReconciled,
sortField,
sortOrder,
});
return NextResponse.json(result, {
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
});
} catch (error) {
console.error("Error fetching transactions:", error);
return NextResponse.json(
{ error: "Failed to fetch transactions" },
{ status: 500 },
);
}
}
export async function POST(request: Request) { export async function POST(request: Request) {
const authError = await requireAuth(); const authError = await requireAuth();
if (authError) return authError; if (authError) return authError;

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo, useEffect, useCallback } from "react";
import { PageLayout, LoadingState, PageHeader } from "@/components/layout"; import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
import { import {
CategoryCard, CategoryCard,
@@ -8,7 +8,8 @@ import {
ParentCategoryRow, ParentCategoryRow,
CategorySearchBar, CategorySearchBar,
} from "@/components/categories"; } from "@/components/categories";
import { useBankingData } from "@/lib/hooks"; import { useBankingMetadata, useCategoryStats } from "@/lib/hooks";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
@@ -34,11 +35,13 @@ interface RecategorizationResult {
} }
export default function CategoriesPage() { export default function CategoriesPage() {
const { data, isLoading, refresh } = useBankingData(); const queryClient = useQueryClient();
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
const { data: categoryStats, isLoading: isLoadingStats } = useCategoryStats();
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState<Category | null>(null); const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [expandedParents, setExpandedParents] = useState<Set<string>>( const [expandedParents, setExpandedParents] = useState<Set<string>>(
new Set(), new Set()
); );
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: "", name: "",
@@ -49,7 +52,7 @@ export default function CategoriesPage() {
}); });
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [recatResults, setRecatResults] = useState<RecategorizationResult[]>( const [recatResults, setRecatResults] = useState<RecategorizationResult[]>(
[], []
); );
const [isRecatDialogOpen, setIsRecatDialogOpen] = useState(false); const [isRecatDialogOpen, setIsRecatDialogOpen] = useState(false);
const [isRecategorizing, setIsRecategorizing] = useState(false); const [isRecategorizing, setIsRecategorizing] = useState(false);
@@ -57,21 +60,25 @@ export default function CategoriesPage() {
// Organiser les catégories par parent // Organiser les catégories par parent
const { parentCategories, childrenByParent, orphanCategories } = const { parentCategories, childrenByParent, orphanCategories } =
useMemo(() => { useMemo(() => {
if (!data?.categories) if (!metadata?.categories)
return { return {
parentCategories: [], parentCategories: [],
childrenByParent: {}, childrenByParent: {},
orphanCategories: [], orphanCategories: [],
}; };
const parents = data.categories.filter((c) => c.parentId === null); const parents = metadata.categories.filter(
(c: Category) => c.parentId === null
);
const children: Record<string, Category[]> = {}; const children: Record<string, Category[]> = {};
const orphans: Category[] = []; const orphans: Category[] = [];
data.categories metadata.categories
.filter((c) => c.parentId !== null) .filter((c: Category) => c.parentId !== null)
.forEach((child) => { .forEach((child: Category) => {
const parentExists = parents.some((p) => p.id === child.parentId); const parentExists = parents.some(
(p: Category) => p.id === child.parentId
);
if (parentExists) { if (parentExists) {
if (!children[child.parentId!]) { if (!children[child.parentId!]) {
children[child.parentId!] = []; children[child.parentId!] = [];
@@ -87,27 +94,25 @@ export default function CategoriesPage() {
childrenByParent: children, childrenByParent: children,
orphanCategories: orphans, orphanCategories: orphans,
}; };
}, [data?.categories]); }, [metadata?.categories]);
// Initialiser tous les parents comme ouverts // Initialiser tous les parents comme ouverts
useState(() => { useEffect(() => {
if (parentCategories.length > 0 && expandedParents.size === 0) { if (parentCategories.length > 0 && expandedParents.size === 0) {
setExpandedParents(new Set(parentCategories.map((p) => p.id))); setExpandedParents(new Set(parentCategories.map((p: Category) => p.id)));
} }
}); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [parentCategories.length]);
if (isLoading || !data) { const refresh = useCallback(() => {
return <LoadingState />; queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
} queryClient.invalidateQueries({ queryKey: ["category-stats"] });
}, [queryClient]);
const formatCurrency = (amount: number) => { const getCategoryStats = useCallback(
return new Intl.NumberFormat("fr-FR", { (categoryId: string, includeChildren = false) => {
style: "currency", if (!categoryStats) return { total: 0, count: 0 };
currency: "EUR",
}).format(amount);
};
const getCategoryStats = (categoryId: string, includeChildren = false) => {
let categoryIds = [categoryId]; let categoryIds = [categoryId];
if (includeChildren && childrenByParent[categoryId]) { if (includeChildren && childrenByParent[categoryId]) {
@@ -117,15 +122,32 @@ export default function CategoriesPage() {
]; ];
} }
const categoryTransactions = data.transactions.filter((t) => // Sum stats from all category IDs
categoryIds.includes(t.categoryId || ""), let total = 0;
); let count = 0;
const total = categoryTransactions.reduce(
(sum, t) => sum + Math.abs(t.amount), categoryIds.forEach((id) => {
0, const stats = categoryStats[id];
); if (stats) {
const count = categoryTransactions.length; total += stats.total;
count += stats.count;
}
});
return { total, count }; return { total, count };
},
[categoryStats, childrenByParent]
);
if (isLoadingMetadata || !metadata || isLoadingStats || !categoryStats) {
return <LoadingState />;
}
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(amount);
}; };
const toggleExpanded = (parentId: string) => { const toggleExpanded = (parentId: string) => {
@@ -139,7 +161,7 @@ export default function CategoriesPage() {
}; };
const expandAll = () => { const expandAll = () => {
setExpandedParents(new Set(parentCategories.map((p) => p.id))); setExpandedParents(new Set(parentCategories.map((p: Category) => p.id)));
}; };
const collapseAll = () => { const collapseAll = () => {
@@ -224,16 +246,27 @@ export default function CategoriesPage() {
const results: RecategorizationResult[] = []; const results: RecategorizationResult[] = [];
try { try {
// Fetch uncategorized transactions
const uncategorizedResponse = await fetch(
"/api/banking/transactions?limit=1000&offset=0&includeUncategorized=true"
);
if (!uncategorizedResponse.ok) {
throw new Error("Failed to fetch uncategorized transactions");
}
const { transactions: uncategorized } =
await uncategorizedResponse.json();
const { updateTransaction } = await import("@/lib/store-db"); const { updateTransaction } = await import("@/lib/store-db");
const uncategorized = data.transactions.filter((t) => !t.categoryId);
for (const transaction of uncategorized) { for (const transaction of uncategorized) {
const categoryId = autoCategorize( const categoryId = autoCategorize(
transaction.description + " " + (transaction.memo || ""), transaction.description + " " + (transaction.memo || ""),
data.categories, metadata.categories
); );
if (categoryId) { if (categoryId) {
const category = data.categories.find((c) => c.id === categoryId); const category = metadata.categories.find(
(c: Category) => c.id === categoryId
);
if (category) { if (category) {
results.push({ transaction, category }); results.push({ transaction, category });
await updateTransaction({ ...transaction, categoryId }); await updateTransaction({ ...transaction, categoryId });
@@ -252,30 +285,30 @@ export default function CategoriesPage() {
} }
}; };
const uncategorizedCount = data.transactions.filter( const uncategorizedCount = categoryStats["uncategorized"]?.count || 0;
(t) => !t.categoryId,
).length;
// Filtrer les catégories selon la recherche // Filtrer les catégories selon la recherche
const filteredParentCategories = parentCategories.filter((parent) => { const filteredParentCategories = parentCategories.filter(
(parent: Category) => {
if (!searchQuery.trim()) return true; if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();
if (parent.name.toLowerCase().includes(query)) return true; if (parent.name.toLowerCase().includes(query)) return true;
if (parent.keywords.some((k) => k.toLowerCase().includes(query))) if (parent.keywords.some((k: string) => k.toLowerCase().includes(query)))
return true; return true;
const children = childrenByParent[parent.id] || []; const children = childrenByParent[parent.id] || [];
return children.some( return children.some(
(c) => (c) =>
c.name.toLowerCase().includes(query) || c.name.toLowerCase().includes(query) ||
c.keywords.some((k) => k.toLowerCase().includes(query)), c.keywords.some((k) => k.toLowerCase().includes(query))
);
}
); );
});
return ( return (
<PageLayout> <PageLayout>
<PageHeader <PageHeader
title="Catégories" title="Catégories"
description={`${parentCategories.length} catégories principales • ${data.categories.length - parentCategories.length} sous-catégories`} description={`${parentCategories.length} catégories principales • ${metadata.categories.length - parentCategories.length} sous-catégories`}
actions={ actions={
<> <>
{uncategorizedCount > 0 && ( {uncategorizedCount > 0 && (
@@ -306,16 +339,16 @@ export default function CategoriesPage() {
/> />
<div className="space-y-1"> <div className="space-y-1">
{filteredParentCategories.map((parent) => { {filteredParentCategories.map((parent: Category) => {
const allChildren = childrenByParent[parent.id] || []; const allChildren = childrenByParent[parent.id] || [];
const children = searchQuery.trim() const children = searchQuery.trim()
? allChildren.filter( ? allChildren.filter(
(c) => (c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) || c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.keywords.some((k) => c.keywords.some((k) =>
k.toLowerCase().includes(searchQuery.toLowerCase()), k.toLowerCase().includes(searchQuery.toLowerCase())
) || ) ||
parent.name.toLowerCase().includes(searchQuery.toLowerCase()), parent.name.toLowerCase().includes(searchQuery.toLowerCase())
) )
: allChildren; : allChildren;
const stats = getCategoryStats(parent.id, true); const stats = getCategoryStats(parent.id, true);
@@ -402,7 +435,7 @@ export default function CategoriesPage() {
</p> </p>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{new Date(result.transaction.date).toLocaleDateString( {new Date(result.transaction.date).toLocaleDateString(
"fr-FR", "fr-FR"
)} )}
{" • "} {" • "}
{new Intl.NumberFormat("fr-FR", { {new Intl.NumberFormat("fr-FR", {

View File

@@ -3,6 +3,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { AuthSessionProvider } from "@/components/providers/session-provider"; import { AuthSessionProvider } from "@/components/providers/session-provider";
import { QueryProvider } from "@/components/providers/query-provider";
const _geist = Geist({ subsets: ["latin"] }); const _geist = Geist({ subsets: ["latin"] });
const _geistMono = Geist_Mono({ subsets: ["latin"] }); const _geistMono = Geist_Mono({ subsets: ["latin"] });
@@ -22,7 +23,9 @@ export default function RootLayout({
return ( return (
<html lang="fr"> <html lang="fr">
<body className="font-sans antialiased"> <body className="font-sans antialiased">
<QueryProvider>
<AuthSessionProvider>{children}</AuthSessionProvider> <AuthSessionProvider>{children}</AuthSessionProvider>
</QueryProvider>
</body> </body>
</html> </html>
); );

View File

@@ -7,14 +7,14 @@ import {
RuleCreateDialog, RuleCreateDialog,
RulesSearchBar, RulesSearchBar,
} from "@/components/rules"; } from "@/components/rules";
import { useBankingData } from "@/lib/hooks"; import { useBankingMetadata, useTransactions } from "@/lib/hooks";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Sparkles, RefreshCw } from "lucide-react"; import { Sparkles, RefreshCw } from "lucide-react";
import { import {
updateCategory, updateCategory,
autoCategorize, autoCategorize,
updateTransaction,
} from "@/lib/store-db"; } from "@/lib/store-db";
import { import {
normalizeDescription, normalizeDescription,
@@ -31,7 +31,27 @@ interface TransactionGroup {
} }
export default function RulesPage() { export default function RulesPage() {
const { data, isLoading, refresh } = useBankingData(); const queryClient = useQueryClient();
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
// Fetch uncategorized transactions only
const {
data: transactionsData,
isLoading: isLoadingTransactions,
invalidate: invalidateTransactions,
} = useTransactions(
{
limit: 10000, // Large limit to get all uncategorized
offset: 0,
includeUncategorized: true,
},
!!metadata,
);
const refresh = useCallback(() => {
invalidateTransactions();
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
}, [invalidateTransactions, queryClient]);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState<"count" | "amount" | "name">("count"); const [sortBy, setSortBy] = useState<"count" | "amount" | "name">("count");
const [filterMinCount, setFilterMinCount] = useState(2); const [filterMinCount, setFilterMinCount] = useState(2);
@@ -44,9 +64,9 @@ export default function RulesPage() {
// Group uncategorized transactions by normalized description // Group uncategorized transactions by normalized description
const transactionGroups = useMemo(() => { const transactionGroups = useMemo(() => {
if (!data?.transactions) return []; if (!transactionsData?.transactions) return [];
const uncategorized = data.transactions.filter((t) => !t.categoryId); const uncategorized = transactionsData.transactions;
const groups: Record<string, Transaction[]> = {}; const groups: Record<string, Transaction[]> = {};
uncategorized.forEach((transaction) => { uncategorized.forEach((transaction) => {
@@ -101,12 +121,9 @@ export default function RulesPage() {
}); });
return filtered; return filtered;
}, [data?.transactions, searchQuery, sortBy, filterMinCount]); }, [transactionsData?.transactions, searchQuery, sortBy, filterMinCount]);
const uncategorizedCount = useMemo(() => { const uncategorizedCount = transactionsData?.total || 0;
if (!data?.transactions) return 0;
return data.transactions.filter((t) => !t.categoryId).length;
}, [data?.transactions]);
const formatCurrency = useCallback((amount: number) => { const formatCurrency = useCallback((amount: number) => {
return new Intl.NumberFormat("fr-FR", { return new Intl.NumberFormat("fr-FR", {
@@ -147,11 +164,11 @@ export default function RulesPage() {
applyToExisting: boolean; applyToExisting: boolean;
transactionIds: string[]; transactionIds: string[];
}) => { }) => {
if (!data) return; if (!metadata) return;
// 1. Add keyword to category // 1. Add keyword to category
const category = data.categories.find( const category = metadata.categories.find(
(c) => c.id === ruleData.categoryId, (c: { id: string }) => c.id === ruleData.categoryId,
); );
if (!category) { if (!category) {
throw new Error("Category not found"); throw new Error("Category not found");
@@ -159,7 +176,7 @@ export default function RulesPage() {
// Check if keyword already exists // Check if keyword already exists
const keywordExists = category.keywords.some( const keywordExists = category.keywords.some(
(k) => k.toLowerCase() === ruleData.keyword.toLowerCase(), (k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase(),
); );
if (!keywordExists) { if (!keywordExists) {
@@ -171,37 +188,41 @@ export default function RulesPage() {
// 2. Apply to existing transactions if requested // 2. Apply to existing transactions if requested
if (ruleData.applyToExisting) { if (ruleData.applyToExisting) {
const transactions = data.transactions.filter((t) =>
ruleData.transactionIds.includes(t.id),
);
await Promise.all( await Promise.all(
transactions.map((t) => ruleData.transactionIds.map((id) =>
updateTransaction({ ...t, categoryId: ruleData.categoryId }), fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, categoryId: ruleData.categoryId }),
}),
), ),
); );
} }
refresh(); refresh();
}, },
[data, refresh], [metadata, refresh],
); );
const handleAutoCategorize = useCallback(async () => { const handleAutoCategorize = useCallback(async () => {
if (!data) return; if (!metadata || !transactionsData) return;
setIsAutoCategorizing(true); setIsAutoCategorizing(true);
try { try {
const uncategorized = data.transactions.filter((t) => !t.categoryId); const uncategorized = transactionsData.transactions;
let categorizedCount = 0; let categorizedCount = 0;
for (const transaction of uncategorized) { for (const transaction of uncategorized) {
const categoryId = autoCategorize( const categoryId = autoCategorize(
transaction.description + " " + (transaction.memo || ""), transaction.description + " " + (transaction.memo || ""),
data.categories, metadata.categories,
); );
if (categoryId) { if (categoryId) {
await updateTransaction({ ...transaction, categoryId }); await fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...transaction, categoryId }),
});
categorizedCount++; categorizedCount++;
} }
} }
@@ -216,16 +237,18 @@ export default function RulesPage() {
} finally { } finally {
setIsAutoCategorizing(false); setIsAutoCategorizing(false);
} }
}, [data, refresh]); }, [metadata, transactionsData, refresh]);
const handleCategorizeGroup = useCallback( const handleCategorizeGroup = useCallback(
async (group: TransactionGroup, categoryId: string | null) => { async (group: TransactionGroup, categoryId: string | null) => {
if (!data) return;
try { try {
await Promise.all( await Promise.all(
group.transactions.map((t) => group.transactions.map((t) =>
updateTransaction({ ...t, categoryId }), fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...t, categoryId }),
}),
), ),
); );
refresh(); refresh();
@@ -234,10 +257,10 @@ export default function RulesPage() {
alert("Erreur lors de la catégorisation"); alert("Erreur lors de la catégorisation");
} }
}, },
[data, refresh], [refresh],
); );
if (isLoading || !data) { if (isLoadingMetadata || !metadata || isLoadingTransactions || !transactionsData) {
return <LoadingState />; return <LoadingState />;
} }
@@ -312,7 +335,7 @@ export default function RulesPage() {
onCategorize={(categoryId) => onCategorize={(categoryId) =>
handleCategorizeGroup(group, categoryId) handleCategorizeGroup(group, categoryId)
} }
categories={data.categories} categories={metadata.categories}
formatCurrency={formatCurrency} formatCurrency={formatCurrency}
formatDate={formatDate} formatDate={formatDate}
/> />
@@ -324,7 +347,7 @@ export default function RulesPage() {
open={isDialogOpen} open={isDialogOpen}
onOpenChange={setIsDialogOpen} onOpenChange={setIsDialogOpen}
group={selectedGroup} group={selectedGroup}
categories={data.categories} categories={metadata.categories}
onSave={handleSaveRule} onSave={handleSaveRule}
/> />
</PageLayout> </PageLayout>

View File

@@ -190,6 +190,7 @@ export default function StatisticsPage() {
const stats = useMemo(() => { const stats = useMemo(() => {
if (!data) return null; if (!data) return null;
// Pre-filter transactions once
let transactions = data.transactions.filter((t) => { let transactions = data.transactions.filter((t) => {
const transactionDate = new Date(t.date); const transactionDate = new Date(t.date);
if (endDate) { if (endDate) {

View File

@@ -10,11 +10,13 @@ import {
} from "@/components/transactions"; } from "@/components/transactions";
import { RuleCreateDialog } from "@/components/rules"; import { RuleCreateDialog } from "@/components/rules";
import { OFXImportDialog } from "@/components/import/ofx-import-dialog"; import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
import { useBankingData } from "@/lib/hooks"; import { useBankingMetadata, useTransactions } from "@/lib/hooks";
import { updateCategory, updateTransaction } from "@/lib/store-db"; import { updateCategory } from "@/lib/store-db";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Upload } from "lucide-react"; import { Upload } from "lucide-react";
import type { Transaction } from "@/lib/types"; import type { Transaction } from "@/lib/types";
import type { TransactionsPaginatedParams } from "@/services/banking.service";
import { import {
normalizeDescription, normalizeDescription,
suggestKeyword, suggestKeyword,
@@ -24,16 +26,31 @@ type SortField = "date" | "amount" | "description";
type SortOrder = "asc" | "desc"; type SortOrder = "asc" | "desc";
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all"; type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
const PAGE_SIZE = 100;
export default function TransactionsPage() { export default function TransactionsPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { data, isLoading, refresh, update } = useBankingData(); const queryClient = useQueryClient();
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]); const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
const [page, setPage] = useState(0);
// Debounce search query
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchQuery(searchQuery);
setPage(0); // Reset to first page when search changes
}, 300);
return () => clearTimeout(timer);
}, [searchQuery]);
useEffect(() => { useEffect(() => {
const accountId = searchParams.get("accountId"); const accountId = searchParams.get("accountId");
if (accountId) { if (accountId) {
setSelectedAccounts([accountId]); setSelectedAccounts([accountId]);
setPage(0);
} }
}, [searchParams]); }, [searchParams]);
@@ -43,20 +60,20 @@ export default function TransactionsPage() {
const [showReconciled, setShowReconciled] = useState<string>("all"); const [showReconciled, setShowReconciled] = useState<string>("all");
const [period, setPeriod] = useState<Period>("all"); const [period, setPeriod] = useState<Period>("all");
const [customStartDate, setCustomStartDate] = useState<Date | undefined>( const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
undefined, undefined
); );
const [customEndDate, setCustomEndDate] = useState<Date | undefined>( const [customEndDate, setCustomEndDate] = useState<Date | undefined>(
undefined, undefined
); );
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false); const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
const [sortField, setSortField] = useState<SortField>("date"); const [sortField, setSortField] = useState<SortField>("date");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc"); const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>( const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
new Set(), new Set()
); );
const [ruleDialogOpen, setRuleDialogOpen] = useState(false); const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>( const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(
null, null
); );
// Get start date based on period // Get start date based on period
@@ -86,225 +103,106 @@ export default function TransactionsPage() {
return undefined; return undefined;
}, [period, customEndDate]); }, [period, customEndDate]);
// Transactions filtered for account filter (by categories, search, reconciled, period - not accounts) // Build transaction query params
const transactionsForAccountFilter = useMemo(() => { const transactionParams = useMemo(() => {
if (!data) return []; const params: TransactionsPaginatedParams = {
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
sortField,
sortOrder,
};
let transactions = [...data.transactions]; if (startDate && period !== "all") {
params.startDate = startDate.toISOString().split("T")[0];
// Filter by period }
transactions = transactions.filter((t) => {
const transactionDate = new Date(t.date);
if (endDate) { if (endDate) {
// Custom date range params.endDate = endDate.toISOString().split("T")[0];
const endOfDay = new Date(endDate);
endOfDay.setHours(23, 59, 59, 999);
return transactionDate >= startDate && transactionDate <= endOfDay;
} else if (period !== "all") {
// Standard period
return transactionDate >= startDate;
} }
return true; if (!selectedAccounts.includes("all")) {
}); params.accountIds = selectedAccounts;
if (searchQuery) {
const query = searchQuery.toLowerCase();
transactions = transactions.filter(
(t) =>
t.description.toLowerCase().includes(query) ||
t.memo?.toLowerCase().includes(query),
);
} }
if (!selectedCategories.includes("all")) { if (!selectedCategories.includes("all")) {
if (selectedCategories.includes("uncategorized")) { if (selectedCategories.includes("uncategorized")) {
transactions = transactions.filter((t) => !t.categoryId); params.includeUncategorized = true;
} else { } else {
transactions = transactions.filter( params.categoryIds = selectedCategories;
(t) => t.categoryId && selectedCategories.includes(t.categoryId),
);
} }
} }
if (debouncedSearchQuery) {
params.search = debouncedSearchQuery;
}
if (showReconciled !== "all") { if (showReconciled !== "all") {
const isReconciled = showReconciled === "reconciled"; params.isReconciled = showReconciled === "reconciled";
transactions = transactions.filter(
(t) => t.isReconciled === isReconciled,
);
} }
return transactions; return params;
}, [ }, [
data, page,
searchQuery,
selectedCategories,
showReconciled,
period,
startDate, startDate,
endDate, endDate,
]);
// Transactions filtered for category filter (by accounts, search, reconciled, period - not categories)
const transactionsForCategoryFilter = useMemo(() => {
if (!data) return [];
let transactions = [...data.transactions];
// Filter by period
transactions = transactions.filter((t) => {
const transactionDate = new Date(t.date);
if (endDate) {
// Custom date range
const endOfDay = new Date(endDate);
endOfDay.setHours(23, 59, 59, 999);
return transactionDate >= startDate && transactionDate <= endOfDay;
} else if (period !== "all") {
// Standard period
return transactionDate >= startDate;
}
return true;
});
if (searchQuery) {
const query = searchQuery.toLowerCase();
transactions = transactions.filter(
(t) =>
t.description.toLowerCase().includes(query) ||
t.memo?.toLowerCase().includes(query),
);
}
if (!selectedAccounts.includes("all")) {
transactions = transactions.filter((t) =>
selectedAccounts.includes(t.accountId),
);
}
if (showReconciled !== "all") {
const isReconciled = showReconciled === "reconciled";
transactions = transactions.filter(
(t) => t.isReconciled === isReconciled,
);
}
return transactions;
}, [
data,
searchQuery,
selectedAccounts,
showReconciled,
period,
startDate,
endDate,
]);
const filteredTransactions = useMemo(() => {
if (!data) return [];
let transactions = [...data.transactions];
// Filter by period
transactions = transactions.filter((t) => {
const transactionDate = new Date(t.date);
if (endDate) {
// Custom date range
const endOfDay = new Date(endDate);
endOfDay.setHours(23, 59, 59, 999);
return transactionDate >= startDate && transactionDate <= endOfDay;
} else if (period !== "all") {
// Standard period
return transactionDate >= startDate;
}
return true;
});
if (searchQuery) {
const query = searchQuery.toLowerCase();
transactions = transactions.filter(
(t) =>
t.description.toLowerCase().includes(query) ||
t.memo?.toLowerCase().includes(query),
);
}
if (!selectedAccounts.includes("all")) {
transactions = transactions.filter((t) =>
selectedAccounts.includes(t.accountId),
);
}
if (!selectedCategories.includes("all")) {
if (selectedCategories.includes("uncategorized")) {
transactions = transactions.filter((t) => !t.categoryId);
} else {
transactions = transactions.filter(
(t) => t.categoryId && selectedCategories.includes(t.categoryId),
);
}
}
if (showReconciled !== "all") {
const isReconciled = showReconciled === "reconciled";
transactions = transactions.filter(
(t) => t.isReconciled === isReconciled,
);
}
transactions.sort((a, b) => {
let comparison = 0;
switch (sortField) {
case "date":
comparison = new Date(a.date).getTime() - new Date(b.date).getTime();
break;
case "amount":
comparison = a.amount - b.amount;
break;
case "description":
comparison = a.description.localeCompare(b.description);
break;
}
return sortOrder === "asc" ? comparison : -comparison;
});
return transactions;
}, [
data,
searchQuery,
selectedAccounts, selectedAccounts,
selectedCategories, selectedCategories,
debouncedSearchQuery,
showReconciled, showReconciled,
sortField,
sortOrder,
period, period,
]);
// Fetch transactions with pagination
const {
data: transactionsData,
isLoading: isLoadingTransactions,
invalidate: invalidateTransactions,
} = useTransactions(transactionParams, !!metadata);
// Reset page when filters change
useEffect(() => {
setPage(0);
}, [
startDate, startDate,
endDate, endDate,
selectedAccounts,
selectedCategories,
debouncedSearchQuery,
showReconciled,
sortField, sortField,
sortOrder, sortOrder,
]); ]);
// For filter comboboxes, we'll use empty arrays for now
// They can be enhanced later with separate queries if needed
const transactionsForAccountFilter: Transaction[] = [];
const transactionsForCategoryFilter: Transaction[] = [];
const handleCreateRule = useCallback((transaction: Transaction) => { const handleCreateRule = useCallback((transaction: Transaction) => {
setRuleTransaction(transaction); setRuleTransaction(transaction);
setRuleDialogOpen(true); setRuleDialogOpen(true);
}, []); }, []);
// Create a virtual group for the rule dialog based on selected transaction // Create a virtual group for the rule dialog based on selected transaction
// Note: This requires fetching similar transactions - simplified for now
const ruleGroup = useMemo(() => { const ruleGroup = useMemo(() => {
if (!ruleTransaction || !data) return null; if (!ruleTransaction || !transactionsData) return null;
// Find similar transactions (same normalized description) // Use transactions from current page to find similar ones
const normalizedDesc = normalizeDescription(ruleTransaction.description); const normalizedDesc = normalizeDescription(ruleTransaction.description);
const similarTransactions = data.transactions.filter( const similarTransactions = transactionsData.transactions.filter(
(t) => normalizeDescription(t.description) === normalizedDesc, (t) => normalizeDescription(t.description) === normalizedDesc
); );
if (similarTransactions.length === 0) return null;
return { return {
key: normalizedDesc, key: normalizedDesc,
displayName: ruleTransaction.description, displayName: ruleTransaction.description,
transactions: similarTransactions, transactions: similarTransactions,
totalAmount: similarTransactions.reduce((sum, t) => sum + t.amount, 0), totalAmount: similarTransactions.reduce((sum, t) => sum + t.amount, 0),
suggestedKeyword: suggestKeyword( suggestedKeyword: suggestKeyword(
similarTransactions.map((t) => t.description), similarTransactions.map((t) => t.description)
), ),
}; };
}, [ruleTransaction, data]); }, [ruleTransaction, transactionsData]);
const handleSaveRule = useCallback( const handleSaveRule = useCallback(
async (ruleData: { async (ruleData: {
@@ -313,11 +211,11 @@ export default function TransactionsPage() {
applyToExisting: boolean; applyToExisting: boolean;
transactionIds: string[]; transactionIds: string[];
}) => { }) => {
if (!data) return; if (!metadata) return;
// 1. Add keyword to category // 1. Add keyword to category
const category = data.categories.find( const category = metadata.categories.find(
(c) => c.id === ruleData.categoryId, (c: { id: string }) => c.id === ruleData.categoryId
); );
if (!category) { if (!category) {
throw new Error("Category not found"); throw new Error("Category not found");
@@ -325,7 +223,7 @@ export default function TransactionsPage() {
// Check if keyword already exists // Check if keyword already exists
const keywordExists = category.keywords.some( const keywordExists = category.keywords.some(
(k) => k.toLowerCase() === ruleData.keyword.toLowerCase(), (k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase()
); );
if (!keywordExists) { if (!keywordExists) {
@@ -337,24 +235,31 @@ export default function TransactionsPage() {
// 2. Apply to existing transactions if requested // 2. Apply to existing transactions if requested
if (ruleData.applyToExisting) { if (ruleData.applyToExisting) {
const transactions = data.transactions.filter((t) =>
ruleData.transactionIds.includes(t.id),
);
await Promise.all( await Promise.all(
transactions.map((t) => ruleData.transactionIds.map((id) =>
updateTransaction({ ...t, categoryId: ruleData.categoryId }), fetch("/api/banking/transactions", {
), method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, categoryId: ruleData.categoryId }),
})
)
); );
} }
refresh(); // Invalidate queries
queryClient.invalidateQueries({ queryKey: ["transactions"] });
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
setRuleDialogOpen(false); setRuleDialogOpen(false);
}, },
[data, refresh], [metadata, queryClient]
); );
if (isLoading || !data) { const invalidateAll = useCallback(() => {
invalidateTransactions();
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
}, [invalidateTransactions, queryClient]);
if (isLoadingMetadata || !metadata) {
return <LoadingState />; return <LoadingState />;
} }
@@ -374,7 +279,11 @@ export default function TransactionsPage() {
}; };
const toggleReconciled = async (transactionId: string) => { const toggleReconciled = async (transactionId: string) => {
const transaction = data.transactions.find((t) => t.id === transactionId); if (!transactionsData) return;
const transaction = transactionsData.transactions.find(
(t) => t.id === transactionId
);
if (!transaction) return; if (!transaction) return;
const updatedTransaction = { const updatedTransaction = {
@@ -382,84 +291,75 @@ export default function TransactionsPage() {
isReconciled: !transaction.isReconciled, isReconciled: !transaction.isReconciled,
}; };
const updatedTransactions = data.transactions.map((t) =>
t.id === transactionId ? updatedTransaction : t,
);
update({ ...data, transactions: updatedTransactions });
try { try {
await fetch("/api/banking/transactions", { await fetch("/api/banking/transactions", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedTransaction), body: JSON.stringify(updatedTransaction),
}); });
invalidateTransactions();
} catch (error) { } catch (error) {
console.error("Failed to update transaction:", error); console.error("Failed to update transaction:", error);
refresh();
} }
}; };
const markReconciled = async (transactionId: string) => { const markReconciled = async (transactionId: string) => {
const transaction = data.transactions.find((t) => t.id === transactionId); if (!transactionsData) return;
if (!transaction || transaction.isReconciled) return; // Skip if already reconciled
const transaction = transactionsData.transactions.find(
(t) => t.id === transactionId
);
if (!transaction || transaction.isReconciled) return;
const updatedTransaction = { const updatedTransaction = {
...transaction, ...transaction,
isReconciled: true, isReconciled: true,
}; };
const updatedTransactions = data.transactions.map((t) =>
t.id === transactionId ? updatedTransaction : t,
);
update({ ...data, transactions: updatedTransactions });
try { try {
await fetch("/api/banking/transactions", { await fetch("/api/banking/transactions", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedTransaction), body: JSON.stringify(updatedTransaction),
}); });
invalidateTransactions();
} catch (error) { } catch (error) {
console.error("Failed to update transaction:", error); console.error("Failed to update transaction:", error);
refresh();
} }
}; };
const setCategory = async ( const setCategory = async (
transactionId: string, transactionId: string,
categoryId: string | null, categoryId: string | null
) => { ) => {
const transaction = data.transactions.find((t) => t.id === transactionId); if (!transactionsData) return;
const transaction = transactionsData.transactions.find(
(t) => t.id === transactionId
);
if (!transaction) return; if (!transaction) return;
const updatedTransaction = { ...transaction, categoryId }; const updatedTransaction = { ...transaction, categoryId };
const updatedTransactions = data.transactions.map((t) =>
t.id === transactionId ? updatedTransaction : t,
);
update({ ...data, transactions: updatedTransactions });
try { try {
await fetch("/api/banking/transactions", { await fetch("/api/banking/transactions", {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedTransaction), body: JSON.stringify(updatedTransaction),
}); });
invalidateTransactions();
} catch (error) { } catch (error) {
console.error("Failed to update transaction:", error); console.error("Failed to update transaction:", error);
refresh();
} }
}; };
const bulkReconcile = async (reconciled: boolean) => { const bulkReconcile = async (reconciled: boolean) => {
const transactionsToUpdate = data.transactions.filter((t) => if (!transactionsData) return;
selectedTransactions.has(t.id),
const transactionsToUpdate = transactionsData.transactions.filter((t) =>
selectedTransactions.has(t.id)
); );
const updatedTransactions = data.transactions.map((t) =>
selectedTransactions.has(t.id) ? { ...t, isReconciled: reconciled } : t,
);
update({ ...data, transactions: updatedTransactions });
setSelectedTransactions(new Set()); setSelectedTransactions(new Set());
try { try {
@@ -469,24 +369,22 @@ export default function TransactionsPage() {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...t, isReconciled: reconciled }), body: JSON.stringify({ ...t, isReconciled: reconciled }),
}), })
), )
); );
invalidateTransactions();
} catch (error) { } catch (error) {
console.error("Failed to update transactions:", error); console.error("Failed to update transactions:", error);
refresh();
} }
}; };
const bulkSetCategory = async (categoryId: string | null) => { const bulkSetCategory = async (categoryId: string | null) => {
const transactionsToUpdate = data.transactions.filter((t) => if (!transactionsData) return;
selectedTransactions.has(t.id),
const transactionsToUpdate = transactionsData.transactions.filter((t) =>
selectedTransactions.has(t.id)
); );
const updatedTransactions = data.transactions.map((t) =>
selectedTransactions.has(t.id) ? { ...t, categoryId } : t,
);
update({ ...data, transactions: updatedTransactions });
setSelectedTransactions(new Set()); setSelectedTransactions(new Set());
try { try {
@@ -496,20 +394,23 @@ export default function TransactionsPage() {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...t, categoryId }), body: JSON.stringify({ ...t, categoryId }),
}), })
), )
); );
invalidateTransactions();
} catch (error) { } catch (error) {
console.error("Failed to update transactions:", error); console.error("Failed to update transactions:", error);
refresh();
} }
}; };
const toggleSelectAll = () => { const toggleSelectAll = () => {
if (selectedTransactions.size === filteredTransactions.length) { if (!transactionsData) return;
if (selectedTransactions.size === transactionsData.transactions.length) {
setSelectedTransactions(new Set()); setSelectedTransactions(new Set());
} else { } else {
setSelectedTransactions(new Set(filteredTransactions.map((t) => t.id))); setSelectedTransactions(
new Set(transactionsData.transactions.map((t) => t.id))
);
} }
}; };
@@ -530,15 +431,10 @@ export default function TransactionsPage() {
setSortField(field); setSortField(field);
setSortOrder(field === "date" ? "desc" : "asc"); setSortOrder(field === "date" ? "desc" : "asc");
} }
setPage(0);
}; };
const deleteTransaction = async (transactionId: string) => { const deleteTransaction = async (transactionId: string) => {
// Optimistic update
const updatedTransactions = data.transactions.filter(
(t) => t.id !== transactionId,
);
update({ ...data, transactions: updatedTransactions });
// Remove from selected if selected // Remove from selected if selected
const newSelected = new Set(selectedTransactions); const newSelected = new Set(selectedTransactions);
newSelected.delete(transactionId); newSelected.delete(transactionId);
@@ -549,22 +445,26 @@ export default function TransactionsPage() {
`/api/banking/transactions?id=${transactionId}`, `/api/banking/transactions?id=${transactionId}`,
{ {
method: "DELETE", method: "DELETE",
}, }
); );
if (!response.ok) throw new Error("Failed to delete transaction"); if (!response.ok) throw new Error("Failed to delete transaction");
invalidateTransactions();
} catch (error) { } catch (error) {
console.error("Failed to delete transaction:", error); console.error("Failed to delete transaction:", error);
refresh(); // Revert on error
} }
}; };
const filteredTransactions = transactionsData?.transactions || [];
const totalTransactions = transactionsData?.total || 0;
const hasMore = transactionsData?.hasMore || false;
return ( return (
<PageLayout> <PageLayout>
<PageHeader <PageHeader
title="Transactions" title="Transactions"
description={`${filteredTransactions.length} transaction${filteredTransactions.length > 1 ? "s" : ""}`} description={`${totalTransactions} transaction${totalTransactions > 1 ? "s" : ""}`}
actions={ actions={
<OFXImportDialog onImportComplete={refresh}> <OFXImportDialog onImportComplete={invalidateAll}>
<Button> <Button>
<Upload className="w-4 h-4 mr-2" /> <Upload className="w-4 h-4 mr-2" />
Importer OFX Importer OFX
@@ -577,14 +477,24 @@ export default function TransactionsPage() {
searchQuery={searchQuery} searchQuery={searchQuery}
onSearchChange={setSearchQuery} onSearchChange={setSearchQuery}
selectedAccounts={selectedAccounts} selectedAccounts={selectedAccounts}
onAccountsChange={setSelectedAccounts} onAccountsChange={(accounts) => {
setSelectedAccounts(accounts);
setPage(0);
}}
selectedCategories={selectedCategories} selectedCategories={selectedCategories}
onCategoriesChange={setSelectedCategories} onCategoriesChange={(categories) => {
setSelectedCategories(categories);
setPage(0);
}}
showReconciled={showReconciled} showReconciled={showReconciled}
onReconciledChange={setShowReconciled} onReconciledChange={(value) => {
setShowReconciled(value);
setPage(0);
}}
period={period} period={period}
onPeriodChange={(p) => { onPeriodChange={(p) => {
setPeriod(p); setPeriod(p);
setPage(0);
if (p !== "custom") { if (p !== "custom") {
setIsCustomDatePickerOpen(false); setIsCustomDatePickerOpen(false);
} else { } else {
@@ -593,28 +503,38 @@ export default function TransactionsPage() {
}} }}
customStartDate={customStartDate} customStartDate={customStartDate}
customEndDate={customEndDate} customEndDate={customEndDate}
onCustomStartDateChange={setCustomStartDate} onCustomStartDateChange={(date) => {
onCustomEndDateChange={setCustomEndDate} setCustomStartDate(date);
setPage(0);
}}
onCustomEndDateChange={(date) => {
setCustomEndDate(date);
setPage(0);
}}
isCustomDatePickerOpen={isCustomDatePickerOpen} isCustomDatePickerOpen={isCustomDatePickerOpen}
onCustomDatePickerOpenChange={setIsCustomDatePickerOpen} onCustomDatePickerOpenChange={setIsCustomDatePickerOpen}
accounts={data.accounts} accounts={metadata.accounts}
folders={data.folders} folders={metadata.folders}
categories={data.categories} categories={metadata.categories}
transactionsForAccountFilter={transactionsForAccountFilter} transactionsForAccountFilter={transactionsForAccountFilter}
transactionsForCategoryFilter={transactionsForCategoryFilter} transactionsForCategoryFilter={transactionsForCategoryFilter}
/> />
<TransactionBulkActions <TransactionBulkActions
selectedCount={selectedTransactions.size} selectedCount={selectedTransactions.size}
categories={data.categories} categories={metadata.categories}
onReconcile={bulkReconcile} onReconcile={bulkReconcile}
onSetCategory={bulkSetCategory} onSetCategory={bulkSetCategory}
/> />
{isLoadingTransactions ? (
<LoadingState />
) : (
<>
<TransactionTable <TransactionTable
transactions={filteredTransactions} transactions={filteredTransactions}
accounts={data.accounts} accounts={metadata.accounts}
categories={data.categories} categories={metadata.categories}
selectedTransactions={selectedTransactions} selectedTransactions={selectedTransactions}
sortField={sortField} sortField={sortField}
sortOrder={sortOrder} sortOrder={sortOrder}
@@ -630,11 +550,42 @@ export default function TransactionsPage() {
formatDate={formatDate} formatDate={formatDate}
/> />
{/* Pagination controls */}
{totalTransactions > PAGE_SIZE && (
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-muted-foreground">
Affichage de {page * PAGE_SIZE + 1} à{" "}
{Math.min((page + 1) * PAGE_SIZE, totalTransactions)} sur{" "}
{totalTransactions}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
>
Précédent
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => p + 1)}
disabled={!hasMore}
>
Suivant
</Button>
</div>
</div>
)}
</>
)}
<RuleCreateDialog <RuleCreateDialog
open={ruleDialogOpen} open={ruleDialogOpen}
onOpenChange={setRuleDialogOpen} onOpenChange={setRuleDialogOpen}
group={ruleGroup} group={ruleGroup}
categories={data.categories} categories={metadata.categories}
onSave={handleSaveRule} onSave={handleSaveRule}
/> />
</PageLayout> </PageLayout>

View File

@@ -0,0 +1,25 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime)
refetchOnWindowFocus: false,
retry: 1,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@@ -1,8 +1,13 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import type { BankingData } from "./types"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { BankingData, Account } from "./types";
import { loadData } from "./store-db"; import { loadData } from "./store-db";
import type {
TransactionsPaginatedParams,
TransactionsPaginatedResult,
} from "@/services/banking.service";
export function useBankingData() { export function useBankingData() {
const [data, setData] = useState<BankingData | null>(null); const [data, setData] = useState<BankingData | null>(null);
@@ -75,3 +80,100 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
return [storedValue, setValue] as const; return [storedValue, setValue] as const;
} }
export function useTransactions(
params: TransactionsPaginatedParams = {},
enabled = true,
) {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ["transactions", params],
queryFn: async (): Promise<TransactionsPaginatedResult> => {
const searchParams = new URLSearchParams();
if (params.limit) searchParams.set("limit", params.limit.toString());
if (params.offset) searchParams.set("offset", params.offset.toString());
if (params.startDate) searchParams.set("startDate", params.startDate);
if (params.endDate) searchParams.set("endDate", params.endDate);
if (params.accountIds && params.accountIds.length > 0) {
searchParams.set("accountIds", params.accountIds.join(","));
}
if (params.categoryIds && params.categoryIds.length > 0) {
searchParams.set("categoryIds", params.categoryIds.join(","));
}
if (params.includeUncategorized) {
searchParams.set("includeUncategorized", "true");
}
if (params.search) searchParams.set("search", params.search);
if (params.isReconciled !== undefined && params.isReconciled !== "all") {
searchParams.set(
"isReconciled",
params.isReconciled === true ? "true" : "false",
);
}
if (params.sortField) searchParams.set("sortField", params.sortField);
if (params.sortOrder) searchParams.set("sortOrder", params.sortOrder);
const response = await fetch(`/api/banking/transactions?${searchParams}`);
if (!response.ok) {
throw new Error("Failed to fetch transactions");
}
return response.json();
},
enabled,
staleTime: 30 * 1000, // 30 seconds
});
const invalidate = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["transactions"] });
}, [queryClient]);
return {
...query,
invalidate,
};
}
export function useCategoryStats() {
return useQuery({
queryKey: ["category-stats"],
queryFn: async (): Promise<Record<string, { count: number; total: number }>> => {
const response = await fetch("/api/banking/categories?statsOnly=true");
if (!response.ok) {
throw new Error("Failed to fetch category stats");
}
return response.json();
},
staleTime: 60 * 1000, // 1 minute
});
}
export function useBankingMetadata() {
return useQuery({
queryKey: ["banking-metadata"],
queryFn: async () => {
const response = await fetch("/api/banking?metadataOnly=true");
if (!response.ok) {
throw new Error("Failed to fetch banking metadata");
}
return response.json();
},
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
export function useAccountsWithStats() {
return useQuery({
queryKey: ["accounts-with-stats"],
queryFn: async () => {
const response = await fetch("/api/banking/accounts?withStats=true");
if (!response.ok) {
throw new Error("Failed to fetch accounts with stats");
}
return response.json() as Promise<
Array<Account & { transactionCount: number }>
>;
},
staleTime: 60 * 1000, // 1 minute
});
}

View File

@@ -51,6 +51,7 @@
"@radix-ui/react-toggle": "1.1.1", "@radix-ui/react-toggle": "1.1.1",
"@radix-ui/react-toggle-group": "1.1.1", "@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.6", "@radix-ui/react-tooltip": "1.1.6",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-virtual": "^3.13.12", "@tanstack/react-virtual": "^3.13.12",
"@vercel/analytics": "1.3.1", "@vercel/analytics": "1.3.1",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",

6633
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -53,6 +53,8 @@ model Transaction {
@@index([accountId]) @@index([accountId])
@@index([categoryId]) @@index([categoryId])
@@index([date]) @@index([date])
@@index([accountId, date])
@@index([isReconciled])
} }
model Folder { model Folder {

View File

@@ -6,6 +6,27 @@ import type {
Folder, Folder,
Category, Category,
} from "@/lib/types"; } 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 = { export const bankingService = {
async getAllData(): Promise<BankingData> { async getAllData(): Promise<BankingData> {
@@ -16,9 +37,19 @@ export const bankingService = {
}, },
}), }),
prisma.transaction.findMany({ prisma.transaction.findMany({
include: { // Removed includes - not needed for transformation, only use direct fields
account: true, select: {
category: true, 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(), 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,
}),
);
},
}; };