diff --git a/app/rules/page.tsx b/app/rules/page.tsx new file mode 100644 index 0000000..e7e2324 --- /dev/null +++ b/app/rules/page.tsx @@ -0,0 +1,298 @@ +"use client"; + +import { useState, useMemo, useCallback } from "react"; +import { PageLayout, LoadingState, PageHeader } from "@/components/layout"; +import { + RuleGroupCard, + RuleCreateDialog, + RulesSearchBar, +} from "@/components/rules"; +import { useBankingData } from "@/lib/hooks"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Sparkles, RefreshCw } from "lucide-react"; +import { updateCategory, autoCategorize, updateTransaction } from "@/lib/store-db"; +import { + normalizeDescription, + suggestKeyword, +} from "@/components/rules/constants"; +import type { Transaction, Category } from "@/lib/types"; + +interface TransactionGroup { + key: string; + displayName: string; + transactions: Transaction[]; + totalAmount: number; + suggestedKeyword: string; +} + +export default function RulesPage() { + const { data, isLoading, refresh } = useBankingData(); + const [searchQuery, setSearchQuery] = useState(""); + const [sortBy, setSortBy] = useState<"count" | "amount" | "name">("count"); + const [filterMinCount, setFilterMinCount] = useState(2); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + const [selectedGroup, setSelectedGroup] = useState( + null + ); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isAutoCategorizing, setIsAutoCategorizing] = useState(false); + + // Group uncategorized transactions by normalized description + const transactionGroups = useMemo(() => { + if (!data?.transactions) return []; + + const uncategorized = data.transactions.filter((t) => !t.categoryId); + const groups: Record = {}; + + uncategorized.forEach((transaction) => { + const key = normalizeDescription(transaction.description); + if (!groups[key]) { + groups[key] = []; + } + groups[key].push(transaction); + }); + + // Convert to array with metadata + const groupArray: TransactionGroup[] = Object.entries(groups).map( + ([key, transactions]) => { + const descriptions = transactions.map((t) => t.description); + return { + key, + displayName: transactions[0].description, // Use first transaction's description as display name + transactions, + totalAmount: transactions.reduce((sum, t) => sum + t.amount, 0), + suggestedKeyword: suggestKeyword(descriptions), + }; + } + ); + + // Filter by search query + let filtered = groupArray; + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + filtered = groupArray.filter( + (g) => + g.displayName.toLowerCase().includes(query) || + g.key.includes(query) || + g.suggestedKeyword.toLowerCase().includes(query) + ); + } + + // Filter by minimum count + filtered = filtered.filter((g) => g.transactions.length >= filterMinCount); + + // Sort + filtered.sort((a, b) => { + switch (sortBy) { + case "count": + return b.transactions.length - a.transactions.length; + case "amount": + return Math.abs(b.totalAmount) - Math.abs(a.totalAmount); + case "name": + return a.displayName.localeCompare(b.displayName); + default: + return 0; + } + }); + + return filtered; + }, [data?.transactions, searchQuery, sortBy, filterMinCount]); + + const uncategorizedCount = useMemo(() => { + if (!data?.transactions) return 0; + return data.transactions.filter((t) => !t.categoryId).length; + }, [data?.transactions]); + + const formatCurrency = useCallback((amount: number) => { + return new Intl.NumberFormat("fr-FR", { + style: "currency", + currency: "EUR", + }).format(amount); + }, []); + + const formatDate = useCallback((dateStr: string) => { + return new Date(dateStr).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "short", + year: "numeric", + }); + }, []); + + const toggleExpand = useCallback((key: string) => { + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }, []); + + const handleCreateRule = useCallback((group: TransactionGroup) => { + setSelectedGroup(group); + setIsDialogOpen(true); + }, []); + + const handleSaveRule = useCallback( + async (ruleData: { + keyword: string; + categoryId: string; + applyToExisting: boolean; + transactionIds: string[]; + }) => { + if (!data) return; + + // 1. Add keyword to category + const category = data.categories.find((c) => c.id === ruleData.categoryId); + if (!category) { + throw new Error("Category not found"); + } + + // Check if keyword already exists + const keywordExists = category.keywords.some( + (k) => k.toLowerCase() === ruleData.keyword.toLowerCase() + ); + + if (!keywordExists) { + await updateCategory({ + ...category, + keywords: [...category.keywords, ruleData.keyword], + }); + } + + // 2. Apply to existing transactions if requested + if (ruleData.applyToExisting) { + const transactions = data.transactions.filter((t) => + ruleData.transactionIds.includes(t.id) + ); + + await Promise.all( + transactions.map((t) => + updateTransaction({ ...t, categoryId: ruleData.categoryId }) + ) + ); + } + + refresh(); + }, + [data, refresh] + ); + + const handleAutoCategorize = useCallback(async () => { + if (!data) return; + + setIsAutoCategorizing(true); + try { + const uncategorized = data.transactions.filter((t) => !t.categoryId); + let categorizedCount = 0; + + for (const transaction of uncategorized) { + const categoryId = autoCategorize( + transaction.description + " " + (transaction.memo || ""), + data.categories + ); + if (categoryId) { + await updateTransaction({ ...transaction, categoryId }); + categorizedCount++; + } + } + + refresh(); + alert(`${categorizedCount} transaction(s) catégorisée(s) automatiquement`); + } catch (error) { + console.error("Error auto-categorizing:", error); + alert("Erreur lors de la catégorisation automatique"); + } finally { + setIsAutoCategorizing(false); + } + }, [data, refresh]); + + if (isLoading || !data) { + return ; + } + + return ( + + + {transactionGroups.length} groupe + {transactionGroups.length > 1 ? "s" : ""} de transactions similaires + {uncategorizedCount} non catégorisées + + } + actions={ + uncategorizedCount > 0 && ( + + ) + } + /> + + + + {transactionGroups.length === 0 ? ( +
+ +

