diff --git a/app/categories/page.tsx b/app/categories/page.tsx index 7abbb05..ad6ee9c 100644 --- a/app/categories/page.tsx +++ b/app/categories/page.tsx @@ -1,44 +1,78 @@ "use client" -import { useState } from "react" +import { useState, useMemo } from "react" import { Sidebar } from "@/components/dashboard/sidebar" import { useBankingData } from "@/lib/hooks" import { Button } from "@/components/ui/button" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Badge } from "@/components/ui/badge" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" -import { Plus, MoreVertical, Pencil, Trash2, Tag, RefreshCw, X } from "lucide-react" -import { generateId, autoCategorize, addCategory, updateCategory, deleteCategory } from "@/lib/store-db" +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Plus, MoreVertical, Pencil, Trash2, RefreshCw, X, ChevronDown, ChevronRight } from "lucide-react" +import { CategoryIcon } from "@/components/ui/category-icon" +import { autoCategorize, addCategory, updateCategory, deleteCategory } from "@/lib/store-db" import type { Category } from "@/lib/types" import { cn } from "@/lib/utils" const categoryColors = [ - "#22c55e", - "#3b82f6", - "#f59e0b", - "#ec4899", - "#ef4444", - "#8b5cf6", - "#06b6d4", - "#84cc16", - "#f97316", - "#6366f1", + "#22c55e", "#3b82f6", "#f59e0b", "#ec4899", "#ef4444", + "#8b5cf6", "#06b6d4", "#84cc16", "#f97316", "#6366f1", + "#14b8a6", "#f43f5e", "#64748b", "#0891b2", "#dc2626", ] export default function CategoriesPage() { const { data, isLoading, refresh } = useBankingData() const [isDialogOpen, setIsDialogOpen] = useState(false) const [editingCategory, setEditingCategory] = useState(null) + const [expandedParents, setExpandedParents] = useState>(new Set()) const [formData, setFormData] = useState({ name: "", color: "#22c55e", keywords: [] as string[], + parentId: null as string | null, }) const [newKeyword, setNewKeyword] = useState("") + // Organiser les catégories par parent + const { parentCategories, childrenByParent, orphanCategories } = useMemo(() => { + if (!data?.categories) return { parentCategories: [], childrenByParent: {}, orphanCategories: [] } + + const parents = data.categories.filter((c) => c.parentId === null) + const children: Record = {} + const orphans: Category[] = [] + + // Grouper les enfants par parent + data.categories + .filter((c) => c.parentId !== null) + .forEach((child) => { + const parentExists = parents.some((p) => 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, + } + }, [data?.categories]) + + // Initialiser tous les parents comme ouverts + useState(() => { + if (parentCategories.length > 0 && expandedParents.size === 0) { + setExpandedParents(new Set(parentCategories.map((p) => p.id))) + } + }) + if (isLoading || !data) { return (
@@ -51,22 +85,35 @@ export default function CategoriesPage() { } const formatCurrency = (amount: number) => { - return new Intl.NumberFormat("fr-FR", { - style: "currency", - currency: "EUR", - }).format(amount) + return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(amount) } - const getCategoryStats = (categoryId: string) => { - const categoryTransactions = data.transactions.filter((t) => t.categoryId === categoryId) + 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 handleNewCategory = () => { + const toggleExpanded = (parentId: string) => { + const newExpanded = new Set(expandedParents) + if (newExpanded.has(parentId)) { + newExpanded.delete(parentId) + } else { + newExpanded.add(parentId) + } + setExpandedParents(newExpanded) + } + + const handleNewCategory = (parentId: string | null = null) => { setEditingCategory(null) - setFormData({ name: "", color: "#22c55e", keywords: [] }) + setFormData({ name: "", color: "#22c55e", keywords: [], parentId }) setIsDialogOpen(true) } @@ -76,6 +123,7 @@ export default function CategoriesPage() { name: category.name, color: category.color, keywords: [...category.keywords], + parentId: category.parentId, }) setIsDialogOpen(true) } @@ -88,6 +136,7 @@ export default function CategoriesPage() { name: formData.name, color: formData.color, keywords: formData.keywords, + parentId: formData.parentId, }) } else { await addCategory({ @@ -95,7 +144,7 @@ export default function CategoriesPage() { color: formData.color, keywords: formData.keywords, icon: "tag", - parentId: null, + parentId: formData.parentId, }) } refresh() @@ -107,7 +156,12 @@ export default function CategoriesPage() { } const handleDelete = async (categoryId: string) => { - if (!confirm("Supprimer cette catégorie ?")) return + 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) @@ -141,9 +195,12 @@ export default function CategoriesPage() { try { 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) + const categoryId = autoCategorize( + transaction.description + " " + (transaction.memo || ""), + data.categories + ) if (categoryId) { await updateTransaction({ ...transaction, categoryId }) } @@ -157,16 +214,59 @@ export default function CategoriesPage() { const uncategorizedCount = data.transactions.filter((t) => !t.categoryId).length + // Composant pour une carte de catégorie enfant + const ChildCategoryCard = ({ category }: { category: Category }) => { + const stats = getCategoryStats(category.id) + + return ( +
+
+
+ +
+ {category.name} + + {stats.count} opération{stats.count > 1 ? "s" : ""} • {formatCurrency(stats.total)} + + {category.keywords.length > 0 && ( + + {category.keywords.length} + + )} +
+ +
+ + +
+
+ ) + } + return (
+ {/* Header */}

Catégories

- Gérez vos catégories et mots-clés pour la catégorisation automatique + {parentCategories.length} catégories principales •{" "} + {data.categories.length - parentCategories.length} sous-catégories

@@ -175,82 +275,157 @@ export default function CategoriesPage() { Recatégoriser ({uncategorizedCount}) )} -
-
- {data.categories.map((category) => { - const stats = getCategoryStats(category.id) + {/* Liste des catégories par parent */} +
+ {parentCategories.map((parent) => { + const children = childrenByParent[parent.id] || [] + const stats = getCategoryStats(parent.id, true) + const isExpanded = expandedParents.has(parent.id) return ( - - -
-
-
+ toggleExpanded(parent.id)}> +
+ + + + +
+
-
- {category.name} -

- {stats.count} transaction{stats.count > 1 ? "s" : ""} -

-
+ + + + + + + + handleEdit(parent)}> + + Modifier + + handleDelete(parent.id)} + className="text-red-600" + > + + Supprimer + + +
- - - - - - handleEdit(category)}> - - Modifier - - handleDelete(category.id)} className="text-red-600"> - - Supprimer - - -
- - -
{formatCurrency(stats.total)}
-
- {category.keywords.slice(0, 5).map((keyword) => ( - - {keyword} - - ))} - {category.keywords.length > 5 && ( - - +{category.keywords.length - 5} - + + + {children.length > 0 ? ( +
+ {children.map((child) => ( + + ))} +
+ ) : ( +
+ Aucune sous-catégorie +
)} -
-
- + + +
) })} + + {/* Catégories orphelines (sans parent valide) */} + {orphanCategories.length > 0 && ( +
+
+ + Catégories non classées ({orphanCategories.length}) + +
+
+ {orphanCategories.map((category) => ( + + ))} +
+
+ )}
+ {/* Dialog de création/édition */} - {editingCategory ? "Modifier la catégorie" : "Nouvelle catégorie"} + + {editingCategory ? "Modifier la catégorie" : "Nouvelle catégorie"} +
+ {/* Catégorie parente */} +
+ + +
+ + {/* Nom */}
+ {/* Couleur */}
@@ -269,7 +445,7 @@ export default function CategoriesPage() { onClick={() => setFormData({ ...formData, color })} className={cn( "w-8 h-8 rounded-full transition-transform", - formData.color === color && "ring-2 ring-offset-2 ring-primary scale-110", + formData.color === color && "ring-2 ring-offset-2 ring-primary scale-110" )} style={{ backgroundColor: color }} /> @@ -277,6 +453,7 @@ export default function CategoriesPage() {
+ {/* Mots-clés */}
@@ -290,7 +467,7 @@ export default function CategoriesPage() {
-
+
{formData.keywords.map((keyword) => ( {keyword} @@ -302,6 +479,7 @@ export default function CategoriesPage() {
+ {/* Actions */}