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;
|
const categorizedCount = data.transactions.filter((t) => t.categoryId).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -114,6 +132,7 @@ export default function SettingsPage() {
|
|||||||
categorizedCount={categorizedCount}
|
categorizedCount={categorizedCount}
|
||||||
onClearCategories={clearAllCategories}
|
onClearCategories={clearAllCategories}
|
||||||
onResetData={resetData}
|
onResetData={resetData}
|
||||||
|
onDeduplicate={deduplicateTransactions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<OFXInfoCard />
|
<OFXInfoCard />
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { CategoryFilterCombobox } from "@/components/ui/category-filter-combobox
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Filter, X, Wallet, CircleSlash, Calendar } from "lucide-react";
|
import { Filter, X, Wallet, CircleSlash, Calendar } from "lucide-react";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
|
import { Calendar as CalendarComponent } from "@/components/ui/calendar";
|
||||||
@@ -38,6 +39,7 @@ export default function StatisticsPage() {
|
|||||||
const [period, setPeriod] = useState<Period>("6months");
|
const [period, setPeriod] = useState<Period>("6months");
|
||||||
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
|
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
|
||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>(["all"]);
|
const [selectedCategories, setSelectedCategories] = useState<string[]>(["all"]);
|
||||||
|
const [excludeInternalTransfers, setExcludeInternalTransfers] = useState(true);
|
||||||
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(undefined);
|
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(undefined);
|
||||||
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(undefined);
|
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(undefined);
|
||||||
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
|
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
|
||||||
@@ -69,6 +71,14 @@ export default function StatisticsPage() {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [period, customEndDate]);
|
}, [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)
|
// Transactions filtered for account filter (by categories, period - not accounts)
|
||||||
const transactionsForAccountFilter = useMemo(() => {
|
const transactionsForAccountFilter = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
@@ -98,8 +108,14 @@ export default function StatisticsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
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)
|
// Transactions filtered for category filter (by accounts, period - not categories)
|
||||||
const transactionsForCategoryFilter = useMemo(() => {
|
const transactionsForCategoryFilter = useMemo(() => {
|
||||||
@@ -126,8 +142,14 @@ export default function StatisticsPage() {
|
|||||||
return selectedAccounts.includes(t.accountId);
|
return selectedAccounts.includes(t.accountId);
|
||||||
}
|
}
|
||||||
return true;
|
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(() => {
|
const stats = useMemo(() => {
|
||||||
if (!data) return null;
|
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
|
// Monthly breakdown
|
||||||
const monthlyData = new Map<string, { income: number; expenses: number }>();
|
const monthlyData = new Map<string, { income: number; expenses: number }>();
|
||||||
transactions.forEach((t) => {
|
transactions.forEach((t) => {
|
||||||
@@ -211,10 +240,20 @@ export default function StatisticsPage() {
|
|||||||
.sort((a, b) => b.value - a.value)
|
.sort((a, b) => b.value - a.value)
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
|
|
||||||
// Top expenses
|
// Top expenses - deduplicate by ID and sort by amount (most negative first)
|
||||||
const topExpenses = transactions
|
const uniqueTransactions = Array.from(
|
||||||
|
new Map(transactions.map((t) => [t.id, t])).values()
|
||||||
|
);
|
||||||
|
const topExpenses = uniqueTransactions
|
||||||
.filter((t) => t.amount < 0)
|
.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);
|
.slice(0, 5);
|
||||||
|
|
||||||
// Summary
|
// Summary
|
||||||
@@ -259,6 +298,10 @@ export default function StatisticsPage() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Exclude "Virement interne" category if checkbox is checked
|
||||||
|
if (excludeInternalTransfers && internalTransferCategory) {
|
||||||
|
if (t.categoryId === internalTransferCategory.id) return false;
|
||||||
|
}
|
||||||
// Only transactions before startDate
|
// Only transactions before startDate
|
||||||
const transactionDate = new Date(t.date);
|
const transactionDate = new Date(t.date);
|
||||||
return transactionDate < startDate;
|
return transactionDate < startDate;
|
||||||
@@ -308,6 +351,10 @@ export default function StatisticsPage() {
|
|||||||
return false;
|
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);
|
const transactionDate = new Date(t.date);
|
||||||
return transactionDate < startDate;
|
return transactionDate < startDate;
|
||||||
})
|
})
|
||||||
@@ -373,7 +420,7 @@ export default function StatisticsPage() {
|
|||||||
perAccountBalanceData,
|
perAccountBalanceData,
|
||||||
transactionCount: transactions.length,
|
transactionCount: transactions.length,
|
||||||
};
|
};
|
||||||
}, [data, startDate, endDate, selectedAccounts, selectedCategories]);
|
}, [data, startDate, endDate, selectedAccounts, selectedCategories, excludeInternalTransfers, internalTransferCategory]);
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat("fr-FR", {
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
@@ -517,6 +564,22 @@ export default function StatisticsPage() {
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</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>
|
</div>
|
||||||
|
|
||||||
<ActiveFilters
|
<ActiveFilters
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ import {
|
|||||||
normalizeDescription,
|
normalizeDescription,
|
||||||
suggestKeyword,
|
suggestKeyword,
|
||||||
} from "@/components/rules/constants";
|
} from "@/components/rules/constants";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
|
||||||
type SortField = "date" | "amount" | "description";
|
type SortField = "date" | "amount" | "description";
|
||||||
type SortOrder = "asc" | "desc";
|
type SortOrder = "asc" | "desc";
|
||||||
|
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
|
||||||
|
|
||||||
export default function TransactionsPage() {
|
export default function TransactionsPage() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -38,6 +41,10 @@ export default function TransactionsPage() {
|
|||||||
|
|
||||||
const [selectedCategories, setSelectedCategories] = useState<string[]>(["all"]);
|
const [selectedCategories, setSelectedCategories] = useState<string[]>(["all"]);
|
||||||
const [showReconciled, setShowReconciled] = 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 [sortField, setSortField] = useState<SortField>("date");
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
||||||
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
|
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
|
||||||
@@ -46,12 +53,54 @@ export default function TransactionsPage() {
|
|||||||
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
||||||
const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(null);
|
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(() => {
|
const transactionsForAccountFilter = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
|
|
||||||
let transactions = [...data.transactions];
|
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) {
|
if (searchQuery) {
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
transactions = transactions.filter(
|
transactions = transactions.filter(
|
||||||
@@ -79,14 +128,29 @@ export default function TransactionsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return transactions;
|
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(() => {
|
const transactionsForCategoryFilter = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
|
|
||||||
let transactions = [...data.transactions];
|
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) {
|
if (searchQuery) {
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
transactions = transactions.filter(
|
transactions = transactions.filter(
|
||||||
@@ -110,13 +174,28 @@ export default function TransactionsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return transactions;
|
return transactions;
|
||||||
}, [data, searchQuery, selectedAccounts, showReconciled]);
|
}, [data, searchQuery, selectedAccounts, showReconciled, period, startDate, endDate]);
|
||||||
|
|
||||||
const filteredTransactions = useMemo(() => {
|
const filteredTransactions = useMemo(() => {
|
||||||
if (!data) return [];
|
if (!data) return [];
|
||||||
|
|
||||||
let transactions = [...data.transactions];
|
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) {
|
if (searchQuery) {
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
transactions = transactions.filter(
|
transactions = transactions.filter(
|
||||||
@@ -172,6 +251,9 @@ export default function TransactionsPage() {
|
|||||||
selectedAccounts,
|
selectedAccounts,
|
||||||
selectedCategories,
|
selectedCategories,
|
||||||
showReconciled,
|
showReconciled,
|
||||||
|
period,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
sortField,
|
sortField,
|
||||||
sortOrder,
|
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 (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -448,6 +556,21 @@ export default function TransactionsPage() {
|
|||||||
onCategoriesChange={setSelectedCategories}
|
onCategoriesChange={setSelectedCategories}
|
||||||
showReconciled={showReconciled}
|
showReconciled={showReconciled}
|
||||||
onReconciledChange={setShowReconciled}
|
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}
|
accounts={data.accounts}
|
||||||
folders={data.folders}
|
folders={data.folders}
|
||||||
categories={data.categories}
|
categories={data.categories}
|
||||||
@@ -476,6 +599,7 @@ export default function TransactionsPage() {
|
|||||||
onMarkReconciled={markReconciled}
|
onMarkReconciled={markReconciled}
|
||||||
onSetCategory={setCategory}
|
onSetCategory={setCategory}
|
||||||
onCreateRule={handleCreateRule}
|
onCreateRule={handleCreateRule}
|
||||||
|
onDelete={deleteTransaction}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
formatDate={formatDate}
|
formatDate={formatDate}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -19,19 +20,39 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Trash2, Tags } from "lucide-react";
|
import { Trash2, Tags, Copy } from "lucide-react";
|
||||||
|
|
||||||
interface DangerZoneCardProps {
|
interface DangerZoneCardProps {
|
||||||
categorizedCount: number;
|
categorizedCount: number;
|
||||||
onClearCategories: () => void;
|
onClearCategories: () => void;
|
||||||
onResetData: () => void;
|
onResetData: () => void;
|
||||||
|
onDeduplicate: () => Promise<{ deletedCount: number; duplicatesFound: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DangerZoneCard({
|
export function DangerZoneCard({
|
||||||
categorizedCount,
|
categorizedCount,
|
||||||
onClearCategories,
|
onClearCategories,
|
||||||
onResetData,
|
onResetData,
|
||||||
|
onDeduplicate,
|
||||||
}: DangerZoneCardProps) {
|
}: 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 (
|
return (
|
||||||
<Card className="border-red-200">
|
<Card className="border-red-200">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -44,6 +65,48 @@ export function DangerZoneCard({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<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 */}
|
{/* Supprimer catégories des opérations */}
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
|
|||||||
@@ -13,9 +13,16 @@ import {
|
|||||||
import { CategoryFilterCombobox } from "@/components/ui/category-filter-combobox";
|
import { CategoryFilterCombobox } from "@/components/ui/category-filter-combobox";
|
||||||
import { AccountFilterCombobox } from "@/components/ui/account-filter-combobox";
|
import { AccountFilterCombobox } from "@/components/ui/account-filter-combobox";
|
||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
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";
|
import type { Account, Category, Folder, Transaction } from "@/lib/types";
|
||||||
|
|
||||||
|
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
|
||||||
|
|
||||||
interface TransactionFiltersProps {
|
interface TransactionFiltersProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
onSearchChange: (query: string) => void;
|
onSearchChange: (query: string) => void;
|
||||||
@@ -25,11 +32,19 @@ interface TransactionFiltersProps {
|
|||||||
onCategoriesChange: (categories: string[]) => void;
|
onCategoriesChange: (categories: string[]) => void;
|
||||||
showReconciled: string;
|
showReconciled: string;
|
||||||
onReconciledChange: (value: string) => void;
|
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[];
|
accounts: Account[];
|
||||||
folders: Folder[];
|
folders: Folder[];
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
transactionsForAccountFilter?: Transaction[]; // Filtered by categories, search, reconciled (not accounts)
|
transactionsForAccountFilter?: Transaction[]; // Filtered by categories, search, reconciled, period (not accounts)
|
||||||
transactionsForCategoryFilter?: Transaction[]; // Filtered by accounts, search, reconciled (not categories)
|
transactionsForCategoryFilter?: Transaction[]; // Filtered by accounts, search, reconciled, period (not categories)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TransactionFilters({
|
export function TransactionFilters({
|
||||||
@@ -41,6 +56,14 @@ export function TransactionFilters({
|
|||||||
onCategoriesChange,
|
onCategoriesChange,
|
||||||
showReconciled,
|
showReconciled,
|
||||||
onReconciledChange,
|
onReconciledChange,
|
||||||
|
period,
|
||||||
|
onPeriodChange,
|
||||||
|
customStartDate,
|
||||||
|
customEndDate,
|
||||||
|
onCustomStartDateChange,
|
||||||
|
onCustomEndDateChange,
|
||||||
|
isCustomDatePickerOpen,
|
||||||
|
onCustomDatePickerOpenChange,
|
||||||
accounts,
|
accounts,
|
||||||
folders,
|
folders,
|
||||||
categories,
|
categories,
|
||||||
@@ -90,6 +113,111 @@ export function TransactionFilters({
|
|||||||
<SelectItem value="not-reconciled">Non pointées</SelectItem>
|
<SelectItem value="not-reconciled">Non pointées</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</div>
|
||||||
|
|
||||||
<ActiveFilters
|
<ActiveFilters
|
||||||
@@ -109,6 +237,14 @@ export function TransactionFilters({
|
|||||||
onClearCategories={() => onCategoriesChange(["all"])}
|
onClearCategories={() => onCategoriesChange(["all"])}
|
||||||
showReconciled={showReconciled}
|
showReconciled={showReconciled}
|
||||||
onClearReconciled={() => onReconciledChange("all")}
|
onClearReconciled={() => onReconciledChange("all")}
|
||||||
|
period={period}
|
||||||
|
onClearPeriod={() => {
|
||||||
|
onPeriodChange("all");
|
||||||
|
onCustomStartDateChange(undefined);
|
||||||
|
onCustomEndDateChange(undefined);
|
||||||
|
}}
|
||||||
|
customStartDate={customStartDate}
|
||||||
|
customEndDate={customEndDate}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
/>
|
/>
|
||||||
@@ -128,6 +264,10 @@ function ActiveFilters({
|
|||||||
onClearCategories,
|
onClearCategories,
|
||||||
showReconciled,
|
showReconciled,
|
||||||
onClearReconciled,
|
onClearReconciled,
|
||||||
|
period,
|
||||||
|
onClearPeriod,
|
||||||
|
customStartDate,
|
||||||
|
customEndDate,
|
||||||
accounts,
|
accounts,
|
||||||
categories,
|
categories,
|
||||||
}: {
|
}: {
|
||||||
@@ -141,6 +281,10 @@ function ActiveFilters({
|
|||||||
onClearCategories: () => void;
|
onClearCategories: () => void;
|
||||||
showReconciled: string;
|
showReconciled: string;
|
||||||
onClearReconciled: () => void;
|
onClearReconciled: () => void;
|
||||||
|
period: Period;
|
||||||
|
onClearPeriod: () => void;
|
||||||
|
customStartDate?: Date;
|
||||||
|
customEndDate?: Date;
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
categories: Category[];
|
categories: Category[];
|
||||||
}) {
|
}) {
|
||||||
@@ -148,8 +292,9 @@ function ActiveFilters({
|
|||||||
const hasAccounts = !selectedAccounts.includes("all");
|
const hasAccounts = !selectedAccounts.includes("all");
|
||||||
const hasCategories = !selectedCategories.includes("all");
|
const hasCategories = !selectedCategories.includes("all");
|
||||||
const hasReconciled = showReconciled !== "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;
|
if (!hasActiveFilters) return null;
|
||||||
|
|
||||||
@@ -162,6 +307,7 @@ function ActiveFilters({
|
|||||||
onClearAccounts();
|
onClearAccounts();
|
||||||
onClearCategories();
|
onClearCategories();
|
||||||
onClearReconciled();
|
onClearReconciled();
|
||||||
|
onClearPeriod();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -229,6 +375,26 @@ function ActiveFilters({
|
|||||||
</Badge>
|
</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
|
<button
|
||||||
onClick={clearAll}
|
onClick={clearAll}
|
||||||
className="text-xs text-muted-foreground hover:text-foreground ml-auto"
|
className="text-xs text-muted-foreground hover:text-foreground ml-auto"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
MoreVertical,
|
MoreVertical,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
Wand2,
|
Wand2,
|
||||||
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -40,6 +41,7 @@ interface TransactionTableProps {
|
|||||||
onMarkReconciled: (id: string) => void;
|
onMarkReconciled: (id: string) => void;
|
||||||
onSetCategory: (transactionId: string, categoryId: string | null) => void;
|
onSetCategory: (transactionId: string, categoryId: string | null) => void;
|
||||||
onCreateRule: (transaction: Transaction) => void;
|
onCreateRule: (transaction: Transaction) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
formatCurrency: (amount: number) => string;
|
formatCurrency: (amount: number) => string;
|
||||||
formatDate: (dateStr: string) => string;
|
formatDate: (dateStr: string) => string;
|
||||||
}
|
}
|
||||||
@@ -60,6 +62,7 @@ export function TransactionTable({
|
|||||||
onMarkReconciled,
|
onMarkReconciled,
|
||||||
onSetCategory,
|
onSetCategory,
|
||||||
onCreateRule,
|
onCreateRule,
|
||||||
|
onDelete,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
formatDate,
|
formatDate,
|
||||||
}: TransactionTableProps) {
|
}: TransactionTableProps) {
|
||||||
@@ -319,6 +322,23 @@ export function TransactionTable({
|
|||||||
<Wand2 className="w-4 h-4 mr-2" />
|
<Wand2 className="w-4 h-4 mr-2" />
|
||||||
Créer une règle
|
Créer une règle
|
||||||
</DropdownMenuItem>
|
</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>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -239,11 +239,36 @@ async function main() {
|
|||||||
return dateA.localeCompare(dateB);
|
return dateA.localeCompare(dateB);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate balance
|
// Deduplicate transactions: same amount + same date + same libelle (description)
|
||||||
const balance = transactions.reduce((sum, t) => sum + parseAmount(t.amount), 0);
|
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
|
// Prepare transactions for insertion
|
||||||
const dbTransactions = transactions.map((transaction, index) => {
|
const dbTransactions = uniqueTransactions.map((transaction, index) => {
|
||||||
const amount = parseAmount(transaction.amount);
|
const amount = parseAmount(transaction.amount);
|
||||||
const date = parseDate(transaction.date);
|
const date = parseDate(transaction.date);
|
||||||
|
|
||||||
|
|||||||
@@ -8,25 +8,56 @@ export interface CreateManyResult {
|
|||||||
|
|
||||||
export const transactionService = {
|
export const transactionService = {
|
||||||
async createMany(transactions: Transaction[]): Promise<CreateManyResult> {
|
async createMany(transactions: Transaction[]): Promise<CreateManyResult> {
|
||||||
// Filter out duplicates based on fitId (business rule)
|
// Get unique account IDs
|
||||||
const existingTransactions = await prisma.transaction.findMany({
|
const accountIds = [...new Set(transactions.map((t) => t.accountId))];
|
||||||
|
|
||||||
|
// Check for existing transactions by fitId
|
||||||
|
const existingByFitId = await prisma.transaction.findMany({
|
||||||
where: {
|
where: {
|
||||||
accountId: { in: transactions.map((t) => t.accountId) },
|
accountId: { in: accountIds },
|
||||||
fitId: { in: transactions.map((t) => t.fitId) },
|
fitId: { in: transactions.map((t) => t.fitId) },
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
accountId: true,
|
accountId: true,
|
||||||
fitId: true,
|
fitId: true,
|
||||||
|
date: true,
|
||||||
|
amount: true,
|
||||||
|
description: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const existingSet = new Set(
|
// Get all existing transactions for these accounts to check duplicates by criteria
|
||||||
existingTransactions.map((t) => `${t.accountId}-${t.fitId}`),
|
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(
|
// Filter out duplicates based on fitId OR (amount + date + description)
|
||||||
(t) => !existingSet.has(`${t.accountId}-${t.fitId}`),
|
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) {
|
if (newTransactions.length === 0) {
|
||||||
return { count: 0, transactions: [] };
|
return { count: 0, transactions: [] };
|
||||||
@@ -90,6 +121,65 @@ export const transactionService = {
|
|||||||
where: { id },
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user