+ {uncategorizedCount === 0 + ? "Toutes les transactions sont catégorisées !" + : "Aucun groupe trouvé"} +

+

+ {uncategorizedCount === 0 + ? "Continuez à importer des transactions pour voir les suggestions de règles." + : filterMinCount > 1 + ? `Essayez de réduire le filtre minimum à ${filterMinCount - 1}+ transactions.` + : "Modifiez vos critères de recherche."} +

+
+ ) : ( +
+ {transactionGroups.map((group) => ( + toggleExpand(group.key)} + onCreateRule={() => handleCreateRule(group)} + categories={data.categories} + formatCurrency={formatCurrency} + formatDate={formatDate} + /> + ))} +
+ )} + + +
+ ); +} + diff --git a/components/dashboard/sidebar.tsx b/components/dashboard/sidebar.tsx index 09cfab0..46842b1 100644 --- a/components/dashboard/sidebar.tsx +++ b/components/dashboard/sidebar.tsx @@ -15,6 +15,7 @@ import { ChevronLeft, ChevronRight, Settings, + Wand2, } from "lucide-react"; const navItems = [ @@ -23,6 +24,7 @@ const navItems = [ { href: "/folders", label: "Organisation", icon: FolderTree }, { href: "/transactions", label: "Transactions", icon: Upload }, { href: "/categories", label: "Catégories", icon: Tags }, + { href: "/rules", label: "Règles", icon: Wand2 }, { href: "/statistics", label: "Statistiques", icon: BarChart3 }, ]; diff --git a/components/rules/constants.ts b/components/rules/constants.ts new file mode 100644 index 0000000..e3efd9c --- /dev/null +++ b/components/rules/constants.ts @@ -0,0 +1,95 @@ +// Minimum transactions in a group to be displayed +export const MIN_GROUP_SIZE = 1; + +// Common words to filter out when suggesting keywords +export const STOP_WORDS = [ + "de", + "du", + "la", + "le", + "les", + "des", + "un", + "une", + "et", + "ou", + "par", + "pour", + "avec", + "sur", + "dans", + "en", + "au", + "aux", + "ce", + "cette", + "ces", + "mon", + "ma", + "mes", + "ton", + "ta", + "tes", + "son", + "sa", + "ses", + "notre", + "nos", + "votre", + "vos", + "leur", + "leurs", +]; + +// Function to normalize transaction descriptions for grouping +export function normalizeDescription(description: string): string { + return description + .toLowerCase() + .replace(/\d{2}\/\d{2}\/\d{4}/g, "") // Remove dates + .replace(/\d{2}-\d{2}-\d{4}/g, "") // Remove dates + .replace(/\d+[.,]\d+/g, "") // Remove amounts + .replace(/carte \*+\d+/gi, "CARTE") // Normalize card numbers + .replace(/cb\*+\d+/gi, "CB") // Normalize CB numbers + .replace(/\s+/g, " ") // Normalize spaces + .replace(/[^\w\s]/g, " ") // Remove special chars + .trim(); +} + +// Extract meaningful keywords from description +export function extractKeywords(description: string): string[] { + const normalized = normalizeDescription(description); + const words = normalized.split(/\s+/); + + return words + .filter((word) => word.length > 2) + .filter((word) => !STOP_WORDS.includes(word.toLowerCase())) + .filter((word) => !/^\d+$/.test(word)); // Remove pure numbers +} + +// Suggest a keyword based on common patterns in descriptions +export function suggestKeyword(descriptions: string[]): string { + // Find common substrings + const keywords = descriptions.flatMap(extractKeywords); + const frequency: Record = {}; + + keywords.forEach((keyword) => { + frequency[keyword] = (frequency[keyword] || 0) + 1; + }); + + // Find the most frequent keyword that appears in most descriptions + const sorted = Object.entries(frequency) + .filter(([_, count]) => count >= Math.ceil(descriptions.length * 0.5)) + .sort((a, b) => b[1] - a[1]); + + if (sorted.length > 0) { + // Return the longest frequent keyword + return sorted.reduce((best, current) => + current[0].length > best[0].length ? current : best + )[0]; + } + + // Fallback: first meaningful word from first description + const firstKeywords = extractKeywords(descriptions[0]); + return firstKeywords[0] || descriptions[0].slice(0, 15); +} + diff --git a/components/rules/index.ts b/components/rules/index.ts new file mode 100644 index 0000000..28bd080 --- /dev/null +++ b/components/rules/index.ts @@ -0,0 +1,4 @@ +export { RuleGroupCard } from "./rule-group-card"; +export { RuleCreateDialog } from "./rule-create-dialog"; +export { RulesSearchBar } from "./rules-search-bar"; + diff --git a/components/rules/rule-create-dialog.tsx b/components/rules/rule-create-dialog.tsx new file mode 100644 index 0000000..55022fb --- /dev/null +++ b/components/rules/rule-create-dialog.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { useState, useMemo, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Checkbox } from "@/components/ui/checkbox"; +import { CategoryIcon } from "@/components/ui/category-icon"; +import { Tag, AlertCircle, CheckCircle2 } from "lucide-react"; +import type { Category, Transaction } from "@/lib/types"; + +interface TransactionGroup { + key: string; + displayName: string; + transactions: Transaction[]; + totalAmount: number; + suggestedKeyword: string; +} + +interface RuleCreateDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + group: TransactionGroup | null; + categories: Category[]; + onSave: (data: { + keyword: string; + categoryId: string; + applyToExisting: boolean; + transactionIds: string[]; + }) => Promise; +} + +export function RuleCreateDialog({ + open, + onOpenChange, + group, + categories, + onSave, +}: RuleCreateDialogProps) { + const [keyword, setKeyword] = useState(""); + const [categoryId, setCategoryId] = useState(""); + const [applyToExisting, setApplyToExisting] = useState(true); + const [isLoading, setIsLoading] = useState(false); + + // Reset form when group changes + useEffect(() => { + if (group) { + setKeyword(group.suggestedKeyword); + setCategoryId(""); + setApplyToExisting(true); + } + }, [group]); + + // Organize categories by parent + const { parentCategories, childrenByParent } = useMemo(() => { + const parents = categories.filter((c) => c.parentId === null); + const children: Record = {}; + + categories + .filter((c) => c.parentId !== null) + .forEach((child) => { + if (!children[child.parentId!]) { + children[child.parentId!] = []; + } + children[child.parentId!].push(child); + }); + + return { parentCategories: parents, childrenByParent: children }; + }, [categories]); + + // Check if keyword already exists in any category + const existingCategory = useMemo(() => { + if (!keyword) return null; + const lowerKeyword = keyword.toLowerCase(); + return categories.find((c) => + c.keywords.some((k) => k.toLowerCase() === lowerKeyword) + ); + }, [keyword, categories]); + + const selectedCategory = categories.find((c) => c.id === categoryId); + + const handleSave = async () => { + if (!keyword || !categoryId || !group) return; + + setIsLoading(true); + try { + await onSave({ + keyword, + categoryId, + applyToExisting, + transactionIds: group.transactions.map((t) => t.id), + }); + onOpenChange(false); + } catch (error) { + console.error("Failed to create rule:", error); + } finally { + setIsLoading(false); + } + }; + + if (!group) return null; + + return ( + + + + Créer une règle de catégorisation + + Associez un mot-clé à une catégorie pour catégoriser automatiquement + les transactions similaires. + + + +
+ {/* Group info */} +
+
+ {group.displayName} +
+
+ {group.transactions.length} transaction + {group.transactions.length > 1 ? "s" : ""} seront catégorisées +
+
+ + {/* Keyword input */} +
+ +
+ + setKeyword(e.target.value)} + placeholder="ex: spotify, carrefour, sncf..." + className="pl-10" + /> +
+

