"use client"; import { useState, useMemo, useEffect, useCallback } from "react"; import { PageLayout, LoadingState, PageHeader } from "@/components/layout"; import { CategoryCard, CategoryEditDialog, ParentCategoryRow, CategorySearchBar, } from "@/components/categories"; import { useBankingMetadata, useCategoryStats } from "@/lib/hooks"; import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from "@/components/ui/dialog"; import { Badge } from "@/components/ui/badge"; import { CategoryIcon } from "@/components/ui/category-icon"; import { Plus, ArrowRight, CheckCircle2, RefreshCw } from "lucide-react"; import { autoCategorize, addCategory, updateCategory, deleteCategory, } from "@/lib/store-db"; import type { Category, Transaction } from "@/lib/types"; interface RecategorizationResult { transaction: Transaction; category: Category; } export default function CategoriesPage() { const queryClient = useQueryClient(); const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata(); const { data: categoryStats, isLoading: isLoadingStats } = useCategoryStats(); const [isDialogOpen, setIsDialogOpen] = useState(false); const [editingCategory, setEditingCategory] = useState(null); const [expandedParents, setExpandedParents] = useState>( new Set() ); const [formData, setFormData] = useState({ name: "", color: "#22c55e", icon: "tag", keywords: [] as string[], parentId: null as string | null, }); const [searchQuery, setSearchQuery] = useState(""); const [recatResults, setRecatResults] = useState( [] ); const [isRecatDialogOpen, setIsRecatDialogOpen] = useState(false); const [isRecategorizing, setIsRecategorizing] = useState(false); // Organiser les catégories par parent const { parentCategories, childrenByParent, orphanCategories } = useMemo(() => { if (!metadata?.categories) return { parentCategories: [], childrenByParent: {}, orphanCategories: [], }; const parents = metadata.categories.filter( (c: Category) => c.parentId === null ); const children: Record = {}; const orphans: Category[] = []; 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!] = []; } children[child.parentId!].push(child); } else { orphans.push(child); } }); return { parentCategories: parents, childrenByParent: children, orphanCategories: orphans, }; }, [metadata?.categories]); // Initialiser tous les parents comme ouverts useEffect(() => { if (parentCategories.length > 0 && expandedParents.size === 0) { setExpandedParents(new Set(parentCategories.map((p: Category) => p.id))); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [parentCategories.length]); 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 ; } const formatCurrency = (amount: number) => { return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", }).format(amount); }; const toggleExpanded = (parentId: string) => { const newExpanded = new Set(expandedParents); if (newExpanded.has(parentId)) { newExpanded.delete(parentId); } else { newExpanded.add(parentId); } setExpandedParents(newExpanded); }; const expandAll = () => { setExpandedParents(new Set(parentCategories.map((p: Category) => p.id))); }; const collapseAll = () => { setExpandedParents(new Set()); }; const allExpanded = parentCategories.length > 0 && expandedParents.size === parentCategories.length; const handleNewCategory = (parentId: string | null = null) => { setEditingCategory(null); setFormData({ name: "", color: "#22c55e", icon: "tag", keywords: [], parentId, }); setIsDialogOpen(true); }; const handleEdit = (category: Category) => { setEditingCategory(category); setFormData({ name: category.name, color: category.color, icon: category.icon, keywords: [...category.keywords], parentId: category.parentId, }); setIsDialogOpen(true); }; const handleSave = async () => { try { if (editingCategory) { await updateCategory({ ...editingCategory, name: formData.name, color: formData.color, icon: formData.icon, keywords: formData.keywords, parentId: formData.parentId, }); } else { await addCategory({ name: formData.name, color: formData.color, icon: formData.icon, keywords: formData.keywords, parentId: formData.parentId, }); } refresh(); setIsDialogOpen(false); } catch (error) { console.error("Error saving category:", error); alert("Erreur lors de la sauvegarde de la catégorie"); } }; const handleDelete = async (categoryId: string) => { const hasChildren = childrenByParent[categoryId]?.length > 0; const message = hasChildren ? "Cette catégorie a des sous-catégories. Supprimer quand même ?" : "Supprimer cette catégorie ?"; if (!confirm(message)) return; try { await deleteCategory(categoryId); refresh(); } catch (error) { console.error("Error deleting category:", error); alert("Erreur lors de la suppression de la catégorie"); } }; const reApplyAutoCategories = async () => { setIsRecategorizing(true); 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"); for (const transaction of uncategorized) { const categoryId = autoCategorize( transaction.description + " " + (transaction.memo || ""), metadata.categories ); if (categoryId) { const category = metadata.categories.find( (c: Category) => c.id === categoryId ); if (category) { results.push({ transaction, category }); await updateTransaction({ ...transaction, categoryId }); } } } setRecatResults(results); setIsRecatDialogOpen(true); refresh(); } catch (error) { console.error("Error re-categorizing:", error); alert("Erreur lors de la recatégorisation"); } finally { setIsRecategorizing(false); } }; const uncategorizedCount = categoryStats["uncategorized"]?.count || 0; // Filtrer les catégories selon la recherche 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 ( {uncategorizedCount > 0 && ( )} } />
{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()) ) || parent.name.toLowerCase().includes(searchQuery.toLowerCase()) ) : allChildren; const stats = getCategoryStats(parent.id, true); const isExpanded = expandedParents.has(parent.id) || (searchQuery.trim() !== "" && children.length > 0); return ( toggleExpanded(parent.id)} formatCurrency={formatCurrency} getCategoryStats={(id) => getCategoryStats(id)} onEdit={handleEdit} onDelete={handleDelete} onNewCategory={handleNewCategory} /> ); })} {orphanCategories.length > 0 && (
Catégories non classées ({orphanCategories.length})
{orphanCategories.map((category) => ( ))}
)}
{/* Dialog des résultats de recatégorisation */} Recatégorisation terminée {recatResults.length === 0 ? "Aucune transaction n'a pu être catégorisée automatiquement." : `${recatResults.length} transaction${recatResults.length > 1 ? "s" : ""} catégorisée${recatResults.length > 1 ? "s" : ""}`} {recatResults.length > 0 && (
{recatResults.map((result, index) => (

{result.transaction.description}

{new Date(result.transaction.date).toLocaleDateString( "fr-FR" )} {" • "} {new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR", }).format(result.transaction.amount)}

{result.category.name}
))}
)}
); }