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

@@ -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