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"
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<Category | null>(null)
const [expandedParents, setExpandedParents] = useState<Set<string>>(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<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) {
return (
<div className="flex h-screen">
@@ -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)
@@ -143,7 +197,10 @@ export default function CategoriesPage() {
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 (
<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 (
<div className="flex h-screen bg-background">
<Sidebar />
<main className="flex-1 overflow-auto">
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Catégories</h1>
<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>
</div>
<div className="flex gap-2">
@@ -175,82 +275,157 @@ export default function CategoriesPage() {
Recatégoriser ({uncategorizedCount})
</Button>
)}
<Button onClick={handleNewCategory}>
<Button onClick={() => handleNewCategory(null)}>
<Plus className="w-4 h-4 mr-2" />
Nouvelle catégorie
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data.categories.map((category) => {
const stats = getCategoryStats(category.id)
{/* Liste des catégories par parent */}
<div className="space-y-1">
{parentCategories.map((parent) => {
const children = childrenByParent[parent.id] || []
const stats = getCategoryStats(parent.id, true)
const isExpanded = expandedParents.has(parent.id)
return (
<Card key={category.id}>
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div key={parent.id} className="border rounded-lg bg-card">
<Collapsible open={isExpanded} onOpenChange={() => toggleExpanded(parent.id)}>
<div className="flex items-center justify-between px-3 py-2">
<CollapsibleTrigger asChild>
<button className="flex items-center gap-2 hover:opacity-80 transition-opacity flex-1 min-w-0">
{isExpanded ? (
<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-10 h-10 rounded-full flex items-center justify-center"
style={{ backgroundColor: `${category.color}20` }}
className="w-7 h-7 rounded-full flex items-center justify-center shrink-0"
style={{ backgroundColor: `${parent.color}20` }}
>
<Tag className="w-5 h-5" style={{ color: category.color }} />
</div>
<div>
<CardTitle className="text-base">{category.name}</CardTitle>
<p className="text-xs text-muted-foreground">
{stats.count} transaction{stats.count > 1 ? "s" : ""}
</p>
</div>
<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)
}}
>
<Plus className="w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(category)}>
<DropdownMenuItem onClick={() => handleEdit(parent)}>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(category.id)} className="text-red-600">
<DropdownMenuItem
onClick={() => handleDelete(parent.id)}
className="text-red-600"
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent>
<div className="text-lg font-semibold mb-3">{formatCurrency(stats.total)}</div>
<div className="flex flex-wrap gap-1">
{category.keywords.slice(0, 5).map((keyword) => (
<Badge key={keyword} variant="secondary" className="text-xs">
{keyword}
</Badge>
))}
{category.keywords.length > 5 && (
<Badge variant="secondary" className="text-xs">
+{category.keywords.length - 5}
</Badge>
)}
</div>
</CardContent>
</Card>
<CollapsibleContent>
{children.length > 0 ? (
<div className="px-3 pb-2 space-y-1 ml-6 border-l-2 border-muted ml-5">
{children.map((child) => (
<ChildCategoryCard key={child.id} category={child} />
))}
</div>
) : (
<div className="px-3 pb-2 ml-11 text-xs text-muted-foreground italic">
Aucune sous-catégorie
</div>
)}
</CollapsibleContent>
</Collapsible>
</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>
</main>
{/* Dialog de création/édition */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{editingCategory ? "Modifier la catégorie" : "Nouvelle catégorie"}</DialogTitle>
<DialogTitle>
{editingCategory ? "Modifier la catégorie" : "Nouvelle catégorie"}
</DialogTitle>
</DialogHeader>
<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">
<Label>Nom</Label>
<Input
@@ -260,6 +435,7 @@ export default function CategoriesPage() {
/>
</div>
{/* Couleur */}
<div className="space-y-2">
<Label>Couleur</Label>
<div className="flex flex-wrap gap-2">
@@ -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() {
</div>
</div>
{/* Mots-clés */}
<div className="space-y-2">
<Label>Mots-clés pour la catégorisation automatique</Label>
<div className="flex gap-2">
@@ -290,7 +467,7 @@ export default function CategoriesPage() {
<Plus className="w-4 h-4" />
</Button>
</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) => (
<Badge key={keyword} variant="secondary" className="gap-1">
{keyword}
@@ -302,6 +479,7 @@ export default function CategoriesPage() {
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
Annuler

View File

@@ -6,6 +6,7 @@ import { useBankingData } from "@/lib/hooks"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { RefreshCw, TrendingUp, TrendingDown, ArrowRight } from "lucide-react"
import { CategoryIcon } from "@/components/ui/category-icon"
import {
BarChart,
Bar,
@@ -385,9 +386,10 @@ export default function StatisticsPage() {
</span>
{category && (
<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 }}
>
<CategoryIcon icon={category.icon} color={category.color} size={10} />
{category.name}
</span>
)}

View File

@@ -17,6 +17,7 @@ import {
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu"
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 { cn } from "@/lib/utils"
@@ -274,7 +275,7 @@ export default function TransactionsPage() {
<DropdownMenuSeparator />
{data.categories.map((cat) => (
<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}
</DropdownMenuItem>
))}
@@ -392,11 +393,13 @@ export default function TransactionsPage() {
{category ? (
<Badge
variant="secondary"
className="gap-1"
style={{
backgroundColor: `${category.color}20`,
color: category.color,
}}
>
<CategoryIcon icon={category.icon} color={category.color} size={12} />
{category.name}
</Badge>
) : (
@@ -413,10 +416,7 @@ export default function TransactionsPage() {
<DropdownMenuSeparator />
{data.categories.map((cat) => (
<DropdownMenuItem key={cat.id} onClick={() => setCategory(transaction.id, 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}
{transaction.categoryId === cat.id && <Check className="w-4 h-4 ml-auto" />}
</DropdownMenuItem>

View File

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

View File

@@ -5,11 +5,49 @@ import { defaultCategories, defaultRootFolder } from "./defaults"
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 = {
accounts: [],
transactions: [],
folders: [defaultRootFolder],
categories: defaultCategories.map((cat, index) => ({ ...cat, id: `cat-${index + 1}` })),
categories: buildCategoriesFromDefaults(),
categoryRules: [],
}

View File

@@ -4,41 +4,85 @@ import { defaultCategories, defaultRootFolder } from "../lib/defaults"
const prisma = new PrismaClient()
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({
where: { id: defaultRootFolder.id },
update: {},
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
for (const category of defaultCategories) {
// D'abord les parents
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({
where: {
name: category.name,
parentId: category.parentId,
},
where: { name: category.name, parentId: null },
})
if (!existing) {
await prisma.category.create({
const created = await prisma.category.create({
data: {
name: category.name,
color: category.color,
icon: category.icon,
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()
@@ -49,4 +93,3 @@ main()
.finally(async () => {
await prisma.$disconnect()
})

View File

@@ -1,74 +1,265 @@
import { PrismaClient } from "@prisma/client"
import { defaultCategories } from "../lib/defaults"
import { defaultCategories, defaultCategoryRules, type CategoryDefinition } from "../lib/defaults"
const prisma = new PrismaClient()
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`)
// ═══════════════════════════════════════════════════════════════════════════
// 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 updated = 0
let unchanged = 0
for (const category of defaultCategories) {
const existing = await prisma.category.findFirst({
// ═══════════════════════════════════════════════════════════════════════════
// PHASE 1: Créer/MAJ les catégories parentes
// ═══════════════════════════════════════════════════════════════════════════
console.log("═".repeat(50))
console.log("PHASE 1: Catégories parentes")
console.log("═".repeat(50))
for (const category of parentCategories) {
const result = await upsertCategory(category, null)
slugToId.set(category.slug, result.id)
if (result.action === "created") created++
else if (result.action === "updated") updated++
else unchanged++
}
// ═══════════════════════════════════════════════════════════════════════════
// PHASE 2: Créer/MAJ les sous-catégories
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n" + "═".repeat(50))
console.log("PHASE 2: Sous-catégories")
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
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)
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}`)
}
// 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 (keywordsChanged || colorChanged || iconChanged) {
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(`✏️ Mise à jour: ${category.name}`)
console.log(`✏️ MAJ: ${existing.name}${nameChanged ? ` ${category.name}` : ""}`)
if (keywordsChanged) {
console.log(` └─ Keywords: ${existingKeywords.length}${category.keywords.length}`)
}
updated++
} else {
unchanged++
if (parentChanged) {
console.log(` └─ Parent modifié`)
}
} else {
await prisma.category.create({
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: category.parentId,
parentId: parentId,
},
})
console.log(`✅ Créée: ${category.name} (${category.keywords.length} keywords)`)
created++
}
}
console.log(`✅ Créée: ${category.name}${category.keywords.length > 0 ? ` (${category.keywords.length} keywords)` : ""}`)
console.log("\n" + "═".repeat(50))
console.log("📊 Résumé:")
console.log(` ✅ Créées: ${created}`)
console.log(` ✏️ Mises à jour: ${updated}`)
console.log(` ⏭️ Inchangées: ${unchanged}`)
console.log("═".repeat(50))
// Stats finales
const totalCategories = await prisma.category.count()
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(` Total keywords: ${totalKeywords}`)
return { id: created.id, action: "created" }
}
main()
@@ -79,4 +270,3 @@ main()
.finally(async () => {
await prisma.$disconnect()
})