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>