585 lines
23 KiB
TypeScript
585 lines
23 KiB
TypeScript
"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<string>;
|
|
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<HTMLParagraphElement>(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 = (
|
|
<span ref={ref} className="text-xs text-muted-foreground truncate block">
|
|
{description}
|
|
</span>
|
|
);
|
|
|
|
// Show tooltip if truncated or if description is reasonably long
|
|
const shouldShowTooltip = isTruncated || description.length > 25;
|
|
|
|
if (!shouldShowTooltip) {
|
|
return content;
|
|
}
|
|
|
|
return (
|
|
<Tooltip delayDuration={200}>
|
|
<TooltipTrigger asChild>
|
|
{content}
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
side="top"
|
|
align="start"
|
|
className="max-w-md break-words"
|
|
sideOffset={5}
|
|
>
|
|
{description}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
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<number | null>(null);
|
|
const parentRef = useRef<HTMLDivElement>(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 (
|
|
<Card className="overflow-hidden">
|
|
<CardContent className="p-0">
|
|
{transactions.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12">
|
|
<p className="text-muted-foreground">Aucune transaction trouvée</p>
|
|
</div>
|
|
) : isMobile ? (
|
|
<div className="divide-y divide-border">
|
|
<div
|
|
ref={parentRef}
|
|
className="overflow-auto"
|
|
style={{ height: "calc(100vh - 300px)", minHeight: "400px" }}
|
|
>
|
|
<div
|
|
style={{
|
|
height: `${virtualizer.getTotalSize()}px`,
|
|
width: "100%",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
{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 (
|
|
<div
|
|
key={transaction.id}
|
|
data-index={virtualRow.index}
|
|
ref={virtualizer.measureElement}
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
width: "100%",
|
|
transform: `translateY(${virtualRow.start}px)`,
|
|
}}
|
|
onClick={() => {
|
|
// 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"
|
|
)}
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex items-start gap-3 flex-1 min-w-0">
|
|
<Checkbox
|
|
checked={selectedTransactions.has(transaction.id)}
|
|
onCheckedChange={() => {
|
|
onToggleSelectTransaction(transaction.id);
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-xs md:text-sm truncate">
|
|
{transaction.description}
|
|
</p>
|
|
{transaction.memo && (
|
|
<p className="text-[10px] md:text-xs text-muted-foreground truncate mt-1">
|
|
{transaction.memo}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
"font-semibold tabular-nums text-sm md:text-base shrink-0",
|
|
transaction.amount >= 0
|
|
? "text-emerald-600"
|
|
: "text-red-600"
|
|
)}
|
|
>
|
|
{transaction.amount >= 0 ? "+" : ""}
|
|
{formatCurrency(transaction.amount)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 md:gap-3 flex-wrap">
|
|
<span className="text-[10px] md:text-xs text-muted-foreground">
|
|
{formatDate(transaction.date)}
|
|
</span>
|
|
{account && (
|
|
<span className="text-[10px] md:text-xs text-muted-foreground">
|
|
• {account.name}
|
|
</span>
|
|
)}
|
|
<div onClick={(e) => e.stopPropagation()} className="flex-1">
|
|
<CategoryCombobox
|
|
categories={categories}
|
|
value={transaction.categoryId}
|
|
onChange={(categoryId) =>
|
|
onSetCategory(transaction.id, categoryId)
|
|
}
|
|
showBadge
|
|
align="start"
|
|
/>
|
|
</div>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 shrink-0"
|
|
>
|
|
<MoreVertical className="w-4 h-4" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onCreateRule(transaction);
|
|
}}
|
|
>
|
|
<Wand2 className="w-4 h-4 mr-2" />
|
|
Créer une règle
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={(e) => {
|
|
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"
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
Supprimer
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
{/* Header fixe */}
|
|
<div className="sticky top-0 z-10 bg-[var(--card)] border-b border-border">
|
|
<div className="grid grid-cols-[auto_120px_2fr_150px_180px_140px_auto_auto] gap-0">
|
|
<div className="p-3">
|
|
<Checkbox
|
|
checked={
|
|
selectedTransactions.size === transactions.length &&
|
|
transactions.length > 0
|
|
}
|
|
onCheckedChange={onToggleSelectAll}
|
|
/>
|
|
</div>
|
|
<div className="p-3">
|
|
<button
|
|
onClick={() => onSortChange("date")}
|
|
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
|
|
>
|
|
Date
|
|
<ArrowUpDown className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
<div className="p-3">
|
|
<button
|
|
onClick={() => onSortChange("description")}
|
|
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
|
|
>
|
|
Description
|
|
<ArrowUpDown className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
<div className="p-3 text-sm font-medium text-muted-foreground">
|
|
Compte
|
|
</div>
|
|
<div className="p-3 text-sm font-medium text-muted-foreground">
|
|
Catégorie
|
|
</div>
|
|
<div className="p-3 text-right">
|
|
<button
|
|
onClick={() => onSortChange("amount")}
|
|
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground ml-auto"
|
|
>
|
|
Montant
|
|
<ArrowUpDown className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
<div className="p-3 text-center text-sm font-medium text-muted-foreground">
|
|
Pointé
|
|
</div>
|
|
<div className="p-3"></div>
|
|
</div>
|
|
</div>
|
|
{/* Body virtualisé */}
|
|
<div
|
|
ref={parentRef}
|
|
className="overflow-auto"
|
|
style={{ height: "calc(100vh - 400px)", minHeight: "400px" }}
|
|
>
|
|
<div
|
|
style={{
|
|
height: `${virtualizer.getTotalSize()}px`,
|
|
width: "100%",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
const transaction = transactions[virtualRow.index];
|
|
const account = getAccount(transaction.accountId);
|
|
const isFocused = focusedIndex === virtualRow.index;
|
|
|
|
return (
|
|
<div
|
|
key={transaction.id}
|
|
data-index={virtualRow.index}
|
|
ref={virtualizer.measureElement}
|
|
style={{
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
width: "100%",
|
|
transform: `translateY(${virtualRow.start}px)`,
|
|
}}
|
|
onClick={() => 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"
|
|
)}
|
|
>
|
|
<div className="p-3">
|
|
<Checkbox
|
|
checked={selectedTransactions.has(transaction.id)}
|
|
onCheckedChange={() =>
|
|
onToggleSelectTransaction(transaction.id)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="p-3 text-sm text-muted-foreground whitespace-nowrap">
|
|
{formatDate(transaction.date)}
|
|
</div>
|
|
<div className="p-3 min-w-0 overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
|
<p className="font-medium text-sm truncate">
|
|
{transaction.description}
|
|
</p>
|
|
{transaction.memo && (
|
|
<DescriptionWithTooltip description={transaction.memo} />
|
|
)}
|
|
</div>
|
|
<div className="p-3 text-sm text-muted-foreground">
|
|
{account?.name || "-"}
|
|
</div>
|
|
<div className="p-3" onClick={(e) => e.stopPropagation()}>
|
|
<CategoryCombobox
|
|
categories={categories}
|
|
value={transaction.categoryId}
|
|
onChange={(categoryId) =>
|
|
onSetCategory(transaction.id, categoryId)
|
|
}
|
|
showBadge
|
|
align="start"
|
|
/>
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
"p-3 text-right font-semibold tabular-nums",
|
|
transaction.amount >= 0
|
|
? "text-emerald-600"
|
|
: "text-red-600"
|
|
)}
|
|
>
|
|
{transaction.amount >= 0 ? "+" : ""}
|
|
{formatCurrency(transaction.amount)}
|
|
</div>
|
|
<div className="p-3 text-center" onClick={(e) => e.stopPropagation()}>
|
|
<button
|
|
onClick={() => onToggleReconciled(transaction.id)}
|
|
className="p-1 hover:bg-muted rounded"
|
|
>
|
|
{transaction.isReconciled ? (
|
|
<CheckCircle2 className="w-5 h-5 text-emerald-600" />
|
|
) : (
|
|
<Circle className="w-5 h-5 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
<div className="p-3" onClick={(e) => e.stopPropagation()}>
|
|
<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={(e) => {
|
|
e.stopPropagation();
|
|
onToggleReconciled(transaction.id);
|
|
}}
|
|
>
|
|
{transaction.isReconciled ? (
|
|
<>
|
|
<Circle className="w-4 h-4 mr-2" />
|
|
Dépointer
|
|
</>
|
|
) : (
|
|
<>
|
|
<CheckCircle2 className="w-4 h-4 mr-2" />
|
|
Pointer
|
|
</>
|
|
)}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onCreateRule(transaction);
|
|
}}
|
|
>
|
|
<Wand2 className="w-4 h-4 mr-2" />
|
|
Créer une règle
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={(e) => {
|
|
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"
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
Supprimer
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|