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

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo } from "react";
import { useState, useMemo, useEffect, useCallback } from "react";
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
import {
CategoryCard,
@@ -8,7 +8,8 @@ import {
ParentCategoryRow,
CategorySearchBar,
} 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 {
Dialog,
@@ -34,11 +35,13 @@ interface RecategorizationResult {
}
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 [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [expandedParents, setExpandedParents] = useState<Set<string>>(
new Set(),
new Set()
);
const [formData, setFormData] = useState({
name: "",
@@ -49,7 +52,7 @@ export default function CategoriesPage() {
});
const [searchQuery, setSearchQuery] = useState("");
const [recatResults, setRecatResults] = useState<RecategorizationResult[]>(
[],
[]
);
const [isRecatDialogOpen, setIsRecatDialogOpen] = useState(false);
const [isRecategorizing, setIsRecategorizing] = useState(false);
@@ -57,21 +60,25 @@ export default function CategoriesPage() {
// Organiser les catégories par parent
const { parentCategories, childrenByParent, orphanCategories } =
useMemo(() => {
if (!data?.categories)
if (!metadata?.categories)
return {
parentCategories: [],
childrenByParent: {},
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 orphans: Category[] = [];
data.categories
.filter((c) => c.parentId !== null)
.forEach((child) => {
const parentExists = parents.some((p) => p.id === child.parentId);
metadata.categories
.filter((c: Category) => c.parentId !== null)
.forEach((child: Category) => {
const parentExists = parents.some(
(p: Category) => p.id === child.parentId
);
if (parentExists) {
if (!children[child.parentId!]) {
children[child.parentId!] = [];
@@ -87,16 +94,52 @@ export default function CategoriesPage() {
childrenByParent: children,
orphanCategories: orphans,
};
}, [data?.categories]);
}, [metadata?.categories]);
// Initialiser tous les parents comme ouverts
useState(() => {
useEffect(() => {
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(() => {
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
queryClient.invalidateQueries({ queryKey: ["category-stats"] });
}, [queryClient]);
const getCategoryStats = useCallback(
(categoryId: string, includeChildren = false) => {
if (!categoryStats) return { total: 0, count: 0 };
let categoryIds = [categoryId];
if (includeChildren && childrenByParent[categoryId]) {
categoryIds = [
...categoryIds,
...childrenByParent[categoryId].map((c) => c.id),
];
}
// Sum stats from all category IDs
let total = 0;
let count = 0;
categoryIds.forEach((id) => {
const stats = categoryStats[id];
if (stats) {
total += stats.total;
count += stats.count;
}
});
return { total, count };
},
[categoryStats, childrenByParent]
);
if (isLoadingMetadata || !metadata || isLoadingStats || !categoryStats) {
return <LoadingState />;
}
@@ -107,27 +150,6 @@ export default function CategoriesPage() {
}).format(amount);
};
const getCategoryStats = (categoryId: string, includeChildren = false) => {
let categoryIds = [categoryId];
if (includeChildren && childrenByParent[categoryId]) {
categoryIds = [
...categoryIds,
...childrenByParent[categoryId].map((c) => c.id),
];
}
const categoryTransactions = data.transactions.filter((t) =>
categoryIds.includes(t.categoryId || ""),
);
const total = categoryTransactions.reduce(
(sum, t) => sum + Math.abs(t.amount),
0,
);
const count = categoryTransactions.length;
return { total, count };
};
const toggleExpanded = (parentId: string) => {
const newExpanded = new Set(expandedParents);
if (newExpanded.has(parentId)) {
@@ -139,7 +161,7 @@ export default function CategoriesPage() {
};
const expandAll = () => {
setExpandedParents(new Set(parentCategories.map((p) => p.id)));
setExpandedParents(new Set(parentCategories.map((p: Category) => p.id)));
};
const collapseAll = () => {
@@ -224,16 +246,27 @@ export default function CategoriesPage() {
const results: RecategorizationResult[] = [];
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 uncategorized = data.transactions.filter((t) => !t.categoryId);
for (const transaction of uncategorized) {
const categoryId = autoCategorize(
transaction.description + " " + (transaction.memo || ""),
data.categories,
metadata.categories
);
if (categoryId) {
const category = data.categories.find((c) => c.id === categoryId);
const category = metadata.categories.find(
(c: Category) => c.id === categoryId
);
if (category) {
results.push({ transaction, category });
await updateTransaction({ ...transaction, categoryId });
@@ -252,30 +285,30 @@ export default function CategoriesPage() {
}
};
const uncategorizedCount = data.transactions.filter(
(t) => !t.categoryId,
).length;
const uncategorizedCount = categoryStats["uncategorized"]?.count || 0;
// Filtrer les catégories selon la recherche
const filteredParentCategories = parentCategories.filter((parent) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
if (parent.name.toLowerCase().includes(query)) return true;
if (parent.keywords.some((k) => k.toLowerCase().includes(query)))
return true;
const children = childrenByParent[parent.id] || [];
return children.some(
(c) =>
c.name.toLowerCase().includes(query) ||
c.keywords.some((k) => k.toLowerCase().includes(query)),
);
});
const filteredParentCategories = parentCategories.filter(
(parent: Category) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
if (parent.name.toLowerCase().includes(query)) return true;
if (parent.keywords.some((k: string) => k.toLowerCase().includes(query)))
return true;
const children = childrenByParent[parent.id] || [];
return children.some(
(c) =>
c.name.toLowerCase().includes(query) ||
c.keywords.some((k) => k.toLowerCase().includes(query))
);
}
);
return (
<PageLayout>
<PageHeader
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={
<>
{uncategorizedCount > 0 && (
@@ -306,16 +339,16 @@ export default function CategoriesPage() {
/>
<div className="space-y-1">
{filteredParentCategories.map((parent) => {
{filteredParentCategories.map((parent: Category) => {
const allChildren = childrenByParent[parent.id] || [];
const children = searchQuery.trim()
? allChildren.filter(
(c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
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;
const stats = getCategoryStats(parent.id, true);
@@ -402,7 +435,7 @@ export default function CategoriesPage() {
</p>
<p className="text-xs text-muted-foreground truncate">
{new Date(result.transaction.date).toLocaleDateString(
"fr-FR",
"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 "./globals.css";
import { AuthSessionProvider } from "@/components/providers/session-provider";
import { QueryProvider } from "@/components/providers/query-provider";
const _geist = Geist({ subsets: ["latin"] });
const _geistMono = Geist_Mono({ subsets: ["latin"] });
@@ -22,7 +23,9 @@ export default function RootLayout({
return (
<html lang="fr">
<body className="font-sans antialiased">
<AuthSessionProvider>{children}</AuthSessionProvider>
<QueryProvider>
<AuthSessionProvider>{children}</AuthSessionProvider>
</QueryProvider>
</body>
</html>
);

View File

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

View File

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

View File

@@ -10,11 +10,13 @@ import {
} from "@/components/transactions";
import { RuleCreateDialog } from "@/components/rules";
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
import { useBankingData } from "@/lib/hooks";
import { updateCategory, updateTransaction } from "@/lib/store-db";
import { useBankingMetadata, useTransactions } from "@/lib/hooks";
import { updateCategory } from "@/lib/store-db";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Upload } from "lucide-react";
import type { Transaction } from "@/lib/types";
import type { TransactionsPaginatedParams } from "@/services/banking.service";
import {
normalizeDescription,
suggestKeyword,
@@ -24,16 +26,31 @@ type SortField = "date" | "amount" | "description";
type SortOrder = "asc" | "desc";
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
const PAGE_SIZE = 100;
export default function TransactionsPage() {
const searchParams = useSearchParams();
const { data, isLoading, refresh, update } = useBankingData();
const queryClient = useQueryClient();
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
const [searchQuery, setSearchQuery] = useState("");
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
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(() => {
const accountId = searchParams.get("accountId");
if (accountId) {
setSelectedAccounts([accountId]);
setPage(0);
}
}, [searchParams]);
@@ -43,20 +60,20 @@ export default function TransactionsPage() {
const [showReconciled, setShowReconciled] = useState<string>("all");
const [period, setPeriod] = useState<Period>("all");
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
undefined,
undefined
);
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(
undefined,
undefined
);
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
const [sortField, setSortField] = useState<SortField>("date");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
new Set(),
new Set()
);
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(
null,
null
);
// Get start date based on period
@@ -86,225 +103,106 @@ export default function TransactionsPage() {
return undefined;
}, [period, customEndDate]);
// Transactions filtered for account filter (by categories, search, reconciled, period - not accounts)
const transactionsForAccountFilter = useMemo(() => {
if (!data) return [];
// Build transaction query params
const transactionParams = useMemo(() => {
const params: TransactionsPaginatedParams = {
limit: PAGE_SIZE,
offset: page * PAGE_SIZE,
sortField,
sortOrder,
};
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 (startDate && period !== "all") {
params.startDate = startDate.toISOString().split("T")[0];
}
if (endDate) {
params.endDate = endDate.toISOString().split("T")[0];
}
if (!selectedAccounts.includes("all")) {
params.accountIds = selectedAccounts;
}
if (!selectedCategories.includes("all")) {
if (selectedCategories.includes("uncategorized")) {
transactions = transactions.filter((t) => !t.categoryId);
params.includeUncategorized = true;
} else {
transactions = transactions.filter(
(t) => t.categoryId && selectedCategories.includes(t.categoryId),
);
params.categoryIds = selectedCategories;
}
}
if (debouncedSearchQuery) {
params.search = debouncedSearchQuery;
}
if (showReconciled !== "all") {
const isReconciled = showReconciled === "reconciled";
transactions = transactions.filter(
(t) => t.isReconciled === isReconciled,
);
params.isReconciled = showReconciled === "reconciled";
}
return transactions;
return params;
}, [
data,
searchQuery,
selectedCategories,
showReconciled,
period,
page,
startDate,
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,
selectedCategories,
debouncedSearchQuery,
showReconciled,
sortField,
sortOrder,
period,
]);
// Fetch transactions with pagination
const {
data: transactionsData,
isLoading: isLoadingTransactions,
invalidate: invalidateTransactions,
} = useTransactions(transactionParams, !!metadata);
// Reset page when filters change
useEffect(() => {
setPage(0);
}, [
startDate,
endDate,
selectedAccounts,
selectedCategories,
debouncedSearchQuery,
showReconciled,
sortField,
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) => {
setRuleTransaction(transaction);
setRuleDialogOpen(true);
}, []);
// Create a virtual group for the rule dialog based on selected transaction
// Note: This requires fetching similar transactions - simplified for now
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 similarTransactions = data.transactions.filter(
(t) => normalizeDescription(t.description) === normalizedDesc,
const similarTransactions = transactionsData.transactions.filter(
(t) => normalizeDescription(t.description) === normalizedDesc
);
if (similarTransactions.length === 0) return null;
return {
key: normalizedDesc,
displayName: ruleTransaction.description,
transactions: similarTransactions,
totalAmount: similarTransactions.reduce((sum, t) => sum + t.amount, 0),
suggestedKeyword: suggestKeyword(
similarTransactions.map((t) => t.description),
similarTransactions.map((t) => t.description)
),
};
}, [ruleTransaction, data]);
}, [ruleTransaction, transactionsData]);
const handleSaveRule = useCallback(
async (ruleData: {
@@ -313,11 +211,11 @@ export default function TransactionsPage() {
applyToExisting: boolean;
transactionIds: string[];
}) => {
if (!data) return;
if (!metadata) return;
// 1. Add keyword to category
const category = data.categories.find(
(c) => c.id === ruleData.categoryId,
const category = metadata.categories.find(
(c: { id: string }) => c.id === ruleData.categoryId
);
if (!category) {
throw new Error("Category not found");
@@ -325,7 +223,7 @@ export default function TransactionsPage() {
// Check if keyword already exists
const keywordExists = category.keywords.some(
(k) => k.toLowerCase() === ruleData.keyword.toLowerCase(),
(k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase()
);
if (!keywordExists) {
@@ -337,24 +235,31 @@ export default function TransactionsPage() {
// 2. Apply to existing transactions if requested
if (ruleData.applyToExisting) {
const transactions = data.transactions.filter((t) =>
ruleData.transactionIds.includes(t.id),
);
await Promise.all(
transactions.map((t) =>
updateTransaction({ ...t, categoryId: ruleData.categoryId }),
),
ruleData.transactionIds.map((id) =>
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);
},
[data, refresh],
[metadata, queryClient]
);
if (isLoading || !data) {
const invalidateAll = useCallback(() => {
invalidateTransactions();
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
}, [invalidateTransactions, queryClient]);
if (isLoadingMetadata || !metadata) {
return <LoadingState />;
}
@@ -374,7 +279,11 @@ export default function TransactionsPage() {
};
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;
const updatedTransaction = {
@@ -382,84 +291,75 @@ export default function TransactionsPage() {
isReconciled: !transaction.isReconciled,
};
const updatedTransactions = data.transactions.map((t) =>
t.id === transactionId ? updatedTransaction : t,
);
update({ ...data, transactions: updatedTransactions });
try {
await fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedTransaction),
});
invalidateTransactions();
} catch (error) {
console.error("Failed to update transaction:", error);
refresh();
}
};
const markReconciled = async (transactionId: string) => {
const transaction = data.transactions.find((t) => t.id === transactionId);
if (!transaction || transaction.isReconciled) return; // Skip if already reconciled
if (!transactionsData) return;
const transaction = transactionsData.transactions.find(
(t) => t.id === transactionId
);
if (!transaction || transaction.isReconciled) return;
const updatedTransaction = {
...transaction,
isReconciled: true,
};
const updatedTransactions = data.transactions.map((t) =>
t.id === transactionId ? updatedTransaction : t,
);
update({ ...data, transactions: updatedTransactions });
try {
await fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedTransaction),
});
invalidateTransactions();
} catch (error) {
console.error("Failed to update transaction:", error);
refresh();
}
};
const setCategory = async (
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;
const updatedTransaction = { ...transaction, categoryId };
const updatedTransactions = data.transactions.map((t) =>
t.id === transactionId ? updatedTransaction : t,
);
update({ ...data, transactions: updatedTransactions });
try {
await fetch("/api/banking/transactions", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedTransaction),
});
invalidateTransactions();
} catch (error) {
console.error("Failed to update transaction:", error);
refresh();
}
};
const bulkReconcile = async (reconciled: boolean) => {
const transactionsToUpdate = data.transactions.filter((t) =>
selectedTransactions.has(t.id),
if (!transactionsData) return;
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());
try {
@@ -469,24 +369,22 @@ export default function TransactionsPage() {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...t, isReconciled: reconciled }),
}),
),
})
)
);
invalidateTransactions();
} catch (error) {
console.error("Failed to update transactions:", error);
refresh();
}
};
const bulkSetCategory = async (categoryId: string | null) => {
const transactionsToUpdate = data.transactions.filter((t) =>
selectedTransactions.has(t.id),
if (!transactionsData) return;
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());
try {
@@ -496,20 +394,23 @@ export default function TransactionsPage() {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...t, categoryId }),
}),
),
})
)
);
invalidateTransactions();
} catch (error) {
console.error("Failed to update transactions:", error);
refresh();
}
};
const toggleSelectAll = () => {
if (selectedTransactions.size === filteredTransactions.length) {
if (!transactionsData) return;
if (selectedTransactions.size === transactionsData.transactions.length) {
setSelectedTransactions(new Set());
} 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);
setSortOrder(field === "date" ? "desc" : "asc");
}
setPage(0);
};
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
const newSelected = new Set(selectedTransactions);
newSelected.delete(transactionId);
@@ -549,22 +445,26 @@ export default function TransactionsPage() {
`/api/banking/transactions?id=${transactionId}`,
{
method: "DELETE",
},
}
);
if (!response.ok) throw new Error("Failed to delete transaction");
invalidateTransactions();
} catch (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 (
<PageLayout>
<PageHeader
title="Transactions"
description={`${filteredTransactions.length} transaction${filteredTransactions.length > 1 ? "s" : ""}`}
description={`${totalTransactions} transaction${totalTransactions > 1 ? "s" : ""}`}
actions={
<OFXImportDialog onImportComplete={refresh}>
<OFXImportDialog onImportComplete={invalidateAll}>
<Button>
<Upload className="w-4 h-4 mr-2" />
Importer OFX
@@ -577,14 +477,24 @@ export default function TransactionsPage() {
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
selectedAccounts={selectedAccounts}
onAccountsChange={setSelectedAccounts}
onAccountsChange={(accounts) => {
setSelectedAccounts(accounts);
setPage(0);
}}
selectedCategories={selectedCategories}
onCategoriesChange={setSelectedCategories}
onCategoriesChange={(categories) => {
setSelectedCategories(categories);
setPage(0);
}}
showReconciled={showReconciled}
onReconciledChange={setShowReconciled}
onReconciledChange={(value) => {
setShowReconciled(value);
setPage(0);
}}
period={period}
onPeriodChange={(p) => {
setPeriod(p);
setPage(0);
if (p !== "custom") {
setIsCustomDatePickerOpen(false);
} else {
@@ -593,48 +503,89 @@ export default function TransactionsPage() {
}}
customStartDate={customStartDate}
customEndDate={customEndDate}
onCustomStartDateChange={setCustomStartDate}
onCustomEndDateChange={setCustomEndDate}
onCustomStartDateChange={(date) => {
setCustomStartDate(date);
setPage(0);
}}
onCustomEndDateChange={(date) => {
setCustomEndDate(date);
setPage(0);
}}
isCustomDatePickerOpen={isCustomDatePickerOpen}
onCustomDatePickerOpenChange={setIsCustomDatePickerOpen}
accounts={data.accounts}
folders={data.folders}
categories={data.categories}
accounts={metadata.accounts}
folders={metadata.folders}
categories={metadata.categories}
transactionsForAccountFilter={transactionsForAccountFilter}
transactionsForCategoryFilter={transactionsForCategoryFilter}
/>
<TransactionBulkActions
selectedCount={selectedTransactions.size}
categories={data.categories}
categories={metadata.categories}
onReconcile={bulkReconcile}
onSetCategory={bulkSetCategory}
/>
<TransactionTable
transactions={filteredTransactions}
accounts={data.accounts}
categories={data.categories}
selectedTransactions={selectedTransactions}
sortField={sortField}
sortOrder={sortOrder}
onSortChange={handleSortChange}
onToggleSelectAll={toggleSelectAll}
onToggleSelectTransaction={toggleSelectTransaction}
onToggleReconciled={toggleReconciled}
onMarkReconciled={markReconciled}
onSetCategory={setCategory}
onCreateRule={handleCreateRule}
onDelete={deleteTransaction}
formatCurrency={formatCurrency}
formatDate={formatDate}
/>
{isLoadingTransactions ? (
<LoadingState />
) : (
<>
<TransactionTable
transactions={filteredTransactions}
accounts={metadata.accounts}
categories={metadata.categories}
selectedTransactions={selectedTransactions}
sortField={sortField}
sortOrder={sortOrder}
onSortChange={handleSortChange}
onToggleSelectAll={toggleSelectAll}
onToggleSelectTransaction={toggleSelectTransaction}
onToggleReconciled={toggleReconciled}
onMarkReconciled={markReconciled}
onSetCategory={setCategory}
onCreateRule={handleCreateRule}
onDelete={deleteTransaction}
formatCurrency={formatCurrency}
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
open={ruleDialogOpen}
onOpenChange={setRuleDialogOpen}
group={ruleGroup}
categories={data.categories}
categories={metadata.categories}
onSave={handleSaveRule}
/>
</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";
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 type {
TransactionsPaginatedParams,
TransactionsPaginatedResult,
} from "@/services/banking.service";
export function useBankingData() {
const [data, setData] = useState<BankingData | null>(null);
@@ -75,3 +80,100 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
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-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.6",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-virtual": "^3.13.12",
"@vercel/analytics": "1.3.1",
"autoprefixer": "^10.4.20",

6731
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([categoryId])
@@index([date])
@@index([accountId, date])
@@index([isReconciled])
}
model Folder {

View File

@@ -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,
}),
);
},
};