feat: add transaction deduplication feature and enhance filtering options in settings and transactions pages
This commit is contained in:
20
app/api/banking/transactions/deduplicate/route.ts
Normal file
20
app/api/banking/transactions/deduplicate/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user