feat: implement hierarchical category management with parent-child relationships and enhance category creation dialog
This commit is contained in:
@@ -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)
|
||||||
@@ -141,9 +195,12 @@ export default function CategoriesPage() {
|
|||||||
try {
|
try {
|
||||||
const { updateTransaction } = await import("@/lib/store-db")
|
const { updateTransaction } = await import("@/lib/store-db")
|
||||||
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
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
143
components/ui/category-icon.tsx
Normal file
143
components/ui/category-icon.tsx
Normal 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} />
|
||||||
|
}
|
||||||
|
|
||||||
404
lib/defaults.ts
404
lib/defaults.ts
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
40
lib/store.ts
40
lib/store.ts
@@ -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: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user