"use client"; import { useEffect, useRef, useState, useCallback } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { CategoryCombobox } from "@/components/ui/category-combobox"; import { CheckCircle2, Circle, MoreVertical, ArrowUpDown, Wand2, Trash2, } from "lucide-react"; import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import { useIsMobile } from "@/hooks/use-mobile"; import type { Transaction, Account, Category } from "@/lib/types"; type SortField = "date" | "amount" | "description"; type SortOrder = "asc" | "desc"; interface TransactionTableProps { transactions: Transaction[]; accounts: Account[]; categories: Category[]; selectedTransactions: Set; sortField: SortField; sortOrder: SortOrder; onSortChange: (field: SortField) => void; onToggleSelectAll: () => void; onToggleSelectTransaction: (id: string) => void; onToggleReconciled: (id: string) => void; onMarkReconciled: (id: string) => void; onSetCategory: (transactionId: string, categoryId: string | null) => void; onCreateRule: (transaction: Transaction) => void; onDelete: (id: string) => void; formatCurrency: (amount: number) => string; formatDate: (dateStr: string) => string; } const ROW_HEIGHT = 72; // Hauteur approximative d'une ligne function DescriptionWithTooltip({ description }: { description: string }) { const ref = useRef(null); const [isTruncated, setIsTruncated] = useState(false); useEffect(() => { const checkTruncation = () => { const element = ref.current; if (!element) return; // Check if text is truncated by comparing scrollWidth and clientWidth // Add a small threshold (1px) to account for rounding issues const truncated = element.scrollWidth > element.clientWidth + 1; setIsTruncated(truncated); }; // Check multiple times to handle virtualization timing checkTruncation(); const timeout1 = setTimeout(checkTruncation, 0); const timeout2 = setTimeout(checkTruncation, 50); const timeout3 = setTimeout(checkTruncation, 150); const timeout4 = setTimeout(checkTruncation, 300); // Use ResizeObserver to detect size changes let resizeObserver: ResizeObserver | null = null; if (ref.current && typeof ResizeObserver !== "undefined") { resizeObserver = new ResizeObserver(() => { // Small delay for ResizeObserver to ensure layout is complete setTimeout(checkTruncation, 0); }); resizeObserver.observe(ref.current); } return () => { clearTimeout(timeout1); clearTimeout(timeout2); clearTimeout(timeout3); clearTimeout(timeout4); if (resizeObserver) { resizeObserver.disconnect(); } }; }, [description]); const content = ( {description} ); // Show tooltip if truncated or if description is reasonably long const shouldShowTooltip = isTruncated || description.length > 25; if (!shouldShowTooltip) { return content; } return ( {content} {description} ); } export function TransactionTable({ transactions, accounts, categories, selectedTransactions, sortField: _sortField, sortOrder: _sortOrder, onSortChange, onToggleSelectAll, onToggleSelectTransaction, onToggleReconciled, onMarkReconciled, onSetCategory, onCreateRule, onDelete, formatCurrency, formatDate, }: TransactionTableProps) { const [focusedIndex, setFocusedIndex] = useState(null); const parentRef = useRef(null); const isMobile = useIsMobile(); const MOBILE_ROW_HEIGHT = 120; const virtualizer = useVirtualizer({ count: transactions.length, getScrollElement: () => parentRef.current, estimateSize: () => (isMobile ? MOBILE_ROW_HEIGHT : ROW_HEIGHT), overscan: 10, }); const handleRowClick = useCallback( (index: number, transactionId: string) => { setFocusedIndex(index); onMarkReconciled(transactionId); }, [onMarkReconciled], ); const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (focusedIndex === null || transactions.length === 0) return; if (e.key === "ArrowDown") { e.preventDefault(); const newIndex = Math.min(focusedIndex + 1, transactions.length - 1); if (newIndex !== focusedIndex) { setFocusedIndex(newIndex); onMarkReconciled(transactions[newIndex].id); virtualizer.scrollToIndex(newIndex, { align: "start", }); } } else if (e.key === "ArrowUp") { e.preventDefault(); const newIndex = Math.max(focusedIndex - 1, 0); if (newIndex !== focusedIndex) { setFocusedIndex(newIndex); onMarkReconciled(transactions[newIndex].id); virtualizer.scrollToIndex(newIndex, { align: "start", }); } } }, [focusedIndex, transactions, onMarkReconciled, virtualizer], ); useEffect(() => { document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [handleKeyDown]); // Reset focused index when transactions change useEffect(() => { setFocusedIndex(null); }, [transactions.length]); const getAccount = useCallback( (accountId: string) => { return accounts.find((a) => a.id === accountId); }, [accounts], ); const getCategory = useCallback( (categoryId: string | null) => { if (!categoryId) return null; return categories.find((c) => c.id === categoryId); }, [categories], ); return ( {transactions.length === 0 ? (

