feat: add duplicate transaction detection and display in transactions page, enhancing user experience with visual indicators for duplicates
This commit is contained in:
20
app/api/banking/duplicates/ids/route.ts
Normal file
20
app/api/banking/duplicates/ids/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 GET() {
|
||||
const authError = await requireAuth();
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const duplicateIds = await transactionService.getDuplicateIds();
|
||||
return NextResponse.json({ duplicateIds: Array.from(duplicateIds) });
|
||||
} catch (error) {
|
||||
console.error("Error finding duplicate IDs:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to find duplicate IDs" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
useBankingMetadata,
|
||||
useTransactions,
|
||||
getTransactionsQueryKey,
|
||||
useDuplicateIds,
|
||||
} from "@/lib/hooks";
|
||||
import { updateCategory } from "@/lib/store-db";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -65,24 +66,25 @@ export default function TransactionsPage() {
|
||||
const [showReconciled, setShowReconciled] = useState<string>("all");
|
||||
const [period, setPeriod] = useState<Period>("all");
|
||||
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(
|
||||
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>>(
|
||||
new Set(),
|
||||
new Set()
|
||||
);
|
||||
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
||||
const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(
|
||||
null,
|
||||
null
|
||||
);
|
||||
const [updatingTransactionIds, setUpdatingTransactionIds] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
const [showDuplicates, setShowDuplicates] = useState(false);
|
||||
|
||||
// Get start date based on period
|
||||
const startDate = useMemo(() => {
|
||||
@@ -164,6 +166,9 @@ export default function TransactionsPage() {
|
||||
invalidate: invalidateTransactions,
|
||||
} = useTransactions(transactionParams, !!metadata);
|
||||
|
||||
// Fetch duplicate IDs
|
||||
const { data: duplicateIds = new Set<string>() } = useDuplicateIds();
|
||||
|
||||
// For filter comboboxes, we'll use empty arrays for now
|
||||
// They can be enhanced later with separate queries if needed
|
||||
const transactionsForAccountFilter: Transaction[] = [];
|
||||
@@ -182,7 +187,7 @@ export default function TransactionsPage() {
|
||||
// Use transactions from current page to find similar ones
|
||||
const normalizedDesc = normalizeDescription(ruleTransaction.description);
|
||||
const similarTransactions = transactionsData.transactions.filter(
|
||||
(t) => normalizeDescription(t.description) === normalizedDesc,
|
||||
(t) => normalizeDescription(t.description) === normalizedDesc
|
||||
);
|
||||
|
||||
if (similarTransactions.length === 0) return null;
|
||||
@@ -193,7 +198,7 @@ export default function TransactionsPage() {
|
||||
transactions: similarTransactions,
|
||||
totalAmount: similarTransactions.reduce((sum, t) => sum + t.amount, 0),
|
||||
suggestedKeyword: suggestKeyword(
|
||||
similarTransactions.map((t) => t.description),
|
||||
similarTransactions.map((t) => t.description)
|
||||
),
|
||||
};
|
||||
}, [ruleTransaction, transactionsData]);
|
||||
@@ -209,7 +214,7 @@ export default function TransactionsPage() {
|
||||
|
||||
// 1. Add keyword to category
|
||||
const category = metadata.categories.find(
|
||||
(c: { id: string }) => c.id === ruleData.categoryId,
|
||||
(c: { id: string }) => c.id === ruleData.categoryId
|
||||
);
|
||||
if (!category) {
|
||||
throw new Error("Category not found");
|
||||
@@ -217,7 +222,7 @@ export default function TransactionsPage() {
|
||||
|
||||
// Check if keyword already exists
|
||||
const keywordExists = category.keywords.some(
|
||||
(k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase(),
|
||||
(k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase()
|
||||
);
|
||||
|
||||
if (!keywordExists) {
|
||||
@@ -235,8 +240,8 @@ export default function TransactionsPage() {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, categoryId: ruleData.categoryId }),
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -245,7 +250,7 @@ export default function TransactionsPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
||||
setRuleDialogOpen(false);
|
||||
},
|
||||
[metadata, queryClient],
|
||||
[metadata, queryClient]
|
||||
);
|
||||
|
||||
const invalidateAll = useCallback(() => {
|
||||
@@ -272,7 +277,7 @@ export default function TransactionsPage() {
|
||||
if (!transactionsData) return;
|
||||
|
||||
const transaction = transactionsData.transactions.find(
|
||||
(t) => t.id === transactionId,
|
||||
(t) => t.id === transactionId
|
||||
);
|
||||
if (!transaction) return;
|
||||
|
||||
@@ -297,7 +302,7 @@ export default function TransactionsPage() {
|
||||
if (!transactionsData) return;
|
||||
|
||||
const transaction = transactionsData.transactions.find(
|
||||
(t) => t.id === transactionId,
|
||||
(t) => t.id === transactionId
|
||||
);
|
||||
if (!transaction || transaction.isReconciled) return;
|
||||
|
||||
@@ -320,12 +325,12 @@ export default function TransactionsPage() {
|
||||
|
||||
const setCategory = async (
|
||||
transactionId: string,
|
||||
categoryId: string | null,
|
||||
categoryId: string | null
|
||||
) => {
|
||||
if (!transactionsData) return;
|
||||
|
||||
const transaction = transactionsData.transactions.find(
|
||||
(t) => t.id === transactionId,
|
||||
(t) => t.id === transactionId
|
||||
);
|
||||
if (!transaction) return;
|
||||
|
||||
@@ -350,7 +355,7 @@ export default function TransactionsPage() {
|
||||
return {
|
||||
...oldData,
|
||||
transactions: oldData.transactions.map((t) =>
|
||||
t.id === transactionId ? { ...t, categoryId } : t,
|
||||
t.id === transactionId ? { ...t, categoryId } : t
|
||||
),
|
||||
};
|
||||
});
|
||||
@@ -370,7 +375,7 @@ export default function TransactionsPage() {
|
||||
if (!transactionsData) return;
|
||||
|
||||
const transactionsToUpdate = transactionsData.transactions.filter((t) =>
|
||||
selectedTransactions.has(t.id),
|
||||
selectedTransactions.has(t.id)
|
||||
);
|
||||
|
||||
setSelectedTransactions(new Set());
|
||||
@@ -382,8 +387,8 @@ export default function TransactionsPage() {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...t, isReconciled: reconciled }),
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
invalidateTransactions();
|
||||
} catch (error) {
|
||||
@@ -395,7 +400,7 @@ export default function TransactionsPage() {
|
||||
if (!transactionsData) return;
|
||||
|
||||
const transactionsToUpdate = transactionsData.transactions.filter((t) =>
|
||||
selectedTransactions.has(t.id),
|
||||
selectedTransactions.has(t.id)
|
||||
);
|
||||
|
||||
const transactionIds = transactionsToUpdate.map((t) => t.id);
|
||||
@@ -413,8 +418,8 @@ export default function TransactionsPage() {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...t, categoryId }),
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Mise à jour directe du cache après succès
|
||||
@@ -424,7 +429,7 @@ export default function TransactionsPage() {
|
||||
return {
|
||||
...oldData,
|
||||
transactions: oldData.transactions.map((t) =>
|
||||
transactionIds.includes(t.id) ? { ...t, categoryId } : t,
|
||||
transactionIds.includes(t.id) ? { ...t, categoryId } : t
|
||||
),
|
||||
};
|
||||
});
|
||||
@@ -446,7 +451,7 @@ export default function TransactionsPage() {
|
||||
setSelectedTransactions(new Set());
|
||||
} else {
|
||||
setSelectedTransactions(
|
||||
new Set(transactionsData.transactions.map((t) => t.id)),
|
||||
new Set(transactionsData.transactions.map((t) => t.id))
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -488,7 +493,7 @@ export default function TransactionsPage() {
|
||||
return {
|
||||
...oldData,
|
||||
transactions: oldData.transactions.filter(
|
||||
(t) => t.id !== transactionId,
|
||||
(t) => t.id !== transactionId
|
||||
),
|
||||
total: oldData.total - 1,
|
||||
};
|
||||
@@ -499,13 +504,13 @@ export default function TransactionsPage() {
|
||||
`/api/banking/transactions?id=${transactionId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.error || `Failed to delete transaction: ${response.status}`,
|
||||
errorData.error || `Failed to delete transaction: ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -593,6 +598,8 @@ export default function TransactionsPage() {
|
||||
}}
|
||||
isCustomDatePickerOpen={isCustomDatePickerOpen}
|
||||
onCustomDatePickerOpenChange={setIsCustomDatePickerOpen}
|
||||
showDuplicates={showDuplicates}
|
||||
onShowDuplicatesChange={setShowDuplicates}
|
||||
accounts={metadata.accounts}
|
||||
folders={metadata.folders}
|
||||
categories={metadata.categories}
|
||||
@@ -631,6 +638,8 @@ export default function TransactionsPage() {
|
||||
formatCurrency={formatCurrency}
|
||||
formatDate={formatDate}
|
||||
updatingTransactionIds={updatingTransactionIds}
|
||||
duplicateIds={duplicateIds}
|
||||
highlightDuplicates={showDuplicates}
|
||||
/>
|
||||
|
||||
{/* Pagination controls */}
|
||||
|
||||
@@ -28,10 +28,12 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Search, X, Filter, Wallet, Calendar } from "lucide-react";
|
||||
import { Search, X, Filter, Wallet, Calendar, Copy } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { Account, Category, Folder, Transaction } from "@/lib/types";
|
||||
|
||||
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
|
||||
@@ -53,6 +55,8 @@ interface TransactionFiltersProps {
|
||||
onCustomEndDateChange: (date: Date | undefined) => void;
|
||||
isCustomDatePickerOpen: boolean;
|
||||
onCustomDatePickerOpenChange: (open: boolean) => void;
|
||||
showDuplicates: boolean;
|
||||
onShowDuplicatesChange: (show: boolean) => void;
|
||||
accounts: Account[];
|
||||
folders: Folder[];
|
||||
categories: Category[];
|
||||
@@ -77,6 +81,8 @@ export function TransactionFilters({
|
||||
onCustomEndDateChange,
|
||||
isCustomDatePickerOpen,
|
||||
onCustomDatePickerOpenChange,
|
||||
showDuplicates,
|
||||
onShowDuplicatesChange,
|
||||
accounts,
|
||||
folders,
|
||||
categories,
|
||||
@@ -153,6 +159,23 @@ export function TransactionFilters({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-border bg-card">
|
||||
<Checkbox
|
||||
id="show-duplicates"
|
||||
checked={showDuplicates}
|
||||
onCheckedChange={(checked) =>
|
||||
onShowDuplicatesChange(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="show-duplicates"
|
||||
className="text-sm font-normal cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<Copy className="h-4 w-4 text-muted-foreground" />
|
||||
Afficher les doublons
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{period === "custom" && (
|
||||
<Popover
|
||||
open={isCustomDatePickerOpen}
|
||||
@@ -256,7 +279,7 @@ export function TransactionFilters({
|
||||
onRemoveCategory={(id) => {
|
||||
const newCategories = selectedCategories.filter((c) => c !== id);
|
||||
onCategoriesChange(
|
||||
newCategories.length > 0 ? newCategories : ["all"],
|
||||
newCategories.length > 0 ? newCategories : ["all"]
|
||||
);
|
||||
}}
|
||||
onClearCategories={() => onCategoriesChange(["all"])}
|
||||
@@ -282,7 +305,8 @@ export function TransactionFilters({
|
||||
(!selectedAccounts.includes("all") ? selectedAccounts.length : 0) +
|
||||
(!selectedCategories.includes("all") ? selectedCategories.length : 0) +
|
||||
(showReconciled !== "all" ? 1 : 0) +
|
||||
(period !== "all" ? 1 : 0);
|
||||
(period !== "all" ? 1 : 0) +
|
||||
(showDuplicates ? 1 : 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -367,7 +391,7 @@ function ActiveFilters({
|
||||
|
||||
const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id));
|
||||
const selectedCats = categories.filter((c) =>
|
||||
selectedCategories.includes(c.id),
|
||||
selectedCategories.includes(c.id)
|
||||
);
|
||||
const isUncategorized = selectedCategories.includes("uncategorized");
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Wand2,
|
||||
Trash2,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -52,6 +53,8 @@ interface TransactionTableProps {
|
||||
formatCurrency: (amount: number) => string;
|
||||
formatDate: (dateStr: string) => string;
|
||||
updatingTransactionIds?: Set<string>;
|
||||
duplicateIds?: Set<string>;
|
||||
highlightDuplicates?: boolean;
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 72; // Hauteur approximative d'une ligne
|
||||
@@ -145,6 +148,8 @@ export function TransactionTable({
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
updatingTransactionIds = new Set(),
|
||||
duplicateIds = new Set(),
|
||||
highlightDuplicates = false,
|
||||
}: TransactionTableProps) {
|
||||
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
@@ -164,7 +169,7 @@ export function TransactionTable({
|
||||
setFocusedIndex(index);
|
||||
onMarkReconciled(transactionId);
|
||||
},
|
||||
[onMarkReconciled],
|
||||
[onMarkReconciled]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
@@ -193,7 +198,7 @@ export function TransactionTable({
|
||||
}
|
||||
}
|
||||
},
|
||||
[focusedIndex, transactions, onMarkReconciled, virtualizer],
|
||||
[focusedIndex, transactions, onMarkReconciled, virtualizer]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -210,7 +215,7 @@ export function TransactionTable({
|
||||
(accountId: string) => {
|
||||
return accounts.find((a) => a.id === accountId);
|
||||
},
|
||||
[accounts],
|
||||
[accounts]
|
||||
);
|
||||
|
||||
const getCategory = useCallback(
|
||||
@@ -218,7 +223,7 @@ export function TransactionTable({
|
||||
if (!categoryId) return null;
|
||||
return categories.find((c) => c.id === categoryId);
|
||||
},
|
||||
[categories],
|
||||
[categories]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -247,6 +252,8 @@ export function TransactionTable({
|
||||
const account = getAccount(transaction.accountId);
|
||||
const _category = getCategory(transaction.categoryId);
|
||||
const isFocused = focusedIndex === virtualRow.index;
|
||||
const isDuplicate =
|
||||
highlightDuplicates && duplicateIds.has(transaction.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -259,6 +266,10 @@ export function TransactionTable({
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
...(isDuplicate && {
|
||||
backgroundColor: "rgb(254 252 232)", // yellow-50
|
||||
borderLeft: "4px solid rgb(234 179 8)", // yellow-500
|
||||
}),
|
||||
}}
|
||||
onClick={() => {
|
||||
// Désactiver le pointage au clic sur mobile
|
||||
@@ -270,6 +281,7 @@ export function TransactionTable({
|
||||
"p-4 space-y-3 hover:bg-muted/50 cursor-pointer border-b border-border",
|
||||
transaction.isReconciled && "bg-emerald-500/5",
|
||||
isFocused && "bg-primary/10 ring-1 ring-primary/30",
|
||||
isDuplicate && "shadow-sm"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
@@ -282,9 +294,23 @@ export function TransactionTable({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-xs md:text-sm truncate">
|
||||
{transaction.description}
|
||||
</p>
|
||||
{isDuplicate && (
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertTriangle className="h-3 w-3 md:h-4 md:w-4 text-[var(--warning)] shrink-0" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Transaction en double (même somme et date)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{transaction.memo && (
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground truncate mt-1">
|
||||
{transaction.memo}
|
||||
@@ -297,7 +323,7 @@ export function TransactionTable({
|
||||
"font-semibold tabular-nums text-sm md:text-base shrink-0",
|
||||
transaction.amount >= 0
|
||||
? "text-emerald-600"
|
||||
: "text-red-600",
|
||||
: "text-red-600"
|
||||
)}
|
||||
>
|
||||
{transaction.amount >= 0 ? "+" : ""}
|
||||
@@ -332,7 +358,7 @@ export function TransactionTable({
|
||||
showBadge
|
||||
align="start"
|
||||
disabled={updatingTransactionIds.has(
|
||||
transaction.id,
|
||||
transaction.id
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -365,7 +391,7 @@ export function TransactionTable({
|
||||
e.stopPropagation();
|
||||
if (
|
||||
confirm(
|
||||
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`,
|
||||
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`
|
||||
)
|
||||
) {
|
||||
onDelete(transaction.id);
|
||||
@@ -455,6 +481,8 @@ export function TransactionTable({
|
||||
const transaction = transactions[virtualRow.index];
|
||||
const account = getAccount(transaction.accountId);
|
||||
const isFocused = focusedIndex === virtualRow.index;
|
||||
const isDuplicate =
|
||||
highlightDuplicates && duplicateIds.has(transaction.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -467,6 +495,10 @@ export function TransactionTable({
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
...(isDuplicate && {
|
||||
backgroundColor: "rgb(254 252 232)", // yellow-50
|
||||
borderLeft: "4px solid rgb(234 179 8)", // yellow-500
|
||||
}),
|
||||
}}
|
||||
onClick={() =>
|
||||
handleRowClick(virtualRow.index, transaction.id)
|
||||
@@ -475,6 +507,7 @@ export function TransactionTable({
|
||||
"grid grid-cols-[auto_120px_2fr_150px_180px_140px_auto_auto] gap-0 border-b border-border hover:bg-muted/50 cursor-pointer",
|
||||
transaction.isReconciled && "bg-emerald-500/5",
|
||||
isFocused && "bg-primary/10 ring-1 ring-primary/30",
|
||||
isDuplicate && "shadow-sm"
|
||||
)}
|
||||
>
|
||||
<div className="p-3">
|
||||
@@ -492,9 +525,23 @@ export function TransactionTable({
|
||||
className="p-3 min-w-0 overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-sm truncate">
|
||||
{transaction.description}
|
||||
</p>
|
||||
{isDuplicate && (
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertTriangle className="h-4 w-4 text-[var(--warning)] shrink-0" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Transaction en double (même somme et date)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{transaction.memo && (
|
||||
<DescriptionWithTooltip
|
||||
description={transaction.memo}
|
||||
@@ -529,7 +576,7 @@ export function TransactionTable({
|
||||
"p-3 text-right font-semibold tabular-nums",
|
||||
transaction.amount >= 0
|
||||
? "text-emerald-600"
|
||||
: "text-red-600",
|
||||
: "text-red-600"
|
||||
)}
|
||||
>
|
||||
{transaction.amount >= 0 ? "+" : ""}
|
||||
@@ -596,7 +643,7 @@ export function TransactionTable({
|
||||
e.stopPropagation();
|
||||
if (
|
||||
confirm(
|
||||
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`,
|
||||
`Êtes-vous sûr de vouloir supprimer cette transaction ?\n\n${transaction.description}\n${formatCurrency(transaction.amount)}`
|
||||
)
|
||||
) {
|
||||
onDelete(transaction.id);
|
||||
|
||||
21
lib/hooks.ts
21
lib/hooks.ts
@@ -83,7 +83,7 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
|
||||
|
||||
// Helper function to serialize transaction params into a query key
|
||||
export function getTransactionsQueryKey(
|
||||
params: TransactionsPaginatedParams = {},
|
||||
params: TransactionsPaginatedParams = {}
|
||||
): (string | number)[] {
|
||||
const key: (string | number)[] = ["transactions"];
|
||||
if (params.limit) key.push(`limit:${params.limit}`);
|
||||
@@ -106,7 +106,7 @@ export function getTransactionsQueryKey(
|
||||
|
||||
export function useTransactions(
|
||||
params: TransactionsPaginatedParams = {},
|
||||
enabled = true,
|
||||
enabled = true
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -134,7 +134,7 @@ export function useTransactions(
|
||||
if (params.isReconciled !== undefined && params.isReconciled !== "all") {
|
||||
searchParams.set(
|
||||
"isReconciled",
|
||||
params.isReconciled === true ? "true" : "false",
|
||||
params.isReconciled === true ? "true" : "false"
|
||||
);
|
||||
}
|
||||
if (params.sortField) searchParams.set("sortField", params.sortField);
|
||||
@@ -205,3 +205,18 @@ export function useAccountsWithStats() {
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
export function useDuplicateIds() {
|
||||
return useQuery({
|
||||
queryKey: ["duplicate-ids"],
|
||||
queryFn: async (): Promise<Set<string>> => {
|
||||
const response = await fetch("/api/banking/duplicates/ids");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch duplicate IDs");
|
||||
}
|
||||
const data = await response.json();
|
||||
return new Set(data.duplicateIds || []);
|
||||
},
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,14 +41,14 @@ export const transactionService = {
|
||||
|
||||
// Create sets for fast lookup
|
||||
const existingFitIdSet = new Set(
|
||||
existingByFitId.map((t) => `${t.accountId}-${t.fitId}`),
|
||||
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}`,
|
||||
),
|
||||
(t) => `${t.accountId}-${t.date}-${t.amount}-${t.description}`
|
||||
)
|
||||
);
|
||||
|
||||
// Filter out duplicates based on fitId OR (amount + date + description)
|
||||
@@ -85,7 +85,7 @@ export const transactionService = {
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: Partial<Omit<Transaction, "id">>,
|
||||
data: Partial<Omit<Transaction, "id">>
|
||||
): Promise<Transaction> {
|
||||
const updated = await prisma.transaction.update({
|
||||
where: { id },
|
||||
@@ -123,14 +123,67 @@ export const transactionService = {
|
||||
await prisma.transaction.delete({
|
||||
where: { id },
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.code === "P2025") {
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
error.code === "P2025"
|
||||
) {
|
||||
throw new Error(`Transaction with id ${id} not found`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getDuplicateIds(): Promise<Set<string>> {
|
||||
// Get all transactions grouped by account
|
||||
const allTransactions = await prisma.transaction.findMany({
|
||||
orderBy: [
|
||||
{ accountId: "asc" },
|
||||
{ date: "asc" },
|
||||
{ createdAt: "asc" }, // Oldest first
|
||||
],
|
||||
select: {
|
||||
id: true,
|
||||
accountId: true,
|
||||
date: true,
|
||||
amount: 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 duplicateIds = new Set<string>();
|
||||
const seenKeys = new Map<string, string>(); // key -> first transaction ID
|
||||
|
||||
// For each account, find duplicates by amount + date only
|
||||
for (const [accountId, transactions] of transactionsByAccount.entries()) {
|
||||
for (const transaction of transactions) {
|
||||
const key = `${accountId}-${transaction.date}-${transaction.amount}`;
|
||||
|
||||
if (seenKeys.has(key)) {
|
||||
// This is a duplicate - mark both the first and this one
|
||||
const firstId = seenKeys.get(key)!;
|
||||
duplicateIds.add(firstId);
|
||||
duplicateIds.add(transaction.id);
|
||||
} else {
|
||||
// First occurrence
|
||||
seenKeys.set(key, transaction.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return duplicateIds;
|
||||
},
|
||||
|
||||
async deduplicate(): Promise<{
|
||||
deletedCount: number;
|
||||
duplicatesFound: number;
|
||||
|
||||
Reference in New Issue
Block a user