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

@@ -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", {