Compare commits
2 Commits
11c0df1293
...
ba4d112cb8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba4d112cb8 | ||
|
|
cb8628ce39 |
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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
|
||||||
"border-t border-border/30 space-y-2",
|
className={cn(
|
||||||
collapsed ? "p-2" : "p-4"
|
"border-t border-border/30 space-y-2",
|
||||||
)}>
|
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
|
||||||
"flex items-center border-b border-border/30 transition-all duration-300",
|
className={cn(
|
||||||
collapsed ? "justify-center p-4" : "justify-between p-6"
|
"flex items-center border-b border-border/30 transition-all duration-300",
|
||||||
)}>
|
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 ? (
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
<p className="font-medium text-xs md:text-sm truncate">
|
<div className="flex items-center gap-2">
|
||||||
{transaction.description}
|
<p className="font-medium text-xs md:text-sm truncate">
|
||||||
</p>
|
{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 && (
|
{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()}
|
||||||
>
|
>
|
||||||
<p className="font-medium text-sm truncate">
|
<div className="flex items-center gap-2">
|
||||||
{transaction.description}
|
<p className="font-medium text-sm truncate">
|
||||||
</p>
|
{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 && (
|
{transaction.memo && (
|
||||||
<DescriptionWithTooltip
|
<DescriptionWithTooltip
|
||||||
description={transaction.memo}
|
description={transaction.memo}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
15
lib/hooks.ts
15
lib/hooks.ts
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user