feat: implement bulk account deletion and enhance account management with folder organization and drag-and-drop functionality

This commit is contained in:
Julien Froidefond
2025-11-30 12:00:29 +01:00
parent d663fbcbd0
commit c26ba9ddc6
16 changed files with 1188 additions and 261 deletions

View File

@@ -1,6 +1,7 @@
"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";
@@ -43,6 +44,8 @@ interface TransactionTableProps {
formatDate: (dateStr: string) => string;
}
const ROW_HEIGHT = 72; // Hauteur approximative d'une ligne
export function TransactionTable({
transactions,
accounts,
@@ -61,7 +64,14 @@ export function TransactionTable({
formatDate,
}: TransactionTableProps) {
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const rowRefs = useRef<(HTMLTableRowElement | null)[]>([]);
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: transactions.length,
getScrollElement: () => parentRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 10,
});
const handleRowClick = useCallback(
(index: number, transactionId: string) => {
@@ -81,9 +91,8 @@ export function TransactionTable({
if (newIndex !== focusedIndex) {
setFocusedIndex(newIndex);
onMarkReconciled(transactions[newIndex].id);
rowRefs.current[newIndex]?.scrollIntoView({
behavior: "smooth",
block: "nearest",
virtualizer.scrollToIndex(newIndex, {
align: "start",
});
}
} else if (e.key === "ArrowUp") {
@@ -92,14 +101,13 @@ export function TransactionTable({
if (newIndex !== focusedIndex) {
setFocusedIndex(newIndex);
onMarkReconciled(transactions[newIndex].id);
rowRefs.current[newIndex]?.scrollIntoView({
behavior: "smooth",
block: "nearest",
virtualizer.scrollToIndex(newIndex, {
align: "start",
});
}
}
},
[focusedIndex, transactions, onMarkReconciled]
[focusedIndex, transactions, onMarkReconciled, virtualizer]
);
useEffect(() => {
@@ -111,6 +119,7 @@ export function TransactionTable({
useEffect(() => {
setFocusedIndex(null);
}, [transactions.length]);
const getAccount = (accountId: string) => {
return accounts.find((a) => a.id === accountId);
};
@@ -124,87 +133,106 @@ export function TransactionTable({
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="p-3 text-left">
<Checkbox
checked={
selectedTransactions.size === transactions.length &&
transactions.length > 0
}
onCheckedChange={onToggleSelectAll}
/>
</th>
<th className="p-3 text-left">
<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>
</th>
<th className="p-3 text-left">
<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>
</th>
<th className="p-3 text-left text-sm font-medium text-muted-foreground">
Compte
</th>
<th className="p-3 text-left text-sm font-medium text-muted-foreground">
Catégorie
</th>
<th 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>
</th>
<th className="p-3 text-center text-sm font-medium text-muted-foreground">
Pointé
</th>
<th className="p-3"></th>
</tr>
</thead>
<tbody>
{transactions.map((transaction, index) => {
{/* 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 === index;
const isFocused = focusedIndex === virtualRow.index;
return (
<tr
<div
key={transaction.id}
ref={(el) => {
rowRefs.current[index] = el;
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
}}
onClick={() => handleRowClick(index, transaction.id)}
onClick={() => handleRowClick(virtualRow.index, transaction.id)}
className={cn(
"border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer",
"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"
)}
>
<td className="p-3">
<div className="p-3">
<Checkbox
checked={selectedTransactions.has(transaction.id)}
onCheckedChange={() =>
onToggleSelectTransaction(transaction.id)
}
/>
</td>
<td className="p-3 text-sm text-muted-foreground whitespace-nowrap">
</div>
<div className="p-3 text-sm text-muted-foreground whitespace-nowrap">
{formatDate(transaction.date)}
</td>
<td className="p-3">
</div>
<div className="p-3">
<p className="font-medium text-sm">
{transaction.description}
</p>
@@ -213,11 +241,11 @@ export function TransactionTable({
{transaction.memo}
</p>
)}
</td>
<td className="p-3 text-sm text-muted-foreground">
</div>
<div className="p-3 text-sm text-muted-foreground">
{account?.name || "-"}
</td>
<td className="p-3" onClick={(e) => e.stopPropagation()}>
</div>
<div className="p-3" onClick={(e) => e.stopPropagation()}>
<CategoryCombobox
categories={categories}
value={transaction.categoryId}
@@ -227,8 +255,8 @@ export function TransactionTable({
showBadge
align="start"
/>
</td>
<td
</div>
<div
className={cn(
"p-3 text-right font-semibold tabular-nums",
transaction.amount >= 0
@@ -238,8 +266,8 @@ export function TransactionTable({
>
{transaction.amount >= 0 ? "+" : ""}
{formatCurrency(transaction.amount)}
</td>
<td className="p-3 text-center" onClick={(e) => e.stopPropagation()}>
</div>
<div className="p-3 text-center" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => onToggleReconciled(transaction.id)}
className="p-1 hover:bg-muted rounded"
@@ -250,8 +278,8 @@ export function TransactionTable({
<Circle className="w-5 h-5 text-muted-foreground" />
)}
</button>
</td>
<td className="p-3" onClick={(e) => e.stopPropagation()}>
</div>
<div className="p-3" onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -293,12 +321,12 @@ export function TransactionTable({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
</div>
</div>
);
})}
</tbody>
</table>
</div>
</div>
</div>
)}
</CardContent>