feat: add duplicate transaction detection and display in transactions page, enhancing user experience with visual indicators for duplicates

This commit is contained in:
Julien Froidefond
2025-12-08 09:50:32 +01:00
parent cb8628ce39
commit ba4d112cb8
6 changed files with 223 additions and 55 deletions

View File

@@ -28,10 +28,12 @@ import {
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { Search, X, Filter, Wallet, Calendar } from "lucide-react";
import { Search, X, Filter, Wallet, Calendar, Copy } from "lucide-react";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import { useIsMobile } from "@/hooks/use-mobile";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import type { Account, Category, Folder, Transaction } from "@/lib/types";
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
@@ -53,6 +55,8 @@ interface TransactionFiltersProps {
onCustomEndDateChange: (date: Date | undefined) => void;
isCustomDatePickerOpen: boolean;
onCustomDatePickerOpenChange: (open: boolean) => void;
showDuplicates: boolean;
onShowDuplicatesChange: (show: boolean) => void;
accounts: Account[];
folders: Folder[];
categories: Category[];
@@ -77,6 +81,8 @@ export function TransactionFilters({
onCustomEndDateChange,
isCustomDatePickerOpen,
onCustomDatePickerOpenChange,
showDuplicates,
onShowDuplicatesChange,
accounts,
folders,
categories,
@@ -153,6 +159,23 @@ export function TransactionFilters({
</SelectContent>
</Select>
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-border bg-card">
<Checkbox
id="show-duplicates"
checked={showDuplicates}
onCheckedChange={(checked) =>
onShowDuplicatesChange(checked === true)
}
/>
<Label
htmlFor="show-duplicates"
className="text-sm font-normal cursor-pointer flex items-center gap-2"
>
<Copy className="h-4 w-4 text-muted-foreground" />
Afficher les doublons
</Label>
</div>
{period === "custom" && (
<Popover
open={isCustomDatePickerOpen}
@@ -256,7 +279,7 @@ export function TransactionFilters({
onRemoveCategory={(id) => {
const newCategories = selectedCategories.filter((c) => c !== id);
onCategoriesChange(
newCategories.length > 0 ? newCategories : ["all"],
newCategories.length > 0 ? newCategories : ["all"]
);
}}
onClearCategories={() => onCategoriesChange(["all"])}
@@ -282,7 +305,8 @@ export function TransactionFilters({
(!selectedAccounts.includes("all") ? selectedAccounts.length : 0) +
(!selectedCategories.includes("all") ? selectedCategories.length : 0) +
(showReconciled !== "all" ? 1 : 0) +
(period !== "all" ? 1 : 0);
(period !== "all" ? 1 : 0) +
(showDuplicates ? 1 : 0);
return (
<>
@@ -367,7 +391,7 @@ function ActiveFilters({
const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id));
const selectedCats = categories.filter((c) =>
selectedCategories.includes(c.id),
selectedCategories.includes(c.id)
);
const isUncategorized = selectedCategories.includes("uncategorized");

View File

@@ -25,6 +25,7 @@ import {
Wand2,
Trash2,
Loader2,
AlertTriangle,
} from "lucide-react";
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
@@ -52,6 +53,8 @@ interface TransactionTableProps {
formatCurrency: (amount: number) => string;
formatDate: (dateStr: string) => string;
updatingTransactionIds?: Set<string>;
duplicateIds?: Set<string>;
highlightDuplicates?: boolean;
}
const ROW_HEIGHT = 72; // Hauteur approximative d'une ligne
@@ -145,6 +148,8 @@ export function TransactionTable({
formatCurrency,
formatDate,
updatingTransactionIds = new Set(),
duplicateIds = new Set(),
highlightDuplicates = false,
}: TransactionTableProps) {
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const parentRef = useRef<HTMLDivElement>(null);
@@ -164,7 +169,7 @@ export function TransactionTable({
setFocusedIndex(index);
onMarkReconciled(transactionId);
},
[onMarkReconciled],
[onMarkReconciled]
);
const handleKeyDown = useCallback(
@@ -193,7 +198,7 @@ export function TransactionTable({
}
}
},
[focusedIndex, transactions, onMarkReconciled, virtualizer],
[focusedIndex, transactions, onMarkReconciled, virtualizer]
);
useEffect(() => {
@@ -210,7 +215,7 @@ export function TransactionTable({
(accountId: string) => {
return accounts.find((a) => a.id === accountId);
},
[accounts],
[accounts]
);
const getCategory = useCallback(
@@ -218,7 +223,7 @@ export function TransactionTable({
if (!categoryId) return null;
return categories.find((c) => c.id === categoryId);
},
[categories],
[categories]
);
return (
@@ -247,6 +252,8 @@ export function TransactionTable({
const account = getAccount(transaction.accountId);
const _category = getCategory(transaction.categoryId);
const isFocused = focusedIndex === virtualRow.index;
const isDuplicate =
highlightDuplicates && duplicateIds.has(transaction.id);
return (
<div
@@ -259,6 +266,10 @@ export function TransactionTable({
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
...(isDuplicate && {
backgroundColor: "rgb(254 252 232)", // yellow-50
borderLeft: "4px solid rgb(234 179 8)", // yellow-500
}),
}}
onClick={() => {
// Désactiver le pointage au clic sur mobile
@@ -270,6 +281,7 @@ export function TransactionTable({
"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",
isDuplicate && "shadow-sm"
)}
>
<div className="flex items-start justify-between gap-2">
@@ -282,9 +294,23 @@ export function TransactionTable({
onClick={(e) => e.stopPropagation()}
/>
<div className="flex-1 min-w-0">
<p className="font-medium text-xs md:text-sm truncate">
{transaction.description}
</p>
<div className="flex items-center gap-2">
<p className="font-medium text-xs md:text-sm truncate">
{transaction.description}
</p>
{isDuplicate && (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<AlertTriangle className="h-3 w-3 md:h-4 md:w-4 text-[var(--warning)] shrink-0" />
</TooltipTrigger>
<TooltipContent>
<p>
Transaction en double (même somme et date)
</p>
</TooltipContent>
</Tooltip>
)}
</div>
{transaction.memo && (
<p className="text-[10px] md:text-xs text-muted-foreground truncate mt-1">
{transaction.memo}
@@ -297,7 +323,7 @@ export function TransactionTable({
"font-semibold tabular-nums text-sm md:text-base shrink-0",
transaction.amount >= 0
? "text-emerald-600"
: "text-red-600",
: "text-red-600"
)}
>
{transaction.amount >= 0 ? "+" : ""}
@@ -332,7 +358,7 @@ export function TransactionTable({
showBadge
align="start"
disabled={updatingTransactionIds.has(
transaction.id,
transaction.id
)}
/>
</div>
@@ -365,7 +391,7 @@ export function TransactionTable({
e.stopPropagation();
if (
confirm(
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`,
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`
)
) {
onDelete(transaction.id);
@@ -455,6 +481,8 @@ export function TransactionTable({
const transaction = transactions[virtualRow.index];
const account = getAccount(transaction.accountId);
const isFocused = focusedIndex === virtualRow.index;
const isDuplicate =
highlightDuplicates && duplicateIds.has(transaction.id);
return (
<div
@@ -467,6 +495,10 @@ export function TransactionTable({
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
...(isDuplicate && {
backgroundColor: "rgb(254 252 232)", // yellow-50
borderLeft: "4px solid rgb(234 179 8)", // yellow-500
}),
}}
onClick={() =>
handleRowClick(virtualRow.index, transaction.id)
@@ -475,6 +507,7 @@ export function TransactionTable({
"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",
isDuplicate && "shadow-sm"
)}
>
<div className="p-3">
@@ -492,9 +525,23 @@ export function TransactionTable({
className="p-3 min-w-0 overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<p className="font-medium text-sm truncate">
{transaction.description}
</p>
<div className="flex items-center gap-2">
<p className="font-medium text-sm truncate">
{transaction.description}
</p>
{isDuplicate && (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<AlertTriangle className="h-4 w-4 text-[var(--warning)] shrink-0" />
</TooltipTrigger>
<TooltipContent>
<p>
Transaction en double (même somme et date)
</p>
</TooltipContent>
</Tooltip>
)}
</div>
{transaction.memo && (
<DescriptionWithTooltip
description={transaction.memo}
@@ -529,7 +576,7 @@ export function TransactionTable({
"p-3 text-right font-semibold tabular-nums",
transaction.amount >= 0
? "text-emerald-600"
: "text-red-600",
: "text-red-600"
)}
>
{transaction.amount >= 0 ? "+" : ""}
@@ -596,7 +643,7 @@ export function TransactionTable({
e.stopPropagation();
if (
confirm(
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`,
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`
)
) {
onDelete(transaction.id);