"use client"; import { useState, useMemo, useCallback } from "react"; import { PageLayout, LoadingState, PageHeader } from "@/components/layout"; import { RuleGroupCard, RuleCreateDialog, RulesSearchBar, } from "@/components/rules"; import { useBankingMetadata, useTransactions } from "@/lib/hooks"; import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Sparkles, RefreshCw } from "lucide-react"; import { updateCategory, autoCategorize, } from "@/lib/store-db"; import { normalizeDescription, suggestKeyword, } from "@/components/rules/constants"; import type { Transaction } from "@/lib/types"; interface TransactionGroup { key: string; displayName: string; transactions: Transaction[]; totalAmount: number; suggestedKeyword: string; } export default function RulesPage() { const queryClient = useQueryClient(); const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata(); // Fetch uncategorized transactions only const { data: transactionsData, isLoading: isLoadingTransactions, invalidate: invalidateTransactions, } = useTransactions( { limit: 10000, // Large limit to get all uncategorized offset: 0, includeUncategorized: true, }, !!metadata, ); const refresh = useCallback(() => { invalidateTransactions(); queryClient.invalidateQueries({ queryKey: ["banking-metadata"] }); }, [invalidateTransactions, queryClient]); 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 (!transactionsData?.transactions) return []; const uncategorized = transactionsData.transactions; 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; }, [transactionsData?.transactions, searchQuery, sortBy, filterMinCount]); const uncategorizedCount = transactionsData?.total || 0; 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 (!metadata) return; // 1. Add keyword to category const category = metadata.categories.find( (c: { id: string }) => c.id === ruleData.categoryId, ); if (!category) { throw new Error("Category not found"); } // Check if keyword already exists const keywordExists = category.keywords.some( (k: string) => 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) { await Promise.all( ruleData.transactionIds.map((id) => fetch("/api/banking/transactions", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, categoryId: ruleData.categoryId }), }), ), ); } refresh(); }, [metadata, refresh], ); const handleAutoCategorize = useCallback(async () => { if (!metadata || !transactionsData) return; setIsAutoCategorizing(true); try { const uncategorized = transactionsData.transactions; let categorizedCount = 0; for (const transaction of uncategorized) { const categoryId = autoCategorize( transaction.description + " " + (transaction.memo || ""), metadata.categories, ); if (categoryId) { await fetch("/api/banking/transactions", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...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); } }, [metadata, transactionsData, refresh]); const handleCategorizeGroup = useCallback( async (group: TransactionGroup, categoryId: string | null) => { try { await Promise.all( group.transactions.map((t) => fetch("/api/banking/transactions", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...t, categoryId }), }), ), ); refresh(); } catch (error) { console.error("Error categorizing group:", error); alert("Erreur lors de la catégorisation"); } }, [refresh], ); if (isLoadingMetadata || !metadata || isLoadingTransactions || !transactionsData) { 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)} onCategorize={(categoryId) => handleCategorizeGroup(group, categoryId) } categories={metadata.categories} formatCurrency={formatCurrency} formatDate={formatDate} /> ))}
)}
); }