feat: implement hierarchical category management with parent-child relationships and enhance category creation dialog

This commit is contained in:
Julien Froidefond
2025-11-27 10:29:59 +01:00
parent d7374e4129
commit 7314cb6716
9 changed files with 1048 additions and 260 deletions

View File

@@ -1,44 +1,78 @@
"use client" "use client"
import { useState } from "react" import { useState, useMemo } from "react"
import { Sidebar } from "@/components/dashboard/sidebar" import { Sidebar } from "@/components/dashboard/sidebar"
import { useBankingData } from "@/lib/hooks" import { useBankingData } from "@/lib/hooks"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Plus, MoreVertical, Pencil, Trash2, Tag, RefreshCw, X } from "lucide-react" import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"
import { generateId, autoCategorize, addCategory, updateCategory, deleteCategory } from "@/lib/store-db" 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 type { Category } from "@/lib/types"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const categoryColors = [ const categoryColors = [
"#22c55e", "#22c55e", "#3b82f6", "#f59e0b", "#ec4899", "#ef4444",
"#3b82f6", "#8b5cf6", "#06b6d4", "#84cc16", "#f97316", "#6366f1",
"#f59e0b", "#14b8a6", "#f43f5e", "#64748b", "#0891b2", "#dc2626",
"#ec4899",
"#ef4444",
"#8b5cf6",
"#06b6d4",
"#84cc16",
"#f97316",
"#6366f1",
] ]
export default function CategoriesPage() { export default function CategoriesPage() {
const { data, isLoading, refresh } = useBankingData() const { data, isLoading, refresh } = useBankingData()
const [isDialogOpen, setIsDialogOpen] = useState(false) const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingCategory, setEditingCategory] = useState<Category | null>(null) const [editingCategory, setEditingCategory] = useState<Category | null>(null)
const [expandedParents, setExpandedParents] = useState<Set<string>>(new Set())
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: "", name: "",
color: "#22c55e", color: "#22c55e",
keywords: [] as string[], keywords: [] as string[],
parentId: null as string | null,
}) })
const [newKeyword, setNewKeyword] = useState("") 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<string, Category[]> = {}
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) { if (isLoading || !data) {
return ( return (
<div className="flex h-screen"> <div className="flex h-screen">
@@ -51,22 +85,35 @@ export default function CategoriesPage() {
} }
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", { return new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(amount)
style: "currency",
currency: "EUR",
}).format(amount)
} }
const getCategoryStats = (categoryId: string) => { const getCategoryStats = (categoryId: string, includeChildren = false) => {
const categoryTransactions = data.transactions.filter((t) => t.categoryId === categoryId) 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 total = categoryTransactions.reduce((sum, t) => sum + Math.abs(t.amount), 0)
const count = categoryTransactions.length const count = categoryTransactions.length
return { total, count } 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) setEditingCategory(null)
setFormData({ name: "", color: "#22c55e", keywords: [] }) setFormData({ name: "", color: "#22c55e", keywords: [], parentId })
setIsDialogOpen(true) setIsDialogOpen(true)
} }
@@ -76,6 +123,7 @@ export default function CategoriesPage() {
name: category.name, name: category.name,
color: category.color, color: category.color,
keywords: [...category.keywords], keywords: [...category.keywords],
parentId: category.parentId,
}) })
setIsDialogOpen(true) setIsDialogOpen(true)
} }
@@ -88,6 +136,7 @@ export default function CategoriesPage() {
name: formData.name, name: formData.name,
color: formData.color, color: formData.color,
keywords: formData.keywords, keywords: formData.keywords,
parentId: formData.parentId,
}) })
} else { } else {
await addCategory({ await addCategory({
@@ -95,7 +144,7 @@ export default function CategoriesPage() {
color: formData.color, color: formData.color,
keywords: formData.keywords, keywords: formData.keywords,
icon: "tag", icon: "tag",
parentId: null, parentId: formData.parentId,
}) })
} }
refresh() refresh()
@@ -107,7 +156,12 @@ export default function CategoriesPage() {
} }
const handleDelete = async (categoryId: string) => { 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 { try {
await deleteCategory(categoryId) await deleteCategory(categoryId)
@@ -143,7 +197,10 @@ export default function CategoriesPage() {
const uncategorized = data.transactions.filter((t) => !t.categoryId) const uncategorized = data.transactions.filter((t) => !t.categoryId)
for (const transaction of uncategorized) { 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) { if (categoryId) {
await updateTransaction({ ...transaction, categoryId }) await updateTransaction({ ...transaction, categoryId })
} }
@@ -157,16 +214,59 @@ export default function CategoriesPage() {
const uncategorizedCount = data.transactions.filter((t) => !t.categoryId).length 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 (
<div className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-muted/50 transition-colors group">
<div className="flex items-center gap-2 min-w-0 flex-1">
<div
className="w-5 h-5 rounded-full flex items-center justify-center shrink-0"
style={{ backgroundColor: `${category.color}20` }}
>
<CategoryIcon icon={category.icon} color={category.color} size={12} />
</div>
<span className="text-sm truncate">{category.name}</span>
<span className="text-sm text-muted-foreground shrink-0">
{stats.count} opération{stats.count > 1 ? "s" : ""} {formatCurrency(stats.total)}
</span>
{category.keywords.length > 0 && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 shrink-0">
{category.keywords.length}
</Badge>
)}
</div>
<div className="flex items-center shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => handleEdit(category)}>
<Pencil className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() => handleDelete(category.id)}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
)
}
return ( return (
<div className="flex h-screen bg-background"> <div className="flex h-screen bg-background">
<Sidebar /> <Sidebar />
<main className="flex-1 overflow-auto"> <main className="flex-1 overflow-auto">
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Catégories</h1> <h1 className="text-2xl font-bold text-foreground">Catégories</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
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
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -175,82 +275,157 @@ export default function CategoriesPage() {
Recatégoriser ({uncategorizedCount}) Recatégoriser ({uncategorizedCount})
</Button> </Button>
)} )}
<Button onClick={handleNewCategory}> <Button onClick={() => handleNewCategory(null)}>
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Nouvelle catégorie Nouvelle catégorie
</Button> </Button>
</div> </div>
</div> </div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> {/* Liste des catégories par parent */}
{data.categories.map((category) => { <div className="space-y-1">
const stats = getCategoryStats(category.id) {parentCategories.map((parent) => {
const children = childrenByParent[parent.id] || []
const stats = getCategoryStats(parent.id, true)
const isExpanded = expandedParents.has(parent.id)
return ( return (
<Card key={category.id}> <div key={parent.id} className="border rounded-lg bg-card">
<CardHeader className="pb-2"> <Collapsible open={isExpanded} onOpenChange={() => toggleExpanded(parent.id)}>
<div className="flex items-start justify-between"> <div className="flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-3"> <CollapsibleTrigger asChild>
<div <button className="flex items-center gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0">
className="w-10 h-10 rounded-full flex items-center justify-center" {isExpanded ? (
style={{ backgroundColor: `${category.color}20` }} <ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
)}
<div
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
style={{ backgroundColor: `${parent.color}20` }}
>
<CategoryIcon icon={parent.icon} color={parent.color} size={14} />
</div>
<span className="font-medium text-sm truncate">{parent.name}</span>
<span className="text-sm text-muted-foreground shrink-0">
{children.length} {stats.count} opération{stats.count > 1 ? "s" : ""} {formatCurrency(stats.total)}
</span>
</button>
</CollapsibleTrigger>
<div className="flex items-center gap-1 shrink-0 ml-2">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation()
handleNewCategory(parent.id)
}}
> >
<Tag className="w-5 h-5" style={{ color: category.color }} /> <Plus className="w-4 h-4" />
</div> </Button>
<div> <DropdownMenu>
<CardTitle className="text-base">{category.name}</CardTitle> <DropdownMenuTrigger asChild>
<p className="text-xs text-muted-foreground"> <Button variant="ghost" size="icon" className="h-7 w-7">
{stats.count} transaction{stats.count > 1 ? "s" : ""} <MoreVertical className="w-4 h-4" />
</p> </Button>
</div> </DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(parent)}>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDelete(parent.id)}
className="text-red-600"
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(category)}>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(category.id)} className="text-red-600">
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</CardHeader>
<CardContent> <CollapsibleContent>
<div className="text-lg font-semibold mb-3">{formatCurrency(stats.total)}</div> {children.length > 0 ? (
<div className="flex flex-wrap gap-1"> <div className="px-3 pb-2 space-y-1 ml-6 border-l-2 border-muted ml-5">
{category.keywords.slice(0, 5).map((keyword) => ( {children.map((child) => (
<Badge key={keyword} variant="secondary" className="text-xs"> <ChildCategoryCard key={child.id} category={child} />
{keyword} ))}
</Badge> </div>
))} ) : (
{category.keywords.length > 5 && ( <div className="px-3 pb-2 ml-11 text-xs text-muted-foreground italic">
<Badge variant="secondary" className="text-xs"> Aucune sous-catégorie
+{category.keywords.length - 5} </div>
</Badge>
)} )}
</div> </CollapsibleContent>
</CardContent> </Collapsible>
</Card> </div>
) )
})} })}
{/* Catégories orphelines (sans parent valide) */}
{orphanCategories.length > 0 && (
<div className="border rounded-lg bg-card">
<div className="px-3 py-2 border-b">
<span className="text-sm font-medium text-muted-foreground">
Catégories non classées ({orphanCategories.length})
</span>
</div>
<div className="p-2 space-y-1">
{orphanCategories.map((category) => (
<ChildCategoryCard key={category.id} category={category} />
))}
</div>
</div>
)}
</div> </div>
</div> </div>
</main> </main>
{/* Dialog de création/édition */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle>{editingCategory ? "Modifier la catégorie" : "Nouvelle catégorie"}</DialogTitle> <DialogTitle>
{editingCategory ? "Modifier la catégorie" : "Nouvelle catégorie"}
</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{/* Catégorie parente */}
<div className="space-y-2">
<Label>Catégorie parente</Label>
<Select
value={formData.parentId || "none"}
onValueChange={(value) =>
setFormData({ ...formData, parentId: value === "none" ? null : value })
}
>
<SelectTrigger>
<SelectValue placeholder="Aucune (catégorie principale)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Aucune (catégorie principale)</SelectItem>
{parentCategories
.filter((p) => p.id !== editingCategory?.id)
.map((parent) => (
<SelectItem key={parent.id} value={parent.id}>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: parent.color }}
/>
{parent.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Nom */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Nom</Label> <Label>Nom</Label>
<Input <Input
@@ -260,6 +435,7 @@ export default function CategoriesPage() {
/> />
</div> </div>
{/* Couleur */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Couleur</Label> <Label>Couleur</Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -269,7 +445,7 @@ export default function CategoriesPage() {
onClick={() => setFormData({ ...formData, color })} onClick={() => setFormData({ ...formData, color })}
className={cn( className={cn(
"w-8 h-8 rounded-full transition-transform", "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 }} style={{ backgroundColor: color }}
/> />
@@ -277,6 +453,7 @@ export default function CategoriesPage() {
</div> </div>
</div> </div>
{/* Mots-clés */}
<div className="space-y-2"> <div className="space-y-2">
<Label>Mots-clés pour la catégorisation automatique</Label> <Label>Mots-clés pour la catégorisation automatique</Label>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -290,7 +467,7 @@ export default function CategoriesPage() {
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
</Button> </Button>
</div> </div>
<div className="flex flex-wrap gap-1 mt-2"> <div className="flex flex-wrap gap-1 mt-2 max-h-32 overflow-y-auto">
{formData.keywords.map((keyword) => ( {formData.keywords.map((keyword) => (
<Badge key={keyword} variant="secondary" className="gap-1"> <Badge key={keyword} variant="secondary" className="gap-1">
{keyword} {keyword}
@@ -302,6 +479,7 @@ export default function CategoriesPage() {
</div> </div>
</div> </div>
{/* Actions */}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsDialogOpen(false)}> <Button variant="outline" onClick={() => setIsDialogOpen(false)}>
Annuler Annuler

View File

@@ -6,6 +6,7 @@ import { useBankingData } from "@/lib/hooks"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { RefreshCw, TrendingUp, TrendingDown, ArrowRight } from "lucide-react" import { RefreshCw, TrendingUp, TrendingDown, ArrowRight } from "lucide-react"
import { CategoryIcon } from "@/components/ui/category-icon"
import { import {
BarChart, BarChart,
Bar, Bar,
@@ -385,9 +386,10 @@ export default function StatisticsPage() {
</span> </span>
{category && ( {category && (
<span <span
className="text-xs px-1.5 py-0.5 rounded" className="text-xs px-1.5 py-0.5 rounded inline-flex items-center gap-1"
style={{ backgroundColor: `${category.color}20`, color: category.color }} style={{ backgroundColor: `${category.color}20`, color: category.color }}
> >
<CategoryIcon icon={category.icon} color={category.color} size={10} />
{category.name} {category.name}
</span> </span>
)} )}

View File

@@ -17,6 +17,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { OFXImportDialog } from "@/components/import/ofx-import-dialog" import { OFXImportDialog } from "@/components/import/ofx-import-dialog"
import { CategoryIcon } from "@/components/ui/category-icon"
import { Search, CheckCircle2, Circle, MoreVertical, Tags, Upload, RefreshCw, ArrowUpDown, Check } from "lucide-react" import { Search, CheckCircle2, Circle, MoreVertical, Tags, Upload, RefreshCw, ArrowUpDown, Check } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -274,7 +275,7 @@ export default function TransactionsPage() {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{data.categories.map((cat) => ( {data.categories.map((cat) => (
<DropdownMenuItem key={cat.id} onClick={() => bulkSetCategory(cat.id)}> <DropdownMenuItem key={cat.id} onClick={() => bulkSetCategory(cat.id)}>
<div className="w-3 h-3 rounded-full mr-2" style={{ backgroundColor: cat.color }} /> <CategoryIcon icon={cat.icon} color={cat.color} size={14} className="mr-2" />
{cat.name} {cat.name}
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
@@ -392,11 +393,13 @@ export default function TransactionsPage() {
{category ? ( {category ? (
<Badge <Badge
variant="secondary" variant="secondary"
className="gap-1"
style={{ style={{
backgroundColor: `${category.color}20`, backgroundColor: `${category.color}20`,
color: category.color, color: category.color,
}} }}
> >
<CategoryIcon icon={category.icon} color={category.color} size={12} />
{category.name} {category.name}
</Badge> </Badge>
) : ( ) : (
@@ -413,10 +416,7 @@ export default function TransactionsPage() {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{data.categories.map((cat) => ( {data.categories.map((cat) => (
<DropdownMenuItem key={cat.id} onClick={() => setCategory(transaction.id, cat.id)}> <DropdownMenuItem key={cat.id} onClick={() => setCategory(transaction.id, cat.id)}>
<div <CategoryIcon icon={cat.icon} color={cat.color} size={14} className="mr-2" />
className="w-3 h-3 rounded-full mr-2"
style={{ backgroundColor: cat.color }}
/>
{cat.name} {cat.name}
{transaction.categoryId === cat.id && <Check className="w-4 h-4 ml-auto" />} {transaction.categoryId === cat.id && <Check className="w-4 h-4 ml-auto" />}
</DropdownMenuItem> </DropdownMenuItem>

View File

@@ -3,6 +3,7 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { CheckCircle2, Circle } from "lucide-react" import { CheckCircle2, Circle } from "lucide-react"
import { CategoryIcon } from "@/components/ui/category-icon"
import type { BankingData } from "@/lib/types" import type { BankingData } from "@/lib/types"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -86,9 +87,10 @@ export function RecentTransactions({ data }: RecentTransactionsProps) {
{category && ( {category && (
<Badge <Badge
variant="secondary" variant="secondary"
className="text-xs" className="text-xs gap-1"
style={{ backgroundColor: `${category.color}20`, color: category.color }} style={{ backgroundColor: `${category.color}20`, color: category.color }}
> >
<CategoryIcon icon={category.icon} color={category.color} size={12} />
{category.name} {category.name}
</Badge> </Badge>
)} )}

View File

@@ -0,0 +1,143 @@
"use client"
import {
ShoppingCart,
Utensils,
Croissant,
Fuel,
Train,
Car,
SquareParking,
Bike,
Plane,
Home,
Zap,
Droplet,
Hammer,
Sofa,
Pill,
Stethoscope,
Hospital,
Glasses,
Dumbbell,
Sparkles,
Tv,
Music,
Film,
Gamepad,
Book,
Ticket,
Shirt,
Smartphone,
Package,
Wifi,
Repeat,
Landmark,
Shield,
HeartPulse,
Receipt,
PiggyBank,
Banknote,
Wallet,
HandCoins,
Undo,
Coins,
Bed,
Luggage,
GraduationCap,
Baby,
PawPrint,
Wrench,
HeartHandshake,
Gift,
Cigarette,
ArrowRightLeft,
HelpCircle,
Tag,
Folder,
Key,
Refrigerator,
type LucideIcon,
} from "lucide-react"
// Map icon names to Lucide components
const iconMap: Record<string, LucideIcon> = {
"shopping-cart": ShoppingCart,
"utensils": Utensils,
"croissant": Croissant,
"fuel": Fuel,
"train": Train,
"car": Car,
"car-taxi": Car, // Using Car as fallback for car-taxi
"car-key": Key, // Using Key as fallback
"parking": SquareParking,
"bike": Bike,
"plane": Plane,
"home": Home,
"zap": Zap,
"droplet": Droplet,
"hammer": Hammer,
"sofa": Sofa,
"refrigerator": Refrigerator,
"pill": Pill,
"stethoscope": Stethoscope,
"hospital": Hospital,
"glasses": Glasses,
"dumbbell": Dumbbell,
"sparkles": Sparkles,
"tv": Tv,
"music": Music,
"film": Film,
"gamepad": Gamepad,
"book": Book,
"ticket": Ticket,
"shirt": Shirt,
"smartphone": Smartphone,
"package": Package,
"wifi": Wifi,
"repeat": Repeat,
"landmark": Landmark,
"shield": Shield,
"heart-pulse": HeartPulse,
"receipt": Receipt,
"piggy-bank": PiggyBank,
"banknote": Banknote,
"wallet": Wallet,
"hand-coins": HandCoins,
"undo": Undo,
"coins": Coins,
"bed": Bed,
"luggage": Luggage,
"graduation-cap": GraduationCap,
"baby": Baby,
"paw-print": PawPrint,
"wrench": Wrench,
"heart-handshake": HeartHandshake,
"gift": Gift,
"cigarette": Cigarette,
"arrow-right-left": ArrowRightLeft,
"help-circle": HelpCircle,
"tag": Tag,
"folder": Folder,
}
// Get all available icon names
export const availableIcons = Object.keys(iconMap)
// Get the icon component by name
export function getIconComponent(iconName: string): LucideIcon {
return iconMap[iconName] || Tag
}
interface CategoryIconProps {
icon: string
color?: string
className?: string
size?: number
}
export function CategoryIcon({ icon, color, className, size = 20 }: CategoryIconProps) {
const IconComponent = getIconComponent(icon)
return <IconComponent className={className} style={{ color }} size={size} />
}

View File

@@ -1,11 +1,33 @@
import type { Category, CategoryRule, Folder } from "./types" import type { CategoryRule } from "./types"
export const defaultCategories: Omit<Category, "id">[] = [ // ═══════════════════════════════════════════════════════════════════════════
// STRUCTURE HIÉRARCHIQUE DES CATÉGORIES
// ═══════════════════════════════════════════════════════════════════════════
export interface CategoryDefinition {
slug: string // Identifiant unique pour le référencement parent/enfant
name: string
color: string
icon: string
keywords: string[]
parentSlug: string | null // Référence au slug du parent
}
export const defaultCategories: CategoryDefinition[] = [
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// ALIMENTATION & COURSES // 🛒 ALIMENTATION (Parent)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
{ {
name: "Alimentation", slug: "alimentation",
name: "🛒 Alimentation",
color: "#22c55e",
icon: "utensils",
keywords: [],
parentSlug: null,
},
{
slug: "alimentation-courses",
name: "Courses & Supermarchés",
color: "#22c55e", color: "#22c55e",
icon: "shopping-cart", icon: "shopping-cart",
keywords: [ keywords: [
@@ -23,9 +45,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
// Générique // Générique
"supermarche", "hypermarche", "epicerie", "alimentation", "courses", "supermarche", "hypermarche", "epicerie", "alimentation", "courses",
], ],
parentId: null, parentSlug: "alimentation",
}, },
{ {
slug: "alimentation-restaurants",
name: "Restaurants & Bars", name: "Restaurants & Bars",
color: "#f97316", color: "#f97316",
icon: "utensils", icon: "utensils",
@@ -42,9 +65,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"restaurant", "brasserie", "bistrot", "cafe", "bar", "pub", "snack", "restaurant", "brasserie", "bistrot", "cafe", "bar", "pub", "snack",
"pizzeria", "traiteur", "cantine", "pizzeria", "traiteur", "cantine",
], ],
parentId: null, parentSlug: "alimentation",
}, },
{ {
slug: "alimentation-boulangerie",
name: "Boulangerie & Pâtisserie", name: "Boulangerie & Pâtisserie",
color: "#d97706", color: "#d97706",
icon: "croissant", icon: "croissant",
@@ -53,13 +77,22 @@ export const defaultCategories: Omit<Category, "id">[] = [
"maison kayser", "eric kayser", "paul", "la mie caline", "marie blachere", "maison kayser", "eric kayser", "paul", "la mie caline", "marie blachere",
"ange", "feuillette", "le fournil", "au bon pain", "ange", "feuillette", "le fournil", "au bon pain",
], ],
parentId: null, parentSlug: "alimentation",
}, },
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// TRANSPORT & MOBILITÉ // 🚗 TRANSPORT (Parent)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
{ {
slug: "transport",
name: "🚗 Transport",
color: "#3b82f6",
icon: "car",
keywords: [],
parentSlug: null,
},
{
slug: "transport-carburant",
name: "Carburant", name: "Carburant",
color: "#64748b", color: "#64748b",
icon: "fuel", icon: "fuel",
@@ -72,9 +105,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"essence", "gasoil", "diesel", "carburant", "station service", "sp95", "sp98", "essence", "gasoil", "diesel", "carburant", "station service", "sp95", "sp98",
"sans plomb", "gazole", "sans plomb", "gazole",
], ],
parentId: null, parentSlug: "transport",
}, },
{ {
slug: "transport-commun",
name: "Transports en commun", name: "Transports en commun",
color: "#3b82f6", color: "#3b82f6",
icon: "train", icon: "train",
@@ -88,9 +122,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
// Générique // Générique
"transport", "titre transport", "abonnement transport", "transport", "titre transport", "abonnement transport",
], ],
parentId: null, parentSlug: "transport",
}, },
{ {
slug: "transport-vtc",
name: "VTC & Taxi", name: "VTC & Taxi",
color: "#1e3a8a", color: "#1e3a8a",
icon: "car-taxi", icon: "car-taxi",
@@ -98,9 +133,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"uber", "bolt", "kapten", "heetch", "freenow", "free now", "uber", "bolt", "kapten", "heetch", "freenow", "free now",
"taxi", "vtc", "chauffeur", "g7", "taxi bleu", "allocab", "taxi", "vtc", "chauffeur", "g7", "taxi bleu", "allocab",
], ],
parentId: null, parentSlug: "transport",
}, },
{ {
slug: "transport-parking",
name: "Parking & Péages", name: "Parking & Péages",
color: "#475569", color: "#475569",
icon: "parking", icon: "parking",
@@ -113,9 +149,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"cofiroute", "escota", "sapn", "asf", "atmb", "sftrf", "cofiroute", "escota", "sapn", "asf", "atmb", "sftrf",
"liber-t", "telepeage", "badge autoroute", "liber-t", "telepeage", "badge autoroute",
], ],
parentId: null, parentSlug: "transport",
}, },
{ {
slug: "transport-location",
name: "Location véhicule", name: "Location véhicule",
color: "#0891b2", color: "#0891b2",
icon: "car-key", icon: "car-key",
@@ -124,9 +161,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"ada", "rent a car", "getaround", "ouicar", "drivy", "ada", "rent a car", "getaround", "ouicar", "drivy",
"location voiture", "location vehicule", "location voiture", "location vehicule",
], ],
parentId: null, parentSlug: "transport",
}, },
{ {
slug: "transport-mobilite-douce",
name: "Mobilité douce", name: "Mobilité douce",
color: "#10b981", color: "#10b981",
icon: "bike", icon: "bike",
@@ -139,9 +177,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
// Vélo // Vélo
"decathlon cycle", "alltricks", "probikeshop", "velo", "cyclable", "decathlon cycle", "alltricks", "probikeshop", "velo", "cyclable",
], ],
parentId: null, parentSlug: "transport",
}, },
{ {
slug: "transport-avion",
name: "Avion", name: "Avion",
color: "#7c3aed", color: "#7c3aed",
icon: "plane", icon: "plane",
@@ -155,13 +194,22 @@ export const defaultCategories: Omit<Category, "id">[] = [
// Réservation // Réservation
"skyscanner", "kayak", "opodo", "liligo", "google flights", "skyscanner", "kayak", "opodo", "liligo", "google flights",
], ],
parentId: null, parentSlug: "transport",
}, },
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// LOGEMENT & MAISON // 🏠 LOGEMENT (Parent)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
{ {
slug: "logement",
name: "🏠 Logement",
color: "#f59e0b",
icon: "home",
keywords: [],
parentSlug: null,
},
{
slug: "logement-loyer",
name: "Loyer & Charges", name: "Loyer & Charges",
color: "#f59e0b", color: "#f59e0b",
icon: "home", icon: "home",
@@ -170,9 +218,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"foncia", "nexity", "orpi", "century 21", "laforet", "guy hoquet", "foncia", "nexity", "orpi", "century 21", "laforet", "guy hoquet",
"agence immobiliere", "bail", "quittance", "agence immobiliere", "bail", "quittance",
], ],
parentId: null, parentSlug: "logement",
}, },
{ {
slug: "logement-electricite",
name: "Électricité & Gaz", name: "Électricité & Gaz",
color: "#fbbf24", color: "#fbbf24",
icon: "zap", icon: "zap",
@@ -182,9 +231,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"electricite", "gaz", "energie", "compteur", "linky", "gazpar", "electricite", "gaz", "energie", "compteur", "linky", "gazpar",
"direct energie", "cdiscount energie", "sowee", "direct energie", "cdiscount energie", "sowee",
], ],
parentId: null, parentSlug: "logement",
}, },
{ {
slug: "logement-eau",
name: "Eau", name: "Eau",
color: "#06b6d4", color: "#06b6d4",
icon: "droplet", icon: "droplet",
@@ -192,9 +242,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"veolia", "suez", "saur", "eau de paris", "sedif", "veolia", "suez", "saur", "eau de paris", "sedif",
"eau", "facture eau", "compteur eau", "assainissement", "eau", "facture eau", "compteur eau", "assainissement",
], ],
parentId: null, parentSlug: "logement",
}, },
{ {
slug: "logement-bricolage",
name: "Bricolage & Jardinage", name: "Bricolage & Jardinage",
color: "#84cc16", color: "#84cc16",
icon: "hammer", icon: "hammer",
@@ -207,9 +258,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
// Générique // Générique
"bricolage", "outillage", "quincaillerie", "jardinage", "plante", "bricolage", "outillage", "quincaillerie", "jardinage", "plante",
], ],
parentId: null, parentSlug: "logement",
}, },
{ {
slug: "logement-ameublement",
name: "Ameublement & Déco", name: "Ameublement & Déco",
color: "#a855f7", color: "#a855f7",
icon: "sofa", icon: "sofa",
@@ -220,9 +272,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"meuble", "decoration", "deco", "literie", "matelas", "meuble", "decoration", "deco", "literie", "matelas",
"emma matelas", "tediber", "simba", "hypnia", "emma matelas", "tediber", "simba", "hypnia",
], ],
parentId: null, parentSlug: "logement",
}, },
{ {
slug: "logement-electromenager",
name: "Électroménager", name: "Électroménager",
color: "#f43f5e", color: "#f43f5e",
icon: "refrigerator", icon: "refrigerator",
@@ -231,13 +284,22 @@ export const defaultCategories: Omit<Category, "id">[] = [
"electromenager", "lave linge", "lave vaisselle", "refrigerateur", "electromenager", "lave linge", "lave vaisselle", "refrigerateur",
"aspirateur", "four", "micro onde", "cafetiere", "aspirateur", "four", "micro onde", "cafetiere",
], ],
parentId: null, parentSlug: "logement",
}, },
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// SANTÉ & BIEN-ÊTRE // 💊 SANTÉ (Parent)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
{ {
slug: "sante",
name: "💊 Santé",
color: "#ef4444",
icon: "heart",
keywords: [],
parentSlug: null,
},
{
slug: "sante-pharmacie",
name: "Pharmacie", name: "Pharmacie",
color: "#ef4444", color: "#ef4444",
icon: "pill", icon: "pill",
@@ -246,9 +308,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"docmorris", "shop pharmacie", "para", "docmorris", "shop pharmacie", "para",
"medicament", "ordonnance", "medicament", "ordonnance",
], ],
parentId: null, parentSlug: "sante",
}, },
{ {
slug: "sante-medecins",
name: "Médecins & Spécialistes", name: "Médecins & Spécialistes",
color: "#dc2626", color: "#dc2626",
icon: "stethoscope", icon: "stethoscope",
@@ -259,9 +322,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"kine", "kinesitherapeute", "osteopathe", "psychologue", "psychiatre", "kine", "kinesitherapeute", "osteopathe", "psychologue", "psychiatre",
"doctolib", "maiia", "qare", "livi", "doctolib", "maiia", "qare", "livi",
], ],
parentId: null, parentSlug: "sante",
}, },
{ {
slug: "sante-hopital",
name: "Hôpital & Clinique", name: "Hôpital & Clinique",
color: "#b91c1c", color: "#b91c1c",
icon: "hospital", icon: "hospital",
@@ -270,9 +334,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"urgences", "hospitalisation", "imagerie", "radiologie", "urgences", "hospitalisation", "imagerie", "radiologie",
"scanner", "irm", "laboratoire", "labo analyse", "biologie", "scanner", "irm", "laboratoire", "labo analyse", "biologie",
], ],
parentId: null, parentSlug: "sante",
}, },
{ {
slug: "sante-optique",
name: "Optique & Audition", name: "Optique & Audition",
color: "#f87171", color: "#f87171",
icon: "glasses", icon: "glasses",
@@ -282,9 +347,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"audioprothesiste", "audition", "appareil auditif", "audika", "amplifon", "audioprothesiste", "audition", "appareil auditif", "audika", "amplifon",
"lentille", "verres", "lentille", "verres",
], ],
parentId: null, parentSlug: "sante",
}, },
{ {
slug: "sante-sport",
name: "Sport & Fitness", name: "Sport & Fitness",
color: "#14b8a6", color: "#14b8a6",
icon: "dumbbell", icon: "dumbbell",
@@ -296,9 +362,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"piscine", "tennis", "golf", "escalade", "yoga", "pilates", "piscine", "tennis", "golf", "escalade", "yoga", "pilates",
"crossfit", "cours collectif", "coach sportif", "crossfit", "cours collectif", "coach sportif",
], ],
parentId: null, parentSlug: "sante",
}, },
{ {
slug: "sante-beaute",
name: "Beauté & Soins", name: "Beauté & Soins",
color: "#ec4899", color: "#ec4899",
icon: "sparkles", icon: "sparkles",
@@ -311,13 +378,22 @@ export const defaultCategories: Omit<Category, "id">[] = [
"estheticienne", "institut beaute", "manucure", "pedicure", "estheticienne", "institut beaute", "manucure", "pedicure",
"spa", "massage", "epilation", "spa", "massage", "epilation",
], ],
parentId: null, parentSlug: "sante",
}, },
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// LOISIRS & DIVERTISSEMENT // 🎬 LOISIRS (Parent)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
{ {
slug: "loisirs",
name: "🎬 Loisirs",
color: "#8b5cf6",
icon: "gamepad",
keywords: [],
parentSlug: null,
},
{
slug: "loisirs-streaming",
name: "Streaming & VOD", name: "Streaming & VOD",
color: "#e11d48", color: "#e11d48",
icon: "tv", icon: "tv",
@@ -327,9 +403,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"mycanal", "salto", "molotov", "adn", "crunchyroll", "wakanim", "mycanal", "salto", "molotov", "adn", "crunchyroll", "wakanim",
"dazn", "rmc sport", "bein sport", "dazn", "rmc sport", "bein sport",
], ],
parentId: null, parentSlug: "loisirs",
}, },
{ {
slug: "loisirs-musique",
name: "Musique & Podcasts", name: "Musique & Podcasts",
color: "#22c55e", color: "#22c55e",
icon: "music", icon: "music",
@@ -338,9 +415,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"soundcloud", "youtube music", "qobuz", "napster", "soundcloud", "youtube music", "qobuz", "napster",
"audible", "podcast", "audiobook", "audible", "podcast", "audiobook",
], ],
parentId: null, parentSlug: "loisirs",
}, },
{ {
slug: "loisirs-cinema",
name: "Cinéma & Spectacles", name: "Cinéma & Spectacles",
color: "#8b5cf6", color: "#8b5cf6",
icon: "film", icon: "film",
@@ -353,9 +431,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"zenith", "olympia", "bercy", "accor arena", "stade de france", "zenith", "olympia", "bercy", "accor arena", "stade de france",
"fnac spectacles", "ticketmaster", "digitick", "billetreduc", "fnac spectacles", "ticketmaster", "digitick", "billetreduc",
], ],
parentId: null, parentSlug: "loisirs",
}, },
{ {
slug: "loisirs-jeux",
name: "Jeux vidéo", name: "Jeux vidéo",
color: "#6366f1", color: "#6366f1",
icon: "gamepad", icon: "gamepad",
@@ -364,9 +443,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"ea games", "ubisoft", "activision", "blizzard", "ea games", "ubisoft", "activision", "blizzard",
"micromania", "game", "jeux video", "micromania", "game", "jeux video",
], ],
parentId: null, parentSlug: "loisirs",
}, },
{ {
slug: "loisirs-livres",
name: "Livres & Presse", name: "Livres & Presse",
color: "#0ea5e9", color: "#0ea5e9",
icon: "book", icon: "book",
@@ -379,9 +459,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"cafeyn", "epresse", "kiosque", "magazine", "journal", "presse", "cafeyn", "epresse", "kiosque", "magazine", "journal", "presse",
"librairie", "livre", "bouquin", "librairie", "livre", "bouquin",
], ],
parentId: null, parentSlug: "loisirs",
}, },
{ {
slug: "loisirs-sorties",
name: "Sorties & Activités", name: "Sorties & Activités",
color: "#f472b6", color: "#f472b6",
icon: "ticket", icon: "ticket",
@@ -394,14 +475,23 @@ export const defaultCategories: Omit<Category, "id">[] = [
"escape game", "bowling", "karting", "laser game", "paintball", "escape game", "bowling", "karting", "laser game", "paintball",
"accrobranche", "parc attractions", "zoo", "aquarium", "accrobranche", "parc attractions", "zoo", "aquarium",
], ],
parentId: null, parentSlug: "loisirs",
}, },
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// SHOPPING & MODE // 👕 SHOPPING (Parent)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
{ {
name: "Vêtements", slug: "shopping",
name: "👕 Shopping",
color: "#06b6d4",
icon: "shopping-bag",
keywords: [],
parentSlug: null,
},
{
slug: "shopping-vetements",
name: "Vêtements & Mode",
color: "#06b6d4", color: "#06b6d4",
icon: "shirt", icon: "shirt",
keywords: [ keywords: [
@@ -423,9 +513,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
// Générique // Générique
"vetement", "mode", "textile", "habillement", "chaussure", "vetement", "mode", "textile", "habillement", "chaussure",
], ],
parentId: null, parentSlug: "shopping",
}, },
{ {
slug: "shopping-hightech",
name: "High-Tech", name: "High-Tech",
color: "#3b82f6", color: "#3b82f6",
icon: "smartphone", icon: "smartphone",
@@ -438,9 +529,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
// Générique // Générique
"informatique", "ordinateur", "tablette", "telephone", "accessoire", "informatique", "ordinateur", "tablette", "telephone", "accessoire",
], ],
parentId: null, parentSlug: "shopping",
}, },
{ {
slug: "shopping-ecommerce",
name: "E-commerce", name: "E-commerce",
color: "#f59e0b", color: "#f59e0b",
icon: "package", icon: "package",
@@ -449,13 +541,22 @@ export const defaultCategories: Omit<Category, "id">[] = [
"veepee", "showroomprive", "privatesportshop", "bazarchic", "veepee", "showroomprive", "privatesportshop", "bazarchic",
"leboncoin", "vinted", "vestiaire collective", "ebay", "etsy", "leboncoin", "vinted", "vestiaire collective", "ebay", "etsy",
], ],
parentId: null, parentSlug: "shopping",
}, },
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// ABONNEMENTS & TÉLÉCOMS // 📱 ABONNEMENTS (Parent)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
{ {
slug: "abonnements",
name: "📱 Abonnements",
color: "#8b5cf6",
icon: "repeat",
keywords: [],
parentSlug: null,
},
{
slug: "abonnements-telecom",
name: "Téléphonie & Internet", name: "Téléphonie & Internet",
color: "#8b5cf6", color: "#8b5cf6",
icon: "wifi", icon: "wifi",
@@ -469,9 +570,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
// Générique // Générique
"internet", "fibre", "adsl", "forfait mobile", "telephone mobile", "operateur", "internet", "fibre", "adsl", "forfait mobile", "telephone mobile", "operateur",
], ],
parentId: null, parentSlug: "abonnements",
}, },
{ {
slug: "abonnements-divers",
name: "Abonnements divers", name: "Abonnements divers",
color: "#a78bfa", color: "#a78bfa",
icon: "repeat", icon: "repeat",
@@ -483,13 +585,22 @@ export const defaultCategories: Omit<Category, "id">[] = [
// Autres // Autres
"abonnement", "mensualite", "prelevement", "cotisation", "abonnement", "mensualite", "prelevement", "cotisation",
], ],
parentId: null, parentSlug: "abonnements",
}, },
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// FINANCE & ASSURANCE // 💰 FINANCE (Parent)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
{ {
slug: "finance",
name: "💰 Finance",
color: "#64748b",
icon: "landmark",
keywords: [],
parentSlug: null,
},
{
slug: "finance-banque",
name: "Banque & Frais bancaires", name: "Banque & Frais bancaires",
color: "#64748b", color: "#64748b",
icon: "landmark", icon: "landmark",
@@ -505,9 +616,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"frais bancaire", "agios", "commission intervention", "cotisation carte", "frais bancaire", "agios", "commission intervention", "cotisation carte",
"frais tenue compte", "frais virement", "frais tenue compte", "frais virement",
], ],
parentId: null, parentSlug: "finance",
}, },
{ {
slug: "finance-assurance",
name: "Assurances", name: "Assurances",
color: "#0369a1", color: "#0369a1",
icon: "shield", icon: "shield",
@@ -520,9 +632,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"assurance auto", "assurance habitation", "assurance vie", "assurance auto", "assurance habitation", "assurance vie",
"responsabilite civile", "garantie", "prime assurance", "responsabilite civile", "garantie", "prime assurance",
], ],
parentId: null, parentSlug: "finance",
}, },
{ {
slug: "finance-mutuelle",
name: "Mutuelle & Prévoyance", name: "Mutuelle & Prévoyance",
color: "#0891b2", color: "#0891b2",
icon: "heart-pulse", icon: "heart-pulse",
@@ -533,9 +646,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
// Générique // Générique
"sante", "prevoyance", "complementaire sante", "remboursement soin", "sante", "prevoyance", "complementaire sante", "remboursement soin",
], ],
parentId: null, parentSlug: "finance",
}, },
{ {
slug: "finance-impots",
name: "Impôts & Taxes", name: "Impôts & Taxes",
color: "#dc2626", color: "#dc2626",
icon: "receipt", icon: "receipt",
@@ -544,9 +658,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"taxe habitation", "taxe fonciere", "impot revenu", "taxe habitation", "taxe fonciere", "impot revenu",
"prelevement source", "csg", "crds", "urssaf", "prelevement source", "csg", "crds", "urssaf",
], ],
parentId: null, parentSlug: "finance",
}, },
{ {
slug: "finance-epargne",
name: "Épargne & Investissement", name: "Épargne & Investissement",
color: "#16a34a", color: "#16a34a",
icon: "piggy-bank", icon: "piggy-bank",
@@ -557,9 +672,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"etoro", "interactive brokers", "scalable capital", "etoro", "interactive brokers", "scalable capital",
"crypto", "bitcoin", "ethereum", "binance", "coinbase", "kraken", "crypto", "bitcoin", "ethereum", "binance", "coinbase", "kraken",
], ],
parentId: null, parentSlug: "finance",
}, },
{ {
slug: "finance-credit",
name: "Crédit & Emprunt", name: "Crédit & Emprunt",
color: "#991b1b", color: "#991b1b",
icon: "banknote", icon: "banknote",
@@ -568,13 +684,22 @@ export const defaultCategories: Omit<Category, "id">[] = [
"sofinco", "cetelem", "cofidis", "oney", "floa", "younited", "sofinco", "cetelem", "cofidis", "oney", "floa", "younited",
"credit immobilier", "pret immo", "pret conso", "credit immobilier", "pret immo", "pret conso",
], ],
parentId: null, parentSlug: "finance",
}, },
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// REVENUS // 💵 REVENUS (Parent)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
{ {
slug: "revenus",
name: "💵 Revenus",
color: "#10b981",
icon: "wallet",
keywords: [],
parentSlug: null,
},
{
slug: "revenus-salaire",
name: "Salaire", name: "Salaire",
color: "#10b981", color: "#10b981",
icon: "wallet", icon: "wallet",
@@ -582,9 +707,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"salaire", "paie", "paye", "virement salaire", "bulletin", "salaire", "paie", "paye", "virement salaire", "bulletin",
"net a payer", "remuneration", "net a payer", "remuneration",
], ],
parentId: null, parentSlug: "revenus",
}, },
{ {
slug: "revenus-allocations",
name: "Allocations & Aides", name: "Allocations & Aides",
color: "#34d399", color: "#34d399",
icon: "hand-coins", icon: "hand-coins",
@@ -593,9 +719,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"pole emploi", "france travail", "are", "chomage", "indemnite", "pole emploi", "france travail", "are", "chomage", "indemnite",
"cpam", "secu", "securite sociale", "ameli", "cpam", "secu", "securite sociale", "ameli",
], ],
parentId: null, parentSlug: "revenus",
}, },
{ {
slug: "revenus-remboursements",
name: "Remboursements", name: "Remboursements",
color: "#6ee7b7", color: "#6ee7b7",
icon: "undo", icon: "undo",
@@ -603,9 +730,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"remboursement", "avoir", "retour", "rembourse", "credit note", "remboursement", "avoir", "retour", "rembourse", "credit note",
"annulation", "refund", "annulation", "refund",
], ],
parentId: null, parentSlug: "revenus",
}, },
{ {
slug: "revenus-divers",
name: "Revenus divers", name: "Revenus divers",
color: "#a7f3d0", color: "#a7f3d0",
icon: "coins", icon: "coins",
@@ -614,13 +742,22 @@ export const defaultCategories: Omit<Category, "id">[] = [
"dividende", "interets", "loyer percu", "pension", "dividende", "interets", "loyer percu", "pension",
"retraite", "rente", "retraite", "rente",
], ],
parentId: null, parentSlug: "revenus",
}, },
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// VOYAGE & HÉBERGEMENT // ✈️ VOYAGE (Parent)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
{ {
slug: "voyage",
name: "✈️ Voyage",
color: "#a855f7",
icon: "plane",
keywords: [],
parentSlug: null,
},
{
slug: "voyage-hotel",
name: "Hôtel & Hébergement", name: "Hôtel & Hébergement",
color: "#a855f7", color: "#a855f7",
icon: "bed", icon: "bed",
@@ -635,9 +772,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
// Générique // Générique
"hotel", "hebergement", "nuitee", "reservation hotel", "hotel", "hebergement", "nuitee", "reservation hotel",
], ],
parentId: null, parentSlug: "voyage",
}, },
{ {
slug: "voyage-sejours",
name: "Voyages & Séjours", name: "Voyages & Séjours",
color: "#c084fc", color: "#c084fc",
icon: "luggage", icon: "luggage",
@@ -648,13 +786,22 @@ export const defaultCategories: Omit<Category, "id">[] = [
// Générique // Générique
"voyage", "sejour", "vacances", "croisiere", "circuit", "voyage", "sejour", "vacances", "croisiere", "circuit",
], ],
parentId: null, parentSlug: "voyage",
}, },
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// ÉDUCATION & ENFANTS // 📚 ÉDUCATION (Parent)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
{ {
slug: "education",
name: "📚 Éducation",
color: "#0284c7",
icon: "graduation-cap",
keywords: [],
parentSlug: null,
},
{
slug: "education-formation",
name: "Éducation & Formation", name: "Éducation & Formation",
color: "#0284c7", color: "#0284c7",
icon: "graduation-cap", icon: "graduation-cap",
@@ -664,9 +811,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"formation", "cours", "udemy", "coursera", "openclassrooms", "formation", "cours", "udemy", "coursera", "openclassrooms",
"linkedin learning", "masterclass", "linkedin learning", "masterclass",
], ],
parentId: null, parentSlug: "education",
}, },
{ {
slug: "education-enfants",
name: "Enfants & Famille", name: "Enfants & Famille",
color: "#f472b6", color: "#f472b6",
icon: "baby", icon: "baby",
@@ -681,14 +829,15 @@ export const defaultCategories: Omit<Category, "id">[] = [
"creche", "nounou", "assistante maternelle", "baby sitting", "creche", "nounou", "assistante maternelle", "baby sitting",
"pajemploi", "cesu", "pajemploi", "cesu",
], ],
parentId: null, parentSlug: "education",
}, },
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// ANIMAUX // 🐕 ANIMAUX (Parent)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
{ {
name: "Animaux", slug: "animaux",
name: "🐕 Animaux",
color: "#ea580c", color: "#ea580c",
icon: "paw-print", icon: "paw-print",
keywords: [ keywords: [
@@ -701,13 +850,22 @@ export const defaultCategories: Omit<Category, "id">[] = [
"croquettes", "alimentation animale", "accessoire animal", "croquettes", "alimentation animale", "accessoire animal",
"chien", "chat", "animal", "chien", "chat", "animal",
], ],
parentId: null, parentSlug: null, // Pas de sous-catégories, reste au niveau racine
}, },
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// AUTO & MOTO // 🔧 AUTO & MOTO (Parent)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
{ {
slug: "auto",
name: "🔧 Auto & Moto",
color: "#78716c",
icon: "car",
keywords: [],
parentSlug: null,
},
{
slug: "auto-entretien",
name: "Entretien véhicule", name: "Entretien véhicule",
color: "#78716c", color: "#78716c",
icon: "wrench", icon: "wrench",
@@ -722,9 +880,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"garage", "reparation auto", "vidange", "revision", "controle technique", "garage", "reparation auto", "vidange", "revision", "controle technique",
"pneu", "freins", "entretien voiture", "pneu", "freins", "entretien voiture",
], ],
parentId: null, parentSlug: "auto",
}, },
{ {
slug: "auto-achat",
name: "Achat véhicule", name: "Achat véhicule",
color: "#57534e", color: "#57534e",
icon: "car", icon: "car",
@@ -733,13 +892,22 @@ export const defaultCategories: Omit<Category, "id">[] = [
"arval", "alphabet", "ald automotive", "arval", "alphabet", "ald automotive",
"la centrale", "leboncoin auto", "autoscout", "aramis", "la centrale", "leboncoin auto", "autoscout", "aramis",
], ],
parentId: null, parentSlug: "auto",
}, },
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// DONS & CADEAUX // 🎁 DONS & CADEAUX (Parent)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
{ {
slug: "dons",
name: "🎁 Dons & Cadeaux",
color: "#f43f5e",
icon: "gift",
keywords: [],
parentSlug: null,
},
{
slug: "dons-charite",
name: "Dons & Charité", name: "Dons & Charité",
color: "#fb7185", color: "#fb7185",
icon: "heart-handshake", icon: "heart-handshake",
@@ -748,9 +916,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"croix rouge", "medecins sans frontieres", "msf", "unicef", "croix rouge", "medecins sans frontieres", "msf", "unicef",
"fondation", "association caritative", "solidarite", "fondation", "association caritative", "solidarite",
], ],
parentId: null, parentSlug: "dons",
}, },
{ {
slug: "dons-cadeaux",
name: "Cadeaux", name: "Cadeaux",
color: "#f43f5e", color: "#f43f5e",
icon: "gift", icon: "gift",
@@ -759,13 +928,22 @@ export const defaultCategories: Omit<Category, "id">[] = [
"fleuriste", "interflora", "aquarelle", "florajet", "fleuriste", "interflora", "aquarelle", "florajet",
"bijouterie", "joaillerie", "pandora", "swarovski", "histoire d'or", "bijouterie", "joaillerie", "pandora", "swarovski", "histoire d'or",
], ],
parentId: null, parentSlug: "dons",
}, },
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// DIVERS // DIVERS (Parent)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
{ {
slug: "divers",
name: "❓ Divers",
color: "#71717a",
icon: "more-horizontal",
keywords: [],
parentSlug: null,
},
{
slug: "divers-tabac",
name: "Tabac & Jeux", name: "Tabac & Jeux",
color: "#9ca3af", color: "#9ca3af",
icon: "cigarette", icon: "cigarette",
@@ -774,9 +952,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"loto", "euromillions", "grattage", "pari sportif", "betclic", "winamax", "loto", "euromillions", "grattage", "pari sportif", "betclic", "winamax",
"cigarette", "vapoteuse", "ecig", "cigarette", "vapoteuse", "ecig",
], ],
parentId: null, parentSlug: "divers",
}, },
{ {
slug: "divers-retraits",
name: "Retraits DAB", name: "Retraits DAB",
color: "#71717a", color: "#71717a",
icon: "banknote", icon: "banknote",
@@ -784,9 +963,10 @@ export const defaultCategories: Omit<Category, "id">[] = [
"retrait", "dab", "distributeur", "retrait especes", "retrait cb", "retrait", "dab", "distributeur", "retrait especes", "retrait cb",
"cash", "liquide", "cash", "liquide",
], ],
parentId: null, parentSlug: "divers",
}, },
{ {
slug: "divers-virements",
name: "Virements & Transferts", name: "Virements & Transferts",
color: "#52525b", color: "#52525b",
icon: "arrow-right-left", icon: "arrow-right-left",
@@ -794,69 +974,82 @@ export const defaultCategories: Omit<Category, "id">[] = [
"virement", "vir ", "transfert", "virement emis", "virement permanent", "virement", "vir ", "transfert", "virement emis", "virement permanent",
"paypal", "wise", "western union", "moneygram", "paypal", "wise", "western union", "moneygram",
], ],
parentId: null, parentSlug: "divers",
}, },
{ {
slug: "divers-non-categorise",
name: "Non catégorisé", name: "Non catégorisé",
color: "#d4d4d8", color: "#d4d4d8",
icon: "help-circle", icon: "help-circle",
keywords: [], keywords: [],
parentId: null, parentSlug: "divers",
}, },
] ]
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// RÈGLES DE CATÉGORISATION AVANCÉES // RÈGLES DE CATÉGORISATION AVANCÉES
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
export const defaultCategoryRules: Omit<CategoryRule, "id" | "categoryId">[] = [ export interface CategoryRuleDefinition extends Omit<CategoryRule, "id" | "categoryId"> {
// Ces règles seront associées aux catégories correspondantes lors du seeding categorySlug: string // Référence au slug de la catégorie
// Format: pattern à matcher, isRegex indique si c'est une expression régulière }
export const defaultCategoryRules: CategoryRuleDefinition[] = [
// Salaire - patterns typiques de virements salaire // Salaire - patterns typiques de virements salaire
{ pattern: "^VIR(EMENT)? (RECU )?.*SALAIRE", isRegex: true }, { categorySlug: "revenus-salaire", pattern: "^VIR(EMENT)? (RECU )?.*SALAIRE", isRegex: true },
{ pattern: "^VIR(EMENT)? (RECU )?.*PAIE", isRegex: true }, { categorySlug: "revenus-salaire", pattern: "^VIR(EMENT)? (RECU )?.*PAIE", isRegex: true },
// Loyer - patterns de prélèvement loyer // Loyer - patterns de prélèvement loyer
{ pattern: "^PRLV.*LOYER", isRegex: true }, { categorySlug: "logement-loyer", pattern: "^PRLV.*LOYER", isRegex: true },
{ pattern: "^PRLV.*FONCIA", isRegex: true }, { categorySlug: "logement-loyer", pattern: "^PRLV.*FONCIA", isRegex: true },
{ pattern: "^PRLV.*NEXITY", isRegex: true }, { categorySlug: "logement-loyer", pattern: "^PRLV.*NEXITY", isRegex: true },
// EDF/Engie // EDF/Engie
{ pattern: "^PRLV.*EDF", isRegex: true }, { categorySlug: "logement-electricite", pattern: "^PRLV.*EDF", isRegex: true },
{ pattern: "^PRLV.*ENGIE", isRegex: true }, { categorySlug: "logement-electricite", pattern: "^PRLV.*ENGIE", isRegex: true },
{ pattern: "^PRLV.*TOTAL.?ENERGIE", isRegex: true }, { categorySlug: "logement-electricite", pattern: "^PRLV.*TOTAL.?ENERGIE", isRegex: true },
// Télécom // Télécom
{ pattern: "^PRLV.*FREE( MOBILE)?", isRegex: true }, { categorySlug: "abonnements-telecom", pattern: "^PRLV.*FREE( MOBILE)?", isRegex: true },
{ pattern: "^PRLV.*ORANGE", isRegex: true }, { categorySlug: "abonnements-telecom", pattern: "^PRLV.*ORANGE", isRegex: true },
{ pattern: "^PRLV.*SFR", isRegex: true }, { categorySlug: "abonnements-telecom", pattern: "^PRLV.*SFR", isRegex: true },
{ pattern: "^PRLV.*BOUYGUES", isRegex: true }, { categorySlug: "abonnements-telecom", pattern: "^PRLV.*BOUYGUES", isRegex: true },
// Assurances // Assurances
{ pattern: "^PRLV.*AXA", isRegex: true }, { categorySlug: "finance-assurance", pattern: "^PRLV.*AXA", isRegex: true },
{ pattern: "^PRLV.*MAIF", isRegex: true }, { categorySlug: "finance-assurance", pattern: "^PRLV.*MAIF", isRegex: true },
{ pattern: "^PRLV.*MACIF", isRegex: true }, { categorySlug: "finance-assurance", pattern: "^PRLV.*MACIF", isRegex: true },
{ pattern: "^PRLV.*MATMUT", isRegex: true }, { categorySlug: "finance-assurance", pattern: "^PRLV.*MATMUT", isRegex: true },
// Impôts // Impôts
{ pattern: "^PRLV.*DGFIP", isRegex: true }, { categorySlug: "finance-impots", pattern: "^PRLV.*DGFIP", isRegex: true },
{ pattern: "^PRLV.*TRESOR PUBLIC", isRegex: true }, { categorySlug: "finance-impots", pattern: "^PRLV.*TRESOR PUBLIC", isRegex: true },
{ pattern: "IMPOT", isRegex: false }, { categorySlug: "finance-impots", pattern: "IMPOT", isRegex: false },
// Remboursements // Remboursements
{ pattern: "^VIR(EMENT)? (RECU )?.*CPAM", isRegex: true }, { categorySlug: "revenus-remboursements", pattern: "^VIR(EMENT)? (RECU )?.*CPAM", isRegex: true },
{ pattern: "^VIR(EMENT)? (RECU )?.*AMELI", isRegex: true }, { categorySlug: "revenus-remboursements", pattern: "^VIR(EMENT)? (RECU )?.*AMELI", isRegex: true },
{ pattern: "REMBOURSEMENT", isRegex: false }, { categorySlug: "revenus-remboursements", pattern: "REMBOURSEMENT", isRegex: false },
// CAF // CAF
{ pattern: "^VIR(EMENT)? (RECU )?.*CAF", isRegex: true }, { categorySlug: "revenus-allocations", pattern: "^VIR(EMENT)? (RECU )?.*CAF", isRegex: true },
{ pattern: "ALLOCATION", isRegex: false }, { categorySlug: "revenus-allocations", pattern: "ALLOCATION", isRegex: false },
// Retraits // Retraits
{ pattern: "^RETRAIT DAB", isRegex: true }, { categorySlug: "divers-retraits", pattern: "^RETRAIT DAB", isRegex: true },
{ pattern: "^RET DAB", isRegex: true }, { categorySlug: "divers-retraits", pattern: "^RET DAB", isRegex: true },
] ]
// ═══════════════════════════════════════════════════════════════════════════
// DOSSIER RACINE
// ═══════════════════════════════════════════════════════════════════════════
export interface Folder {
id: string
name: string
parentId: string | null
color: string
icon: string
}
export const defaultRootFolder: Folder = { export const defaultRootFolder: Folder = {
id: "folder-root", id: "folder-root",
name: "Mes Comptes", name: "Mes Comptes",
@@ -864,4 +1057,3 @@ export const defaultRootFolder: Folder = {
color: "#6366f1", color: "#6366f1",
icon: "folder", icon: "folder",
} }

View File

@@ -5,11 +5,49 @@ import { defaultCategories, defaultRootFolder } from "./defaults"
const STORAGE_KEY = "banking-app-data" const STORAGE_KEY = "banking-app-data"
// Convertir les CategoryDefinition en Category pour le localStorage
function buildCategoriesFromDefaults(): Category[] {
const slugToId = new Map<string, string>()
const categories: Category[] = []
// D'abord les parents
const parents = defaultCategories.filter((c) => c.parentSlug === null)
parents.forEach((cat, index) => {
const id = `cat-${index + 1}`
slugToId.set(cat.slug, id)
categories.push({
id,
name: cat.name,
color: cat.color,
icon: cat.icon,
keywords: cat.keywords,
parentId: null,
})
})
// Puis les enfants
const children = defaultCategories.filter((c) => c.parentSlug !== null)
children.forEach((cat, index) => {
const id = `cat-${parents.length + index + 1}`
slugToId.set(cat.slug, id)
categories.push({
id,
name: cat.name,
color: cat.color,
icon: cat.icon,
keywords: cat.keywords,
parentId: cat.parentSlug ? slugToId.get(cat.parentSlug) || null : null,
})
})
return categories
}
const defaultData: BankingData = { const defaultData: BankingData = {
accounts: [], accounts: [],
transactions: [], transactions: [],
folders: [defaultRootFolder], folders: [defaultRootFolder],
categories: defaultCategories.map((cat, index) => ({ ...cat, id: `cat-${index + 1}` })), categories: buildCategoriesFromDefaults(),
categoryRules: [], categoryRules: [],
} }

View File

@@ -4,41 +4,85 @@ import { defaultCategories, defaultRootFolder } from "../lib/defaults"
const prisma = new PrismaClient() const prisma = new PrismaClient()
async function main() { async function main() {
console.log("Seeding database...") console.log("🌱 Seeding database...")
// Create root folder if it doesn't exist // ═══════════════════════════════════════════════════════════════════════════
// Créer le dossier racine
// ═══════════════════════════════════════════════════════════════════════════
const rootFolder = await prisma.folder.upsert({ const rootFolder = await prisma.folder.upsert({
where: { id: defaultRootFolder.id }, where: { id: defaultRootFolder.id },
update: {}, update: {},
create: defaultRootFolder, create: defaultRootFolder,
}) })
console.log("📁 Root folder:", rootFolder.name)
console.log("Root folder created:", rootFolder.name) // ═══════════════════════════════════════════════════════════════════════════
// Créer les catégories (hiérarchiques)
// ═══════════════════════════════════════════════════════════════════════════
const slugToId = new Map<string, string>()
// Create default categories // D'abord les parents
for (const category of defaultCategories) { const parents = defaultCategories.filter((c) => c.parentSlug === null)
console.log(`\n🏷 Création de ${parents.length} catégories parentes...`)
for (const category of parents) {
const existing = await prisma.category.findFirst({ const existing = await prisma.category.findFirst({
where: { where: { name: category.name, parentId: null },
name: category.name,
parentId: category.parentId,
},
}) })
if (!existing) { if (!existing) {
await prisma.category.create({ const created = await prisma.category.create({
data: { data: {
name: category.name, name: category.name,
color: category.color, color: category.color,
icon: category.icon, icon: category.icon,
keywords: JSON.stringify(category.keywords), keywords: JSON.stringify(category.keywords),
parentId: category.parentId, parentId: null,
}, },
}) })
console.log(`Created category: ${category.name}`) slugToId.set(category.slug, created.id)
console.log(`${category.name}`)
} else {
slugToId.set(category.slug, existing.id)
} }
} }
console.log("Seeding completed!") // Puis les enfants
const children = defaultCategories.filter((c) => c.parentSlug !== null)
console.log(`\n📂 Création de ${children.length} sous-catégories...`)
for (const category of children) {
const parentId = slugToId.get(category.parentSlug!)
if (!parentId) {
console.log(` ⚠️ Parent non trouvé pour: ${category.name}`)
continue
}
const existing = await prisma.category.findFirst({
where: { name: category.name, parentId },
})
if (!existing) {
const created = await prisma.category.create({
data: {
name: category.name,
color: category.color,
icon: category.icon,
keywords: JSON.stringify(category.keywords),
parentId,
},
})
slugToId.set(category.slug, created.id)
console.log(`${category.name}`)
} else {
slugToId.set(category.slug, existing.id)
}
}
// Stats
const totalCategories = await prisma.category.count()
console.log(`\n✨ Seeding terminé! ${totalCategories} catégories en base.`)
} }
main() main()
@@ -49,4 +93,3 @@ main()
.finally(async () => { .finally(async () => {
await prisma.$disconnect() await prisma.$disconnect()
}) })

View File

@@ -1,76 +1,267 @@
import { PrismaClient } from "@prisma/client" import { PrismaClient } from "@prisma/client"
import { defaultCategories } from "../lib/defaults" import { defaultCategories, defaultCategoryRules, type CategoryDefinition } from "../lib/defaults"
const prisma = new PrismaClient() const prisma = new PrismaClient()
async function main() { async function main() {
console.log("🏷️ Synchronisation des catégories...") console.log("🏷️ Synchronisation des catégories hiérarchiques...")
console.log(` ${defaultCategories.length} catégories à synchroniser\n`) console.log(` ${defaultCategories.length} catégories à synchroniser\n`)
// ═══════════════════════════════════════════════════════════════════════════
// PHASE 0: Nettoyage des doublons existants
// ═══════════════════════════════════════════════════════════════════════════
console.log("═".repeat(50))
console.log("PHASE 0: Nettoyage des doublons")
console.log("═".repeat(50))
const allExisting = await prisma.category.findMany()
const byNormalizedName = new Map<string, typeof allExisting>()
for (const cat of allExisting) {
const normalized = normalizeName(cat.name)
if (!byNormalizedName.has(normalized)) {
byNormalizedName.set(normalized, [])
}
byNormalizedName.get(normalized)!.push(cat)
}
let merged = 0
for (const [_normalized, cats] of byNormalizedName) {
if (cats.length > 1) {
// Garder celui avec emoji
let keeper = cats[0]
for (const cat of cats) {
if (/[\u{1F300}-\u{1F9FF}]/u.test(cat.name)) {
keeper = cat
break
}
}
const toDelete: typeof cats = []
for (const cat of cats) {
if (cat.id !== keeper.id) toDelete.push(cat)
}
for (const dup of toDelete) {
// Transférer transactions
await prisma.transaction.updateMany({
where: { categoryId: dup.id },
data: { categoryId: keeper.id },
})
// Transférer enfants
await prisma.category.updateMany({
where: { parentId: dup.id },
data: { parentId: keeper.id },
})
// Supprimer doublon
await prisma.category.delete({ where: { id: dup.id } })
console.log(`🗑️ Fusionné: "${dup.name}" → "${keeper.name}"`)
merged++
}
}
}
console.log(merged > 0 ? ` ${merged} doublons fusionnés` : " Aucun doublon ✓")
// Séparer parents et enfants
const parentCategories = defaultCategories.filter((c) => c.parentSlug === null)
const childCategories = defaultCategories.filter((c) => c.parentSlug !== null)
console.log(`\n📁 ${parentCategories.length} catégories parentes`)
console.log(` └─ ${childCategories.length} sous-catégories\n`)
// Map slug -> id (pour résoudre les parentId)
const slugToId = new Map<string, string>()
let created = 0 let created = 0
let updated = 0 let updated = 0
let unchanged = 0 let unchanged = 0
for (const category of defaultCategories) { // ═══════════════════════════════════════════════════════════════════════════
const existing = await prisma.category.findFirst({ // PHASE 1: Créer/MAJ les catégories parentes
where: { name: category.name }, // ═══════════════════════════════════════════════════════════════════════════
}) console.log("═".repeat(50))
console.log("PHASE 1: Catégories parentes")
console.log("═".repeat(50))
if (existing) { for (const category of parentCategories) {
// Comparer pour voir si mise à jour nécessaire const result = await upsertCategory(category, null)
const existingKeywords = JSON.parse(existing.keywords) as string[] slugToId.set(category.slug, result.id)
const keywordsChanged =
JSON.stringify(existingKeywords.sort()) !== JSON.stringify([...category.keywords].sort())
const colorChanged = existing.color !== category.color
const iconChanged = existing.icon !== category.icon
if (keywordsChanged || colorChanged || iconChanged) { if (result.action === "created") created++
await prisma.category.update({ else if (result.action === "updated") updated++
where: { id: existing.id }, else unchanged++
data: {
color: category.color,
icon: category.icon,
keywords: JSON.stringify(category.keywords),
},
})
console.log(`✏️ Mise à jour: ${category.name}`)
if (keywordsChanged) {
console.log(` └─ Keywords: ${existingKeywords.length}${category.keywords.length}`)
}
updated++
} else {
unchanged++
}
} else {
await prisma.category.create({
data: {
name: category.name,
color: category.color,
icon: category.icon,
keywords: JSON.stringify(category.keywords),
parentId: category.parentId,
},
})
console.log(`✅ Créée: ${category.name} (${category.keywords.length} keywords)`)
created++
}
} }
// ═══════════════════════════════════════════════════════════════════════════
// PHASE 2: Créer/MAJ les sous-catégories
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n" + "═".repeat(50)) console.log("\n" + "═".repeat(50))
console.log("📊 Résumé:") console.log("PHASE 2: Sous-catégories")
console.log(` ✅ Créées: ${created}`)
console.log(` ✏️ Mises à jour: ${updated}`)
console.log(` ⏭️ Inchangées: ${unchanged}`)
console.log("═".repeat(50)) console.log("═".repeat(50))
for (const category of childCategories) {
const parentId = slugToId.get(category.parentSlug!)
if (!parentId) {
console.log(`⚠️ Parent introuvable pour: ${category.name} (parentSlug: ${category.parentSlug})`)
continue
}
const result = await upsertCategory(category, parentId)
slugToId.set(category.slug, result.id)
if (result.action === "created") created++
else if (result.action === "updated") updated++
else unchanged++
}
// ═══════════════════════════════════════════════════════════════════════════
// PHASE 3: Sync des règles (optionnel)
// ═══════════════════════════════════════════════════════════════════════════
if (defaultCategoryRules.length > 0) {
console.log("\n" + "═".repeat(50))
console.log("PHASE 3: Règles de catégorisation")
console.log("═".repeat(50))
let rulesCreated = 0
let rulesSkipped = 0
for (const rule of defaultCategoryRules) {
const categoryId = slugToId.get(rule.categorySlug)
if (!categoryId) {
console.log(`⚠️ Catégorie introuvable pour règle: ${rule.categorySlug}`)
rulesSkipped++
continue
}
// Vérifier si la règle existe déjà
const existing = await prisma.categoryRule.findFirst({
where: {
categoryId,
pattern: rule.pattern,
},
})
if (!existing) {
await prisma.categoryRule.create({
data: {
categoryId,
pattern: rule.pattern,
isRegex: rule.isRegex,
},
})
console.log(`✅ Règle créée: ${rule.pattern.substring(0, 40)}...`)
rulesCreated++
} else {
rulesSkipped++
}
}
console.log(`\n📊 Règles: ${rulesCreated} créées, ${rulesSkipped} existantes`)
}
// ═══════════════════════════════════════════════════════════════════════════
// RÉSUMÉ
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n" + "═".repeat(50))
console.log("📊 RÉSUMÉ CATÉGORIES:")
console.log("═".repeat(50))
console.log(` ✅ Créées: ${created}`)
console.log(` ✏️ Mises à jour: ${updated}`)
console.log(` ⏭️ Inchangées: ${unchanged}`)
// Stats finales // Stats finales
const totalCategories = await prisma.category.count() const totalCategories = await prisma.category.count()
const parentCount = await prisma.category.count({ where: { parentId: null } })
const childCount = await prisma.category.count({ where: { NOT: { parentId: null } } })
const totalRules = await prisma.categoryRule.count()
const totalKeywords = defaultCategories.reduce((sum, c) => sum + c.keywords.length, 0) const totalKeywords = defaultCategories.reduce((sum, c) => sum + c.keywords.length, 0)
console.log(`\n📈 Base de données:`)
console.log(` Total catégories: ${totalCategories}`) console.log("\n📈 Base de données:")
console.log(` Total catégories: ${totalCategories} (${parentCount} parents, ${childCount} enfants)`)
console.log(` Total règles: ${totalRules}`)
console.log(` Total keywords: ${totalKeywords}`) console.log(` Total keywords: ${totalKeywords}`)
} }
// Normaliser un nom (enlever emojis, espaces multiples, lowercase)
function normalizeName(name: string): string {
return name
.replace(/[\u{1F300}-\u{1F9FF}]/gu, "") // Remove emojis
.replace(/[^\w\sÀ-ÿ]/g, "") // Keep only alphanumeric and accents
.replace(/\s+/g, " ")
.toLowerCase()
.trim()
}
async function upsertCategory(
category: CategoryDefinition,
parentId: string | null
): Promise<{ id: string; action: "created" | "updated" | "unchanged" }> {
// Chercher par nom exact d'abord
let existing = await prisma.category.findFirst({
where: { name: category.name },
})
// Si pas trouvé, chercher par nom normalisé (sans emoji) dans TOUTES les catégories
if (!existing) {
const allCategories = await prisma.category.findMany()
const normalizedTarget = normalizeName(category.name)
for (const cat of allCategories) {
if (normalizeName(cat.name) === normalizedTarget) {
existing = cat
console.log(` 🔗 Match normalisé: "${cat.name}" → "${category.name}"`)
break
}
}
}
if (existing) {
// Comparer pour voir si mise à jour nécessaire
const existingKeywords = JSON.parse(existing.keywords) as string[]
const keywordsChanged =
JSON.stringify(existingKeywords.sort()) !== JSON.stringify([...category.keywords].sort())
const nameChanged = existing.name !== category.name
const colorChanged = existing.color !== category.color
const iconChanged = existing.icon !== category.icon
const parentChanged = existing.parentId !== parentId
if (nameChanged || keywordsChanged || colorChanged || iconChanged || parentChanged) {
await prisma.category.update({
where: { id: existing.id },
data: {
name: category.name, // Met à jour le nom aussi (ajout emoji)
color: category.color,
icon: category.icon,
keywords: JSON.stringify(category.keywords),
parentId: parentId,
},
})
console.log(`✏️ MAJ: ${existing.name}${nameChanged ? `${category.name}` : ""}`)
if (keywordsChanged) {
console.log(` └─ Keywords: ${existingKeywords.length}${category.keywords.length}`)
}
if (parentChanged) {
console.log(` └─ Parent modifié`)
}
return { id: existing.id, action: "updated" }
}
return { id: existing.id, action: "unchanged" }
}
// Créer nouvelle catégorie
const created = await prisma.category.create({
data: {
name: category.name,
color: category.color,
icon: category.icon,
keywords: JSON.stringify(category.keywords),
parentId: parentId,
},
})
console.log(`✅ Créée: ${category.name}${category.keywords.length > 0 ? ` (${category.keywords.length} keywords)` : ""}`)
return { id: created.id, action: "created" }
}
main() main()
.catch((e) => { .catch((e) => {
console.error("❌ Erreur:", e) console.error("❌ Erreur:", e)
@@ -79,4 +270,3 @@ main()
.finally(async () => { .finally(async () => {
await prisma.$disconnect() await prisma.$disconnect()
}) })