feat: integrate React Query for improved data fetching and state management across banking and transactions components
This commit is contained in:
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user