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 */}
|
||||
|
||||
Reference in New Issue
Block a user