Aucune transaction trouvée

) : isMobile ? (
{virtualizer.getVirtualItems().map((virtualRow) => { const transaction = transactions[virtualRow.index]; const account = getAccount(transaction.accountId); const _category = getCategory(transaction.categoryId); const isFocused = focusedIndex === virtualRow.index; return (
{ // Désactiver le pointage au clic sur mobile if (!isMobile) { handleRowClick(virtualRow.index, transaction.id); } }} className={cn( "p-4 space-y-3 hover:bg-muted/50 cursor-pointer border-b border-border", transaction.isReconciled && "bg-emerald-500/5", isFocused && "bg-primary/10 ring-1 ring-primary/30", )} >
{ onToggleSelectTransaction(transaction.id); }} onClick={(e) => e.stopPropagation()} />

{transaction.description}

{transaction.memo && (

{transaction.memo}

)}
= 0 ? "text-emerald-600" : "text-red-600", )} > {transaction.amount >= 0 ? "+" : ""} {formatCurrency(transaction.amount)}
{formatDate(transaction.date)} {account && ( • {account.name} )}
e.stopPropagation()} className="flex-1" > onSetCategory(transaction.id, categoryId) } showBadge align="start" />
e.stopPropagation()} > { e.stopPropagation(); onCreateRule(transaction); }} > Créer une règle { e.stopPropagation(); if ( confirm( `Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`, ) ) { onDelete(transaction.id); } }} className="text-red-600 focus:text-red-600" > Supprimer
); })}
) : (
{/* Header fixe */}
0 } onCheckedChange={onToggleSelectAll} />
Compte
Catégorie
Pointé
{/* Body virtualisé */}
{virtualizer.getVirtualItems().map((virtualRow) => { const transaction = transactions[virtualRow.index]; const account = getAccount(transaction.accountId); const isFocused = focusedIndex === virtualRow.index; return (
handleRowClick(virtualRow.index, transaction.id) } className={cn( "grid grid-cols-[auto_120px_2fr_150px_180px_140px_auto_auto] gap-0 border-b border-border hover:bg-muted/50 cursor-pointer", transaction.isReconciled && "bg-emerald-500/5", isFocused && "bg-primary/10 ring-1 ring-primary/30", )} >
onToggleSelectTransaction(transaction.id) } />
{formatDate(transaction.date)}
e.stopPropagation()} >

{transaction.description}

{transaction.memo && ( )}
{account?.name || "-"}
e.stopPropagation()}> onSetCategory(transaction.id, categoryId) } showBadge align="start" />
= 0 ? "text-emerald-600" : "text-red-600", )} > {transaction.amount >= 0 ? "+" : ""} {formatCurrency(transaction.amount)}
e.stopPropagation()} >
e.stopPropagation()}> { e.stopPropagation(); onToggleReconciled(transaction.id); }} > {transaction.isReconciled ? ( <> Dépointer ) : ( <> Pointer )} { e.stopPropagation(); onCreateRule(transaction); }} > Créer une règle { e.stopPropagation(); if ( confirm( `Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`, ) ) { onDelete(transaction.id); } }} className="text-red-600 focus:text-red-600" > Supprimer
); })}
)}
); }