+ Les transactions contenant ce mot-clé seront automatiquement + catégorisées. +

+ {existingCategory && ( +
+ + + Ce mot-clé existe déjà dans "{existingCategory.name}" + +
+ )} +
+ + {/* Category select */} +
+ + + {selectedCategory && ( +
+ + Mots-clés actuels: + + {selectedCategory.keywords.length > 0 ? ( + selectedCategory.keywords.map((k, i) => ( + + {k} + + )) + ) : ( + + Aucun + + )} +
+ )} +
+ + {/* Apply to existing checkbox */} +
+ + setApplyToExisting(checked as boolean) + } + /> +
+ +

+ Catégoriser immédiatement les {group.transactions.length}{" "} + transaction + {group.transactions.length > 1 ? "s" : ""} de ce groupe +

+
+
+ + {/* Preview */} + {keyword && categoryId && ( +
+
+ + + Le mot-clé "{keyword}" sera ajouté à la + catégorie "{selectedCategory?.name}" + +
+
+ )} +
+ + + + + +
+
+ ); +} + diff --git a/components/rules/rule-group-card.tsx b/components/rules/rule-group-card.tsx new file mode 100644 index 0000000..d2ba39b --- /dev/null +++ b/components/rules/rule-group-card.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { ChevronDown, ChevronRight, Plus, Tag } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import type { Transaction, Category } from "@/lib/types"; + +interface TransactionGroup { + key: string; + displayName: string; + transactions: Transaction[]; + totalAmount: number; + suggestedKeyword: string; +} + +interface RuleGroupCardProps { + group: TransactionGroup; + isExpanded: boolean; + onToggleExpand: () => void; + onCreateRule: () => void; + categories: Category[]; + formatCurrency: (amount: number) => string; + formatDate: (date: string) => string; +} + +export function RuleGroupCard({ + group, + isExpanded, + onToggleExpand, + onCreateRule, + formatCurrency, + formatDate, +}: RuleGroupCardProps) { + const avgAmount = + group.transactions.reduce((sum, t) => sum + t.amount, 0) / + group.transactions.length; + const isDebit = avgAmount < 0; + + return ( +
+ {/* Header */} +
+ + +
+
+ + {group.displayName} + + + {group.transactions.length} transaction + {group.transactions.length > 1 ? "s" : ""} + +
+
+ + + {group.suggestedKeyword} + +
+
+ +
+
+
+ {formatCurrency(group.totalAmount)} +
+
+ Moy: {formatCurrency(avgAmount)} +
+
+ + +
+
+ + {/* Expanded transactions list */} + {isExpanded && ( +
+
+ + + + + + + + + + {group.transactions.map((transaction) => ( + + + + + + ))} + +
+ Date + + Description + + Montant +
+ {formatDate(transaction.date)} + + {transaction.description} + {transaction.memo && ( + + ({transaction.memo}) + + )} + + {formatCurrency(transaction.amount)} +
+
+
+ )} +
+ ); +} + diff --git a/components/rules/rules-search-bar.tsx b/components/rules/rules-search-bar.tsx new file mode 100644 index 0000000..8e3f69f --- /dev/null +++ b/components/rules/rules-search-bar.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Search, Filter, ArrowUpDown } from "lucide-react"; + +interface RulesSearchBarProps { + searchQuery: string; + onSearchChange: (value: string) => void; + sortBy: "count" | "amount" | "name"; + onSortChange: (value: "count" | "amount" | "name") => void; + filterMinCount: number; + onFilterMinCountChange: (value: number) => void; +} + +export function RulesSearchBar({ + searchQuery, + onSearchChange, + sortBy, + onSortChange, + filterMinCount, + onFilterMinCountChange, +}: RulesSearchBarProps) { + return ( +
+
+ + onSearchChange(e.target.value)} + className="pl-10" + /> +
+ +
+ + + +
+
+ ); +} +