feat: add transaction deduplication feature and enhance filtering options in settings and transactions pages

This commit is contained in:
Julien Froidefond
2025-11-30 13:02:03 +01:00
parent e087143675
commit d5aa00a885
9 changed files with 616 additions and 26 deletions

View File

@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { transactionService } from "@/services/transaction.service";
import { requireAuth } from "@/lib/auth-utils";
export async function POST() {
const authError = await requireAuth();
if (authError) return authError;
try {
const result = await transactionService.deduplicate();
return NextResponse.json(result);
} catch (error) {
console.error("Error deduplicating transactions:", error);
return NextResponse.json(
{ error: "Failed to deduplicate transactions" },
{ status: 500 }
);
}
}

View File

@@ -89,6 +89,24 @@ export default function SettingsPage() {
}
};
const deduplicateTransactions = async () => {
try {
const response = await fetch(
"/api/banking/transactions/deduplicate",
{
method: "POST",
}
);
if (!response.ok) throw new Error("Erreur");
const result = await response.json();
refresh();
return result;
} catch (error) {
console.error(error);
throw error;
}
};
const categorizedCount = data.transactions.filter((t) => t.categoryId).length;
return (
@@ -114,6 +132,7 @@ export default function SettingsPage() {
categorizedCount={categorizedCount}
onClearCategories={clearAllCategories}
onResetData={resetData}
onDeduplicate={deduplicateTransactions}
/>
<OFXInfoCard />

View File

@@ -23,6 +23,7 @@ import { CategoryFilterCombobox } from "@/components/ui/category-filter-combobox
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { CategoryIcon } from "@/components/ui/category-icon";
import { Checkbox } from "@/components/ui/checkbox";
import { Filter, X, Wallet, CircleSlash, Calendar } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
@@ -38,6 +39,7 @@ export default function StatisticsPage() {
const [period, setPeriod] = useState<Period>("6months");
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
const [selectedCategories, setSelectedCategories] = useState<string[]>(["all"]);
const [excludeInternalTransfers, setExcludeInternalTransfers] = useState(true);
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(undefined);
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(undefined);
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
@@ -69,6 +71,14 @@ export default function StatisticsPage() {
return undefined;
}, [period, customEndDate]);
// Find "Virement interne" category
const internalTransferCategory = useMemo(() => {
if (!data) return null;
return data.categories.find(
(c) => c.name.toLowerCase() === "virement interne"
);
}, [data]);
// Transactions filtered for account filter (by categories, period - not accounts)
const transactionsForAccountFilter = useMemo(() => {
if (!data) return [];
@@ -98,8 +108,14 @@ export default function StatisticsPage() {
}
}
return true;
}).filter((t) => {
// Exclude "Virement interne" category if checkbox is checked
if (excludeInternalTransfers && internalTransferCategory) {
return t.categoryId !== internalTransferCategory.id;
}
return true;
});
}, [data, startDate, endDate, selectedCategories]);
}, [data, startDate, endDate, selectedCategories, excludeInternalTransfers, internalTransferCategory]);
// Transactions filtered for category filter (by accounts, period - not categories)
const transactionsForCategoryFilter = useMemo(() => {
@@ -126,8 +142,14 @@ export default function StatisticsPage() {
return selectedAccounts.includes(t.accountId);
}
return true;
}).filter((t) => {
// Exclude "Virement interne" category if checkbox is checked
if (excludeInternalTransfers && internalTransferCategory) {
return t.categoryId !== internalTransferCategory.id;
}
return true;
});
}, [data, startDate, endDate, selectedAccounts]);
}, [data, startDate, endDate, selectedAccounts, excludeInternalTransfers, internalTransferCategory]);
const stats = useMemo(() => {
if (!data) return null;
@@ -163,6 +185,13 @@ export default function StatisticsPage() {
}
}
// Exclude "Virement interne" category if checkbox is checked
if (excludeInternalTransfers && internalTransferCategory) {
transactions = transactions.filter(
(t) => t.categoryId !== internalTransferCategory.id
);
}
// Monthly breakdown
const monthlyData = new Map<string, { income: number; expenses: number }>();
transactions.forEach((t) => {
@@ -211,10 +240,20 @@ export default function StatisticsPage() {
.sort((a, b) => b.value - a.value)
.slice(0, 8);
// Top expenses
const topExpenses = transactions
// Top expenses - deduplicate by ID and sort by amount (most negative first)
const uniqueTransactions = Array.from(
new Map(transactions.map((t) => [t.id, t])).values()
);
const topExpenses = uniqueTransactions
.filter((t) => t.amount < 0)
.sort((a, b) => a.amount - b.amount)
.sort((a, b) => {
// Sort by amount (most negative first)
if (a.amount !== b.amount) {
return a.amount - b.amount;
}
// If same amount, sort by date (most recent first) for stable sorting
return new Date(b.date).getTime() - new Date(a.date).getTime();
})
.slice(0, 5);
// Summary
@@ -259,6 +298,10 @@ export default function StatisticsPage() {
return false;
}
}
// Exclude "Virement interne" category if checkbox is checked
if (excludeInternalTransfers && internalTransferCategory) {
if (t.categoryId === internalTransferCategory.id) return false;
}
// Only transactions before startDate
const transactionDate = new Date(t.date);
return transactionDate < startDate;
@@ -308,6 +351,10 @@ export default function StatisticsPage() {
return false;
}
}
// Exclude "Virement interne" category if checkbox is checked
if (excludeInternalTransfers && internalTransferCategory) {
if (t.categoryId === internalTransferCategory.id) return false;
}
const transactionDate = new Date(t.date);
return transactionDate < startDate;
})
@@ -373,7 +420,7 @@ export default function StatisticsPage() {
perAccountBalanceData,
transactionCount: transactions.length,
};
}, [data, startDate, endDate, selectedAccounts, selectedCategories]);
}, [data, startDate, endDate, selectedAccounts, selectedCategories, excludeInternalTransfers, internalTransferCategory]);
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat("fr-FR", {
@@ -517,6 +564,22 @@ export default function StatisticsPage() {
</PopoverContent>
</Popover>
)}
{internalTransferCategory && (
<div className="flex items-center gap-2 px-3 py-2 border border-border rounded-md bg-[var(--card)]">
<Checkbox
id="exclude-internal-transfers"
checked={excludeInternalTransfers}
onCheckedChange={(checked) => setExcludeInternalTransfers(checked === true)}
/>
<label
htmlFor="exclude-internal-transfers"
className="text-sm font-medium cursor-pointer select-none"
>
Exclure Virement interne
</label>
</div>
)}
</div>
<ActiveFilters

View File

@@ -19,9 +19,12 @@ import {
normalizeDescription,
suggestKeyword,
} from "@/components/rules/constants";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
type SortField = "date" | "amount" | "description";
type SortOrder = "asc" | "desc";
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
export default function TransactionsPage() {
const searchParams = useSearchParams();
@@ -38,6 +41,10 @@ export default function TransactionsPage() {
const [selectedCategories, setSelectedCategories] = useState<string[]>(["all"]);
const [showReconciled, setShowReconciled] = useState<string>("all");
const [period, setPeriod] = useState<Period>("all");
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(undefined);
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(undefined);
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
const [sortField, setSortField] = useState<SortField>("date");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
@@ -46,12 +53,54 @@ export default function TransactionsPage() {
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(null);
// Transactions filtered for account filter (by categories, search, reconciled - not accounts)
// Get start date based on period
const startDate = useMemo(() => {
const now = new Date();
switch (period) {
case "1month":
return new Date(now.getFullYear(), now.getMonth() - 1, 1);
case "3months":
return new Date(now.getFullYear(), now.getMonth() - 3, 1);
case "6months":
return new Date(now.getFullYear(), now.getMonth() - 6, 1);
case "12months":
return new Date(now.getFullYear(), now.getMonth() - 12, 1);
case "custom":
return customStartDate || new Date(0);
default:
return new Date(0);
}
}, [period, customStartDate]);
// Get end date (only for custom period)
const endDate = useMemo(() => {
if (period === "custom" && customEndDate) {
return customEndDate;
}
return undefined;
}, [period, customEndDate]);
// Transactions filtered for account filter (by categories, search, reconciled, period - not accounts)
const transactionsForAccountFilter = useMemo(() => {
if (!data) return [];
let transactions = [...data.transactions];
// Filter by period
transactions = transactions.filter((t) => {
const transactionDate = new Date(t.date);
if (endDate) {
// Custom date range
const endOfDay = new Date(endDate);
endOfDay.setHours(23, 59, 59, 999);
return transactionDate >= startDate && transactionDate <= endOfDay;
} else if (period !== "all") {
// Standard period
return transactionDate >= startDate;
}
return true;
});
if (searchQuery) {
const query = searchQuery.toLowerCase();
transactions = transactions.filter(
@@ -79,14 +128,29 @@ export default function TransactionsPage() {
}
return transactions;
}, [data, searchQuery, selectedCategories, showReconciled]);
}, [data, searchQuery, selectedCategories, showReconciled, period, startDate, endDate]);
// Transactions filtered for category filter (by accounts, search, reconciled - not categories)
// Transactions filtered for category filter (by accounts, search, reconciled, period - not categories)
const transactionsForCategoryFilter = useMemo(() => {
if (!data) return [];
let transactions = [...data.transactions];
// Filter by period
transactions = transactions.filter((t) => {
const transactionDate = new Date(t.date);
if (endDate) {
// Custom date range
const endOfDay = new Date(endDate);
endOfDay.setHours(23, 59, 59, 999);
return transactionDate >= startDate && transactionDate <= endOfDay;
} else if (period !== "all") {
// Standard period
return transactionDate >= startDate;
}
return true;
});
if (searchQuery) {
const query = searchQuery.toLowerCase();
transactions = transactions.filter(
@@ -110,13 +174,28 @@ export default function TransactionsPage() {
}
return transactions;
}, [data, searchQuery, selectedAccounts, showReconciled]);
}, [data, searchQuery, selectedAccounts, showReconciled, period, startDate, endDate]);
const filteredTransactions = useMemo(() => {
if (!data) return [];
let transactions = [...data.transactions];
// Filter by period
transactions = transactions.filter((t) => {
const transactionDate = new Date(t.date);
if (endDate) {
// Custom date range
const endOfDay = new Date(endDate);
endOfDay.setHours(23, 59, 59, 999);
return transactionDate >= startDate && transactionDate <= endOfDay;
} else if (period !== "all") {
// Standard period
return transactionDate >= startDate;
}
return true;
});
if (searchQuery) {
const query = searchQuery.toLowerCase();
transactions = transactions.filter(
@@ -172,6 +251,9 @@ export default function TransactionsPage() {
selectedAccounts,
selectedCategories,
showReconciled,
period,
startDate,
endDate,
sortField,
sortOrder,
]);
@@ -424,6 +506,32 @@ export default function TransactionsPage() {
}
};
const deleteTransaction = async (transactionId: string) => {
// Optimistic update
const updatedTransactions = data.transactions.filter(
(t) => t.id !== transactionId
);
update({ ...data, transactions: updatedTransactions });
// Remove from selected if selected
const newSelected = new Set(selectedTransactions);
newSelected.delete(transactionId);
setSelectedTransactions(newSelected);
try {
const response = await fetch(
`/api/banking/transactions?id=${transactionId}`,
{
method: "DELETE",
}
);
if (!response.ok) throw new Error("Failed to delete transaction");
} catch (error) {
console.error("Failed to delete transaction:", error);
refresh(); // Revert on error
}
};
return (
<PageLayout>
<PageHeader
@@ -448,6 +556,21 @@ export default function TransactionsPage() {
onCategoriesChange={setSelectedCategories}
showReconciled={showReconciled}
onReconciledChange={setShowReconciled}
period={period}
onPeriodChange={(p) => {
setPeriod(p);
if (p !== "custom") {
setIsCustomDatePickerOpen(false);
} else {
setIsCustomDatePickerOpen(true);
}
}}
customStartDate={customStartDate}
customEndDate={customEndDate}
onCustomStartDateChange={setCustomStartDate}
onCustomEndDateChange={setCustomEndDate}
isCustomDatePickerOpen={isCustomDatePickerOpen}
onCustomDatePickerOpenChange={setIsCustomDatePickerOpen}
accounts={data.accounts}
folders={data.folders}
categories={data.categories}
@@ -476,6 +599,7 @@ export default function TransactionsPage() {
onMarkReconciled={markReconciled}
onSetCategory={setCategory}
onCreateRule={handleCreateRule}
onDelete={deleteTransaction}
formatCurrency={formatCurrency}
formatDate={formatDate}
/>

View File

@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -19,19 +20,39 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Trash2, Tags } from "lucide-react";
import { Trash2, Tags, Copy } from "lucide-react";
interface DangerZoneCardProps {
categorizedCount: number;
onClearCategories: () => void;
onResetData: () => void;
onDeduplicate: () => Promise<{ deletedCount: number; duplicatesFound: number }>;
}
export function DangerZoneCard({
categorizedCount,
onClearCategories,
onResetData,
onDeduplicate,
}: DangerZoneCardProps) {
const [deduplicating, setDeduplicating] = useState(false);
const handleDeduplicate = async () => {
setDeduplicating(true);
try {
const result = await onDeduplicate();
if (result.deletedCount > 0) {
alert(`${result.deletedCount} transaction${result.deletedCount > 1 ? "s" : ""} en double supprimée${result.deletedCount > 1 ? "s" : ""}`);
} else {
alert("Aucun doublon trouvé");
}
} catch (error) {
console.error(error);
alert("Erreur lors du dédoublonnage");
} finally {
setDeduplicating(false);
}
};
return (
<Card className="border-red-200">
<CardHeader>
@@ -44,6 +65,48 @@ export function DangerZoneCard({
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* Dédoublonnage */}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
className="w-full justify-start border-blue-300 text-blue-700 hover:bg-blue-50"
disabled={deduplicating}
>
<Copy className="w-4 h-4 mr-2" />
Dédoublonner les transactions
{deduplicating && (
<span className="ml-auto text-xs text-muted-foreground">
En cours...
</span>
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Dédoublonner les transactions ?
</AlertDialogTitle>
<AlertDialogDescription>
Cette action va rechercher et supprimer les transactions en double
dans votre base de données. Les critères de dédoublonnage sont :
même compte, même date, même montant et même libellé. La première
transaction trouvée sera conservée, les autres seront supprimées.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annuler</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeduplicate}
className="bg-blue-600 hover:bg-blue-700"
disabled={deduplicating}
>
{deduplicating ? "Dédoublonnage..." : "Dédoublonner"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Supprimer catégories des opérations */}
<AlertDialog>
<AlertDialogTrigger asChild>

View File

@@ -13,9 +13,16 @@ import {
import { CategoryFilterCombobox } from "@/components/ui/category-filter-combobox";
import { AccountFilterCombobox } from "@/components/ui/account-filter-combobox";
import { CategoryIcon } from "@/components/ui/category-icon";
import { Search, X, Filter, Wallet } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
import { Button } from "@/components/ui/button";
import { Search, X, Filter, Wallet, Calendar } from "lucide-react";
import { format } from "date-fns";
import { fr } from "date-fns/locale";
import type { Account, Category, Folder, Transaction } from "@/lib/types";
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
interface TransactionFiltersProps {
searchQuery: string;
onSearchChange: (query: string) => void;
@@ -25,11 +32,19 @@ interface TransactionFiltersProps {
onCategoriesChange: (categories: string[]) => void;
showReconciled: string;
onReconciledChange: (value: string) => void;
period: Period;
onPeriodChange: (period: Period) => void;
customStartDate?: Date;
customEndDate?: Date;
onCustomStartDateChange: (date: Date | undefined) => void;
onCustomEndDateChange: (date: Date | undefined) => void;
isCustomDatePickerOpen: boolean;
onCustomDatePickerOpenChange: (open: boolean) => void;
accounts: Account[];
folders: Folder[];
categories: Category[];
transactionsForAccountFilter?: Transaction[]; // Filtered by categories, search, reconciled (not accounts)
transactionsForCategoryFilter?: Transaction[]; // Filtered by accounts, search, reconciled (not categories)
transactionsForAccountFilter?: Transaction[]; // Filtered by categories, search, reconciled, period (not accounts)
transactionsForCategoryFilter?: Transaction[]; // Filtered by accounts, search, reconciled, period (not categories)
}
export function TransactionFilters({
@@ -41,6 +56,14 @@ export function TransactionFilters({
onCategoriesChange,
showReconciled,
onReconciledChange,
period,
onPeriodChange,
customStartDate,
customEndDate,
onCustomStartDateChange,
onCustomEndDateChange,
isCustomDatePickerOpen,
onCustomDatePickerOpenChange,
accounts,
folders,
categories,
@@ -90,6 +113,111 @@ export function TransactionFilters({
<SelectItem value="not-reconciled">Non pointées</SelectItem>
</SelectContent>
</Select>
<Select
value={period}
onValueChange={(v) => {
onPeriodChange(v as Period);
if (v !== "custom") {
onCustomDatePickerOpenChange(false);
} else {
onCustomDatePickerOpenChange(true);
}
}}
>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Période" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1month">1 mois</SelectItem>
<SelectItem value="3months">3 mois</SelectItem>
<SelectItem value="6months">6 mois</SelectItem>
<SelectItem value="12months">12 mois</SelectItem>
<SelectItem value="custom">Personnalisé</SelectItem>
<SelectItem value="all">Tout</SelectItem>
</SelectContent>
</Select>
{period === "custom" && (
<Popover open={isCustomDatePickerOpen} onOpenChange={onCustomDatePickerOpenChange}>
<PopoverTrigger asChild>
<Button variant="outline" className="w-[280px] justify-start text-left font-normal">
<Calendar className="mr-2 h-4 w-4" />
{customStartDate && customEndDate ? (
<>
{format(customStartDate, "PPP", { locale: fr })} -{" "}
{format(customEndDate, "PPP", { locale: fr })}
</>
) : customStartDate ? (
format(customStartDate, "PPP", { locale: fr })
) : (
<span className="text-muted-foreground">Sélectionner les dates</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="p-4 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Date de début</label>
<CalendarComponent
mode="single"
selected={customStartDate}
onSelect={(date) => {
onCustomStartDateChange(date);
if (date && customEndDate && date > customEndDate) {
onCustomEndDateChange(undefined);
}
}}
locale={fr}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Date de fin</label>
<CalendarComponent
mode="single"
selected={customEndDate}
onSelect={(date) => {
if (date && customStartDate && date < customStartDate) {
return;
}
onCustomEndDateChange(date);
if (date && customStartDate) {
onCustomDatePickerOpenChange(false);
}
}}
disabled={(date) => {
if (!customStartDate) return true;
return date < customStartDate;
}}
locale={fr}
/>
</div>
{customStartDate && customEndDate && (
<div className="flex gap-2 pt-2 border-t">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => {
onCustomStartDateChange(undefined);
onCustomEndDateChange(undefined);
}}
>
Réinitialiser
</Button>
<Button
size="sm"
className="flex-1"
onClick={() => onCustomDatePickerOpenChange(false)}
>
Valider
</Button>
</div>
)}
</div>
</PopoverContent>
</Popover>
)}
</div>
<ActiveFilters
@@ -109,6 +237,14 @@ export function TransactionFilters({
onClearCategories={() => onCategoriesChange(["all"])}
showReconciled={showReconciled}
onClearReconciled={() => onReconciledChange("all")}
period={period}
onClearPeriod={() => {
onPeriodChange("all");
onCustomStartDateChange(undefined);
onCustomEndDateChange(undefined);
}}
customStartDate={customStartDate}
customEndDate={customEndDate}
accounts={accounts}
categories={categories}
/>
@@ -128,6 +264,10 @@ function ActiveFilters({
onClearCategories,
showReconciled,
onClearReconciled,
period,
onClearPeriod,
customStartDate,
customEndDate,
accounts,
categories,
}: {
@@ -141,6 +281,10 @@ function ActiveFilters({
onClearCategories: () => void;
showReconciled: string;
onClearReconciled: () => void;
period: Period;
onClearPeriod: () => void;
customStartDate?: Date;
customEndDate?: Date;
accounts: Account[];
categories: Category[];
}) {
@@ -148,8 +292,9 @@ function ActiveFilters({
const hasAccounts = !selectedAccounts.includes("all");
const hasCategories = !selectedCategories.includes("all");
const hasReconciled = showReconciled !== "all";
const hasPeriod = period !== "all";
const hasActiveFilters = hasSearch || hasAccounts || hasCategories || hasReconciled;
const hasActiveFilters = hasSearch || hasAccounts || hasCategories || hasReconciled || hasPeriod;
if (!hasActiveFilters) return null;
@@ -162,6 +307,7 @@ function ActiveFilters({
onClearAccounts();
onClearCategories();
onClearReconciled();
onClearPeriod();
};
return (
@@ -229,6 +375,26 @@ function ActiveFilters({
</Badge>
)}
{hasPeriod && (
<Badge variant="secondary" className="gap-1 text-xs font-normal">
<Calendar className="h-3 w-3" />
{period === "custom" && customStartDate && customEndDate
? `${format(customStartDate, "d MMM", { locale: fr })} - ${format(customEndDate, "d MMM yyyy", { locale: fr })}`
: period === "1month"
? "1 mois"
: period === "3months"
? "3 mois"
: period === "6months"
? "6 mois"
: period === "12months"
? "12 mois"
: "Période"}
<button onClick={onClearPeriod} className="ml-1 hover:text-foreground">
<X className="h-3 w-3" />
</button>
</Badge>
)}
<button
onClick={clearAll}
className="text-xs text-muted-foreground hover:text-foreground ml-auto"

View File

@@ -18,6 +18,7 @@ import {
MoreVertical,
ArrowUpDown,
Wand2,
Trash2,
} from "lucide-react";
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
@@ -40,6 +41,7 @@ interface TransactionTableProps {
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;
}
@@ -60,6 +62,7 @@ export function TransactionTable({
onMarkReconciled,
onSetCategory,
onCreateRule,
onDelete,
formatCurrency,
formatDate,
}: TransactionTableProps) {
@@ -319,6 +322,23 @@ export function TransactionTable({
<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>

View File

@@ -239,11 +239,36 @@ async function main() {
return dateA.localeCompare(dateB);
});
// Calculate balance
const balance = transactions.reduce((sum, t) => sum + parseAmount(t.amount), 0);
// Deduplicate transactions: same amount + same date + same libelle (description)
const seenTransactions = new Map<string, CSVTransaction>();
const uniqueTransactions: CSVTransaction[] = [];
let duplicatesCount = 0;
for (const transaction of transactions) {
const amount = parseAmount(transaction.amount);
const date = parseDate(transaction.date);
const description = transaction.libelle.trim();
// Create a unique key: date-amount-description
const key = `${date}-${amount}-${description}`;
if (!seenTransactions.has(key)) {
seenTransactions.set(key, transaction);
uniqueTransactions.push(transaction);
} else {
duplicatesCount++;
}
}
if (duplicatesCount > 0) {
console.log(`${duplicatesCount} doublons détectés et ignorés (même date, montant, libellé)`);
}
// Calculate balance from unique transactions
const balance = uniqueTransactions.reduce((sum, t) => sum + parseAmount(t.amount), 0);
// Prepare transactions for insertion
const dbTransactions = transactions.map((transaction, index) => {
const dbTransactions = uniqueTransactions.map((transaction, index) => {
const amount = parseAmount(transaction.amount);
const date = parseDate(transaction.date);

View File

@@ -8,25 +8,56 @@ export interface CreateManyResult {
export const transactionService = {
async createMany(transactions: Transaction[]): Promise<CreateManyResult> {
// Filter out duplicates based on fitId (business rule)
const existingTransactions = await prisma.transaction.findMany({
// Get unique account IDs
const accountIds = [...new Set(transactions.map((t) => t.accountId))];
// Check for existing transactions by fitId
const existingByFitId = await prisma.transaction.findMany({
where: {
accountId: { in: transactions.map((t) => t.accountId) },
accountId: { in: accountIds },
fitId: { in: transactions.map((t) => t.fitId) },
},
select: {
accountId: true,
fitId: true,
date: true,
amount: true,
description: true,
},
});
const existingSet = new Set(
existingTransactions.map((t) => `${t.accountId}-${t.fitId}`),
// Get all existing transactions for these accounts to check duplicates by criteria
const allExistingTransactions = await prisma.transaction.findMany({
where: {
accountId: { in: accountIds },
},
select: {
accountId: true,
date: true,
amount: true,
description: true,
},
});
// Create sets for fast lookup
const existingFitIdSet = new Set(
existingByFitId.map((t) => `${t.accountId}-${t.fitId}`),
);
// Create set for duplicates by amount + date + description
const existingCriteriaSet = new Set(
allExistingTransactions.map((t) =>
`${t.accountId}-${t.date}-${t.amount}-${t.description}`
),
);
const newTransactions = transactions.filter(
(t) => !existingSet.has(`${t.accountId}-${t.fitId}`),
);
// Filter out duplicates based on fitId OR (amount + date + description)
const newTransactions = transactions.filter((t) => {
const fitIdKey = `${t.accountId}-${t.fitId}`;
const criteriaKey = `${t.accountId}-${t.date}-${t.amount}-${t.description}`;
return !existingFitIdSet.has(fitIdKey) && !existingCriteriaSet.has(criteriaKey);
});
if (newTransactions.length === 0) {
return { count: 0, transactions: [] };
@@ -90,6 +121,65 @@ export const transactionService = {
where: { id },
});
},
async deduplicate(): Promise<{ deletedCount: number; duplicatesFound: number }> {
// Get all transactions grouped by account
const allTransactions = await prisma.transaction.findMany({
orderBy: [
{ accountId: "asc" },
{ date: "asc" },
{ id: "asc" }, // Keep the oldest transaction (first created)
],
select: {
id: true,
accountId: true,
date: true,
amount: true,
description: true,
},
});
// Group by account for efficient processing
const transactionsByAccount = new Map<string, typeof allTransactions>();
for (const transaction of allTransactions) {
if (!transactionsByAccount.has(transaction.accountId)) {
transactionsByAccount.set(transaction.accountId, []);
}
transactionsByAccount.get(transaction.accountId)!.push(transaction);
}
const duplicatesToDelete: string[] = [];
const seenKeys = new Set<string>();
// For each account, find duplicates
for (const [accountId, transactions] of transactionsByAccount.entries()) {
for (const transaction of transactions) {
const key = `${accountId}-${transaction.date}-${transaction.amount}-${transaction.description}`;
if (seenKeys.has(key)) {
// This is a duplicate, mark for deletion
duplicatesToDelete.push(transaction.id);
} else {
// First occurrence, keep it
seenKeys.add(key);
}
}
}
// Delete duplicates
if (duplicatesToDelete.length > 0) {
await prisma.transaction.deleteMany({
where: {
id: { in: duplicatesToDelete },
},
});
}
return {
deletedCount: duplicatesToDelete.length,
duplicatesFound: duplicatesToDelete.length,
};
},
};