Compare commits

..

2 Commits

13 changed files with 225 additions and 63 deletions

View 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 },
);
}
}

View File

@@ -125,9 +125,6 @@ export async function DELETE(request: Request) {
console.error("Error deleting transaction:", error); console.error("Error deleting transaction:", error);
const errorMessage = const errorMessage =
error instanceof Error ? error.message : "Failed to delete transaction"; error instanceof Error ? error.message : "Failed to delete transaction";
return NextResponse.json( return NextResponse.json({ error: errorMessage }, { status: 500 });
{ error: errorMessage },
{ status: 500 },
);
} }
} }

View File

@@ -42,7 +42,7 @@ export default function RulesPage() {
offset: 0, offset: 0,
includeUncategorized: true, includeUncategorized: true,
}, },
!!metadata !!metadata,
); );
const refresh = useCallback(() => { const refresh = useCallback(() => {
@@ -56,7 +56,7 @@ export default function RulesPage() {
const [filterMinCount, setFilterMinCount] = useState(2); const [filterMinCount, setFilterMinCount] = useState(2);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set()); const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [selectedGroup, setSelectedGroup] = useState<TransactionGroup | null>( const [selectedGroup, setSelectedGroup] = useState<TransactionGroup | null>(
null null,
); );
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isAutoCategorizing, setIsAutoCategorizing] = useState(false); const [isAutoCategorizing, setIsAutoCategorizing] = useState(false);
@@ -87,7 +87,7 @@ export default function RulesPage() {
totalAmount: transactions.reduce((sum, t) => sum + t.amount, 0), totalAmount: transactions.reduce((sum, t) => sum + t.amount, 0),
suggestedKeyword: suggestKeyword(descriptions), suggestedKeyword: suggestKeyword(descriptions),
}; };
} },
); );
// Filter by search query // Filter by search query
@@ -98,7 +98,7 @@ export default function RulesPage() {
(g) => (g) =>
g.displayName.toLowerCase().includes(query) || g.displayName.toLowerCase().includes(query) ||
g.key.includes(query) || g.key.includes(query) ||
g.suggestedKeyword.toLowerCase().includes(query) g.suggestedKeyword.toLowerCase().includes(query),
); );
} }
@@ -167,7 +167,7 @@ export default function RulesPage() {
// 1. Add keyword to category // 1. Add keyword to category
const category = metadata.categories.find( const category = metadata.categories.find(
(c: { id: string }) => c.id === ruleData.categoryId (c: { id: string }) => c.id === ruleData.categoryId,
); );
if (!category) { if (!category) {
throw new Error("Category not found"); throw new Error("Category not found");
@@ -175,7 +175,7 @@ export default function RulesPage() {
// Check if keyword already exists // Check if keyword already exists
const keywordExists = category.keywords.some( const keywordExists = category.keywords.some(
(k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase() (k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase(),
); );
if (!keywordExists) { if (!keywordExists) {
@@ -193,14 +193,14 @@ export default function RulesPage() {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, categoryId: ruleData.categoryId }), body: JSON.stringify({ id, categoryId: ruleData.categoryId }),
}) }),
) ),
); );
} }
refresh(); refresh();
}, },
[metadata, refresh] [metadata, refresh],
); );
const handleAutoCategorize = useCallback(async () => { const handleAutoCategorize = useCallback(async () => {
@@ -214,7 +214,7 @@ export default function RulesPage() {
for (const transaction of uncategorized) { for (const transaction of uncategorized) {
const categoryId = autoCategorize( const categoryId = autoCategorize(
transaction.description + " " + (transaction.memo || ""), transaction.description + " " + (transaction.memo || ""),
metadata.categories metadata.categories,
); );
if (categoryId) { if (categoryId) {
await fetch("/api/banking/transactions", { await fetch("/api/banking/transactions", {
@@ -228,7 +228,7 @@ export default function RulesPage() {
refresh(); refresh();
alert( alert(
`${categorizedCount} transaction(s) catégorisée(s) automatiquement` `${categorizedCount} transaction(s) catégorisée(s) automatiquement`,
); );
} catch (error) { } catch (error) {
console.error("Error auto-categorizing:", error); console.error("Error auto-categorizing:", error);
@@ -247,8 +247,8 @@ export default function RulesPage() {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...t, categoryId }), body: JSON.stringify({ ...t, categoryId }),
}) }),
) ),
); );
refresh(); refresh();
} catch (error) { } catch (error) {
@@ -256,7 +256,7 @@ export default function RulesPage() {
alert("Erreur lors de la catégorisation"); alert("Erreur lors de la catégorisation");
} }
}, },
[refresh] [refresh],
); );
if ( if (

View File

@@ -15,6 +15,7 @@ import {
useBankingMetadata, useBankingMetadata,
useTransactions, useTransactions,
getTransactionsQueryKey, getTransactionsQueryKey,
useDuplicateIds,
} from "@/lib/hooks"; } from "@/lib/hooks";
import { updateCategory } from "@/lib/store-db"; import { updateCategory } from "@/lib/store-db";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
@@ -83,6 +84,7 @@ export default function TransactionsPage() {
const [updatingTransactionIds, setUpdatingTransactionIds] = useState< const [updatingTransactionIds, setUpdatingTransactionIds] = useState<
Set<string> Set<string>
>(new Set()); >(new Set());
const [showDuplicates, setShowDuplicates] = useState(false);
// Get start date based on period // Get start date based on period
const startDate = useMemo(() => { const startDate = useMemo(() => {
@@ -164,6 +166,9 @@ export default function TransactionsPage() {
invalidate: invalidateTransactions, invalidate: invalidateTransactions,
} = useTransactions(transactionParams, !!metadata); } = useTransactions(transactionParams, !!metadata);
// Fetch duplicate IDs
const { data: duplicateIds = new Set<string>() } = useDuplicateIds();
// For filter comboboxes, we'll use empty arrays for now // For filter comboboxes, we'll use empty arrays for now
// They can be enhanced later with separate queries if needed // They can be enhanced later with separate queries if needed
const transactionsForAccountFilter: Transaction[] = []; const transactionsForAccountFilter: Transaction[] = [];
@@ -593,6 +598,8 @@ export default function TransactionsPage() {
}} }}
isCustomDatePickerOpen={isCustomDatePickerOpen} isCustomDatePickerOpen={isCustomDatePickerOpen}
onCustomDatePickerOpenChange={setIsCustomDatePickerOpen} onCustomDatePickerOpenChange={setIsCustomDatePickerOpen}
showDuplicates={showDuplicates}
onShowDuplicatesChange={setShowDuplicates}
accounts={metadata.accounts} accounts={metadata.accounts}
folders={metadata.folders} folders={metadata.folders}
categories={metadata.categories} categories={metadata.categories}
@@ -631,6 +638,8 @@ export default function TransactionsPage() {
formatCurrency={formatCurrency} formatCurrency={formatCurrency}
formatDate={formatDate} formatDate={formatDate}
updatingTransactionIds={updatingTransactionIds} updatingTransactionIds={updatingTransactionIds}
duplicateIds={duplicateIds}
highlightDuplicates={showDuplicates}
/> />
{/* Pagination controls */} {/* Pagination controls */}

View File

@@ -77,10 +77,7 @@ function SidebarContent({
</div> </div>
)} )}
<nav className={cn( <nav className={cn("flex-1 space-y-2", collapsed ? "p-2" : "p-4")}>
"flex-1 space-y-2",
collapsed ? "p-2" : "p-4"
)}>
{navItems.map((item) => { {navItems.map((item) => {
const isActive = pathname === item.href; const isActive = pathname === item.href;
return ( return (
@@ -117,10 +114,12 @@ function SidebarContent({
})} })}
</nav> </nav>
<div className={cn( <div
className={cn(
"border-t border-border/30 space-y-2", "border-t border-border/30 space-y-2",
collapsed ? "p-2" : "p-4" collapsed ? "p-2" : "p-4",
)}> )}
>
<Link href="/settings" onClick={handleLinkClick}> <Link href="/settings" onClick={handleLinkClick}>
<Button <Button
variant="ghost" variant="ghost"
@@ -186,10 +185,12 @@ export function Sidebar({ open, onOpenChange }: SidebarProps) {
collapsed ? "w-16" : "w-64", collapsed ? "w-16" : "w-64",
)} )}
> >
<div className={cn( <div
className={cn(
"flex items-center border-b border-border/30 transition-all duration-300", "flex items-center border-b border-border/30 transition-all duration-300",
collapsed ? "justify-center p-4" : "justify-between p-6" collapsed ? "justify-center p-4" : "justify-between p-6",
)}> )}
>
{!collapsed && ( {!collapsed && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-primary via-primary/90 to-primary/80 flex items-center justify-center shadow-xl shadow-primary/30 backdrop-blur-sm"> <div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-primary via-primary/90 to-primary/80 flex items-center justify-center shadow-xl shadow-primary/30 backdrop-blur-sm">
@@ -206,7 +207,7 @@ export function Sidebar({ open, onOpenChange }: SidebarProps) {
onClick={() => setCollapsed(!collapsed)} onClick={() => setCollapsed(!collapsed)}
className={cn( className={cn(
"hover:bg-muted/60 rounded-xl transition-all duration-300 hover:scale-110", "hover:bg-muted/60 rounded-xl transition-all duration-300 hover:scale-110",
collapsed ? "" : "ml-auto" collapsed ? "" : "ml-auto",
)} )}
> >
{collapsed ? ( {collapsed ? (

View File

@@ -40,9 +40,7 @@ export function PageHeader({
<h1 className="text-2xl md:text-4xl lg:text-5xl font-black text-foreground tracking-tight leading-tight flex-1 min-w-0"> <h1 className="text-2xl md:text-4xl lg:text-5xl font-black text-foreground tracking-tight leading-tight flex-1 min-w-0">
{title} {title}
</h1> </h1>
{rightContent && ( {rightContent && <div className="shrink-0">{rightContent}</div>}
<div className="shrink-0">{rightContent}</div>
)}
</div> </div>
{description && ( {description && (
<div className="text-base md:text-lg text-muted-foreground/70 font-semibold"> <div className="text-base md:text-lg text-muted-foreground/70 font-semibold">

View File

@@ -28,10 +28,12 @@ import {
SheetTitle, SheetTitle,
SheetTrigger, SheetTrigger,
} from "@/components/ui/sheet"; } 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 { format } from "date-fns";
import { fr } from "date-fns/locale"; import { fr } from "date-fns/locale";
import { useIsMobile } from "@/hooks/use-mobile"; 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"; import type { Account, Category, Folder, Transaction } from "@/lib/types";
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all"; type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
@@ -53,6 +55,8 @@ interface TransactionFiltersProps {
onCustomEndDateChange: (date: Date | undefined) => void; onCustomEndDateChange: (date: Date | undefined) => void;
isCustomDatePickerOpen: boolean; isCustomDatePickerOpen: boolean;
onCustomDatePickerOpenChange: (open: boolean) => void; onCustomDatePickerOpenChange: (open: boolean) => void;
showDuplicates: boolean;
onShowDuplicatesChange: (show: boolean) => void;
accounts: Account[]; accounts: Account[];
folders: Folder[]; folders: Folder[];
categories: Category[]; categories: Category[];
@@ -77,6 +81,8 @@ export function TransactionFilters({
onCustomEndDateChange, onCustomEndDateChange,
isCustomDatePickerOpen, isCustomDatePickerOpen,
onCustomDatePickerOpenChange, onCustomDatePickerOpenChange,
showDuplicates,
onShowDuplicatesChange,
accounts, accounts,
folders, folders,
categories, categories,
@@ -153,6 +159,23 @@ export function TransactionFilters({
</SelectContent> </SelectContent>
</Select> </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" && ( {period === "custom" && (
<Popover <Popover
open={isCustomDatePickerOpen} open={isCustomDatePickerOpen}
@@ -256,7 +279,7 @@ export function TransactionFilters({
onRemoveCategory={(id) => { onRemoveCategory={(id) => {
const newCategories = selectedCategories.filter((c) => c !== id); const newCategories = selectedCategories.filter((c) => c !== id);
onCategoriesChange( onCategoriesChange(
newCategories.length > 0 ? newCategories : ["all"], newCategories.length > 0 ? newCategories : ["all"]
); );
}} }}
onClearCategories={() => onCategoriesChange(["all"])} onClearCategories={() => onCategoriesChange(["all"])}
@@ -282,7 +305,8 @@ export function TransactionFilters({
(!selectedAccounts.includes("all") ? selectedAccounts.length : 0) + (!selectedAccounts.includes("all") ? selectedAccounts.length : 0) +
(!selectedCategories.includes("all") ? selectedCategories.length : 0) + (!selectedCategories.includes("all") ? selectedCategories.length : 0) +
(showReconciled !== "all" ? 1 : 0) + (showReconciled !== "all" ? 1 : 0) +
(period !== "all" ? 1 : 0); (period !== "all" ? 1 : 0) +
(showDuplicates ? 1 : 0);
return ( return (
<> <>
@@ -367,7 +391,7 @@ function ActiveFilters({
const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id)); const selectedAccs = accounts.filter((a) => selectedAccounts.includes(a.id));
const selectedCats = categories.filter((c) => const selectedCats = categories.filter((c) =>
selectedCategories.includes(c.id), selectedCategories.includes(c.id)
); );
const isUncategorized = selectedCategories.includes("uncategorized"); const isUncategorized = selectedCategories.includes("uncategorized");

View File

@@ -25,6 +25,7 @@ import {
Wand2, Wand2,
Trash2, Trash2,
Loader2, Loader2,
AlertTriangle,
} 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";
@@ -52,6 +53,8 @@ interface TransactionTableProps {
formatCurrency: (amount: number) => string; formatCurrency: (amount: number) => string;
formatDate: (dateStr: string) => string; formatDate: (dateStr: string) => string;
updatingTransactionIds?: Set<string>; updatingTransactionIds?: Set<string>;
duplicateIds?: Set<string>;
highlightDuplicates?: boolean;
} }
const ROW_HEIGHT = 72; // Hauteur approximative d'une ligne const ROW_HEIGHT = 72; // Hauteur approximative d'une ligne
@@ -145,6 +148,8 @@ export function TransactionTable({
formatCurrency, formatCurrency,
formatDate, formatDate,
updatingTransactionIds = new Set(), updatingTransactionIds = new Set(),
duplicateIds = new Set(),
highlightDuplicates = false,
}: TransactionTableProps) { }: TransactionTableProps) {
const [focusedIndex, setFocusedIndex] = useState<number | null>(null); const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
@@ -247,6 +252,8 @@ export function TransactionTable({
const account = getAccount(transaction.accountId); const account = getAccount(transaction.accountId);
const _category = getCategory(transaction.categoryId); const _category = getCategory(transaction.categoryId);
const isFocused = focusedIndex === virtualRow.index; const isFocused = focusedIndex === virtualRow.index;
const isDuplicate =
highlightDuplicates && duplicateIds.has(transaction.id);
return ( return (
<div <div
@@ -259,6 +266,10 @@ export function TransactionTable({
left: 0, left: 0,
width: "100%", width: "100%",
transform: `translateY(${virtualRow.start}px)`, transform: `translateY(${virtualRow.start}px)`,
...(isDuplicate && {
backgroundColor: "rgb(254 252 232)", // yellow-50
borderLeft: "4px solid rgb(234 179 8)", // yellow-500
}),
}} }}
onClick={() => { onClick={() => {
// Désactiver le pointage au clic sur mobile // Désactiver le pointage au clic sur mobile
@@ -269,7 +280,8 @@ export function TransactionTable({
className={cn( className={cn(
"p-4 space-y-3 hover:bg-muted/50 cursor-pointer border-b border-border", "p-4 space-y-3 hover:bg-muted/50 cursor-pointer border-b border-border",
transaction.isReconciled && "bg-emerald-500/5", transaction.isReconciled && "bg-emerald-500/5",
isFocused && "bg-primary/10 ring-1 ring-primary/30" isFocused && "bg-primary/10 ring-1 ring-primary/30",
isDuplicate && "shadow-sm"
)} )}
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
@@ -282,9 +294,23 @@ export function TransactionTable({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-xs md:text-sm truncate"> <p className="font-medium text-xs md:text-sm truncate">
{transaction.description} {transaction.description}
</p> </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 && ( {transaction.memo && (
<p className="text-[10px] md:text-xs text-muted-foreground truncate mt-1"> <p className="text-[10px] md:text-xs text-muted-foreground truncate mt-1">
{transaction.memo} {transaction.memo}
@@ -455,6 +481,8 @@ export function TransactionTable({
const transaction = transactions[virtualRow.index]; const transaction = transactions[virtualRow.index];
const account = getAccount(transaction.accountId); const account = getAccount(transaction.accountId);
const isFocused = focusedIndex === virtualRow.index; const isFocused = focusedIndex === virtualRow.index;
const isDuplicate =
highlightDuplicates && duplicateIds.has(transaction.id);
return ( return (
<div <div
@@ -467,6 +495,10 @@ export function TransactionTable({
left: 0, left: 0,
width: "100%", width: "100%",
transform: `translateY(${virtualRow.start}px)`, transform: `translateY(${virtualRow.start}px)`,
...(isDuplicate && {
backgroundColor: "rgb(254 252 232)", // yellow-50
borderLeft: "4px solid rgb(234 179 8)", // yellow-500
}),
}} }}
onClick={() => onClick={() =>
handleRowClick(virtualRow.index, transaction.id) handleRowClick(virtualRow.index, transaction.id)
@@ -474,7 +506,8 @@ export function TransactionTable({
className={cn( className={cn(
"grid grid-cols-[auto_120px_2fr_150px_180px_140px_auto_auto] gap-0 border-b border-border hover:bg-muted/50 cursor-pointer", "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", transaction.isReconciled && "bg-emerald-500/5",
isFocused && "bg-primary/10 ring-1 ring-primary/30" isFocused && "bg-primary/10 ring-1 ring-primary/30",
isDuplicate && "shadow-sm"
)} )}
> >
<div className="p-3"> <div className="p-3">
@@ -492,9 +525,23 @@ export function TransactionTable({
className="p-3 min-w-0 overflow-hidden" className="p-3 min-w-0 overflow-hidden"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="flex items-center gap-2">
<p className="font-medium text-sm truncate"> <p className="font-medium text-sm truncate">
{transaction.description} {transaction.description}
</p> </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 && ( {transaction.memo && (
<DescriptionWithTooltip <DescriptionWithTooltip
description={transaction.memo} description={transaction.memo}

View File

@@ -182,9 +182,7 @@ export function AccountFilterCombobox({
{isFolderPartiallySelected(folder.id) && ( {isFolderPartiallySelected(folder.id) && (
<div className="h-3 w-3 rounded-sm bg-primary/50 mr-1" /> <div className="h-3 w-3 rounded-sm bg-primary/50 mr-1" />
)} )}
{isFolderSelected(folder.id) && ( {isFolderSelected(folder.id) && <Check className="h-4 w-4" />}
<Check className="h-4 w-4" />
)}
</div> </div>
</CommandItem> </CommandItem>
@@ -306,9 +304,7 @@ export function AccountFilterCombobox({
) )
</span> </span>
)} )}
{isAll && ( {isAll && <Check className="ml-auto h-4 w-4" />}
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>

View File

@@ -193,7 +193,11 @@ export function CategoryFilterCombobox({
align="start" align="start"
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
> >
<Command value={isAll ? "all" : isUncategorized ? "uncategorized" : value.join(",")}> <Command
value={
isAll ? "all" : isUncategorized ? "uncategorized" : value.join(",")
}
>
<CommandInput placeholder="Rechercher..." /> <CommandInput placeholder="Rechercher..." />
<CommandList className="max-h-[300px]"> <CommandList className="max-h-[300px]">
<CommandEmpty>Aucune catégorie trouvée.</CommandEmpty> <CommandEmpty>Aucune catégorie trouvée.</CommandEmpty>
@@ -212,9 +216,7 @@ export function CategoryFilterCombobox({
({filteredTransactions.length}) ({filteredTransactions.length})
</span> </span>
)} )}
{isAll && ( {isAll && <Check className="ml-auto h-4 w-4 shrink-0" />}
<Check className="ml-auto h-4 w-4 shrink-0" />
)}
</CommandItem> </CommandItem>
<CommandItem <CommandItem
value="uncategorized" value="uncategorized"

View File

@@ -4,7 +4,7 @@ const MOBILE_BREAKPOINT = 768;
export function useIsMobile() { export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>( const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined undefined,
); );
React.useEffect(() => { React.useEffect(() => {

View File

@@ -205,3 +205,18 @@ export function useAccountsWithStats() {
staleTime: 60 * 1000, // 1 minute 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
});
}

View File

@@ -41,14 +41,14 @@ export const transactionService = {
// Create sets for fast lookup // Create sets for fast lookup
const existingFitIdSet = new Set( 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 // Create set for duplicates by amount + date + description
const existingCriteriaSet = new Set( const existingCriteriaSet = new Set(
allExistingTransactions.map( 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) // Filter out duplicates based on fitId OR (amount + date + description)
@@ -85,7 +85,7 @@ export const transactionService = {
async update( async update(
id: string, id: string,
data: Partial<Omit<Transaction, "id">>, data: Partial<Omit<Transaction, "id">>
): Promise<Transaction> { ): Promise<Transaction> {
const updated = await prisma.transaction.update({ const updated = await prisma.transaction.update({
where: { id }, where: { id },
@@ -123,14 +123,67 @@ export const transactionService = {
await prisma.transaction.delete({ await prisma.transaction.delete({
where: { id }, where: { id },
}); });
} catch (error: any) { } catch (error: unknown) {
if (error.code === "P2025") { if (
error &&
typeof error === "object" &&
"code" in error &&
error.code === "P2025"
) {
throw new Error(`Transaction with id ${id} not found`); throw new Error(`Transaction with id ${id} not found`);
} }
throw error; 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<{ async deduplicate(): Promise<{
deletedCount: number; deletedCount: number;
duplicatesFound: number; duplicatesFound: number;