diff --git a/app/accounts/page.tsx b/app/accounts/page.tsx index b064b94..651058b 100644 --- a/app/accounts/page.tsx +++ b/app/accounts/page.tsx @@ -2,11 +2,15 @@ import { useState } from "react"; import { PageLayout, LoadingState, PageHeader } from "@/components/layout"; -import { AccountCard, AccountEditDialog } from "@/components/accounts"; +import { + AccountCard, + AccountEditDialog, + AccountBulkActions, +} from "@/components/accounts"; import { useBankingData } from "@/lib/hooks"; import { updateAccount, deleteAccount } from "@/lib/store-db"; import { Card, CardContent } from "@/components/ui/card"; -import { Building2 } from "lucide-react"; +import { Building2, Folder } from "lucide-react"; import type { Account } from "@/lib/types"; import { cn } from "@/lib/utils"; @@ -14,6 +18,9 @@ export default function AccountsPage() { const { data, isLoading, refresh } = useBankingData(); const [editingAccount, setEditingAccount] = useState(null); const [isDialogOpen, setIsDialogOpen] = useState(false); + const [selectedAccounts, setSelectedAccounts] = useState>( + new Set(), + ); const [formData, setFormData] = useState({ name: "", type: "CHECKING" as Account["type"], @@ -76,12 +83,69 @@ export default function AccountsPage() { } }; + const handleBulkDelete = async () => { + const count = selectedAccounts.size; + if ( + !confirm( + `Supprimer ${count} compte${count > 1 ? "s" : ""} et toutes leurs transactions ?`, + ) + ) + return; + + try { + const ids = Array.from(selectedAccounts); + const response = await fetch( + `/api/banking/accounts?ids=${ids.join(",")}`, + { + method: "DELETE", + }, + ); + if (!response.ok) { + throw new Error("Failed to delete accounts"); + } + setSelectedAccounts(new Set()); + refresh(); + } catch (error) { + console.error("Error deleting accounts:", error); + alert("Erreur lors de la suppression des comptes"); + } + }; + + const toggleSelectAccount = (accountId: string, selected: boolean) => { + const newSelected = new Set(selectedAccounts); + if (selected) { + newSelected.add(accountId); + } else { + newSelected.delete(accountId); + } + setSelectedAccounts(newSelected); + }; + + const getTransactionCount = (accountId: string) => { return data.transactions.filter((t) => t.accountId === accountId).length; }; const totalBalance = data.accounts.reduce((sum, a) => sum + a.balance, 0); + // Grouper les comptes par folder + const accountsByFolder = data.accounts.reduce( + (acc, account) => { + const folderId = account.folderId || "no-folder"; + if (!acc[folderId]) { + acc[folderId] = []; + } + acc[folderId].push(account); + return acc; + }, + {} as Record, + ); + + // Obtenir les folders racine (sans parent) et les trier par nom + const rootFolders = data.folders + .filter((f) => !f.parentId) + .sort((a, b) => a.name.localeCompare(b.name)); + return ( ) : ( -
- {data.accounts.map((account) => { - const folder = data.folders.find((f) => f.id === account.folderId); + <> + +
+ {/* Afficher d'abord les comptes sans dossier */} + {accountsByFolder["no-folder"] && + accountsByFolder["no-folder"].length > 0 && ( +
+
+ +

Sans dossier

+ + ({accountsByFolder["no-folder"].length}) + +
+
+ {accountsByFolder["no-folder"].map((account) => { + const folder = data.folders.find( + (f) => f.id === account.folderId, + ); - return ( - - ); - })} -
+ return ( + + ); + })} +
+
+ )} + + {/* Afficher les comptes groupés par folder */} + {rootFolders.map((folder) => { + const folderAccounts = accountsByFolder[folder.id] || []; + if (folderAccounts.length === 0) return null; + + const folderBalance = folderAccounts.reduce( + (sum, a) => sum + a.balance, + 0, + ); + + return ( +
+
+
+ +
+

{folder.name}

+ + ({folderAccounts.length}) + + = 0 + ? "text-emerald-600" + : "text-red-600", + )} + > + {formatCurrency(folderBalance)} + +
+
+ {folderAccounts.map((account) => { + const accountFolder = data.folders.find( + (f) => f.id === account.folderId, + ); + + return ( + + ); + })} +
+
+ ); + })} +
+ )} (null); // Account editing state const [isAccountDialogOpen, setIsAccountDialogOpen] = useState(false); @@ -38,6 +50,14 @@ export default function FoldersPage() { folderId: "folder-root", }); + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + if (isLoading || !data) { return ; } @@ -144,6 +164,87 @@ export default function FoldersPage() { } }; + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id as string); + }; + + const handleDragEnd = async (event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); + + if (!over || active.id === over.id) return; + + const activeId = active.id as string; + const overId = over.id as string; + + // Déplacer un compte vers un dossier + if (activeId.startsWith("account-")) { + const accountId = activeId.replace("account-", ""); + let targetFolderId: string | null = null; + + if (overId.startsWith("folder-")) { + const folderId = overId.replace("folder-", ""); + targetFolderId = folderId === "folder-root" ? null : folderId; + } else if (overId.startsWith("account-")) { + // Déplacer vers le dossier du compte cible + const targetAccountId = overId.replace("account-", ""); + const targetAccount = data.accounts.find((a) => a.id === targetAccountId); + if (targetAccount) { + targetFolderId = targetAccount.folderId; + } + } + + if (targetFolderId !== undefined) { + try { + const account = data.accounts.find((a) => a.id === accountId); + if (account) { + await updateAccount({ + ...account, + folderId: targetFolderId, + }); + refresh(); + } + } catch (error) { + console.error("Error moving account:", error); + alert("Erreur lors du déplacement du compte"); + } + } + } + + // Déplacer un dossier vers un autre dossier (changer le parent) + if (activeId.startsWith("folder-") && overId.startsWith("folder-")) { + const folderId = activeId.replace("folder-", ""); + const targetFolderId = overId.replace("folder-", ""); + + // Empêcher de déplacer un dossier dans lui-même ou ses enfants + const folder = data.folders.find((f) => f.id === folderId); + if (!folder) return; + + const isDescendant = (parentId: string, childId: string): boolean => { + const child = data.folders.find((f) => f.id === childId); + if (!child || !child.parentId) return false; + if (child.parentId === parentId) return true; + return isDescendant(parentId, child.parentId); + }; + + if (folderId === targetFolderId || isDescendant(folderId, targetFolderId)) { + return; // Ne pas permettre de déplacer un dossier dans lui-même ou ses descendants + } + + try { + const newParentId = targetFolderId === "folder-root" ? null : targetFolderId; + await updateFolder({ + ...folder, + parentId: newParentId, + }); + refresh(); + } catch (error) { + console.error("Error moving folder:", error); + alert("Erreur lors du déplacement du dossier"); + } + } + }; + return ( Arborescence des comptes -
- {rootFolders.map((folder) => ( - - ))} -
+ + `folder-${f.id}`), + ...data.accounts.map((a) => `account-${a.id}`), + ]} + strategy={verticalListSortingStrategy} + > +
+ {rootFolders.map((folder) => ( + + ))} +
+
+ + {activeId ? ( +
+ {activeId.startsWith("account-") ? ( +
+ {data.accounts.find( + (a) => a.id === activeId.replace("account-", "") + )?.name || ""} +
+ ) : ( +
+ {data.folders.find( + (f) => f.id === activeId.replace("folder-", "") + )?.name || ""} +
+ )} +
+ ) : null} +
+
diff --git a/components/accounts/account-bulk-actions.tsx b/components/accounts/account-bulk-actions.tsx new file mode 100644 index 0000000..e151682 --- /dev/null +++ b/components/accounts/account-bulk-actions.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Trash2 } from "lucide-react"; + +interface AccountBulkActionsProps { + selectedCount: number; + onDelete: () => void; +} + +export function AccountBulkActions({ + selectedCount, + onDelete, +}: AccountBulkActionsProps) { + if (selectedCount === 0) return null; + + return ( + + +
+ + {selectedCount} compte{selectedCount > 1 ? "s" : ""} sélectionné + {selectedCount > 1 ? "s" : ""} + + +
+
+
+ ); +} + diff --git a/components/accounts/account-card.tsx b/components/accounts/account-card.tsx index 406afb7..0c34377 100644 --- a/components/accounts/account-card.tsx +++ b/components/accounts/account-card.tsx @@ -13,6 +13,7 @@ import { cn } from "@/lib/utils"; import Link from "next/link"; import type { Account, Folder } from "@/lib/types"; import { accountTypeIcons, accountTypeLabels } from "./constants"; +import { Checkbox } from "@/components/ui/checkbox"; interface AccountCardProps { account: Account; @@ -21,6 +22,8 @@ interface AccountCardProps { onEdit: (account: Account) => void; onDelete: (accountId: string) => void; formatCurrency: (amount: number) => string; + isSelected?: boolean; + onSelect?: (accountId: string, selected: boolean) => void; } export function AccountCard({ @@ -30,28 +33,44 @@ export function AccountCard({ onEdit, onDelete, formatCurrency, + isSelected = false, + onSelect, }: AccountCardProps) { const Icon = accountTypeIcons[account.type]; return ( - - + +
-
-
- +
+ {onSelect && ( + + onSelect(account.id, checked === true) + } + onClick={(e) => e.stopPropagation()} + /> + )} +
+
-
- {account.name} +
+ {account.name}

{accountTypeLabels[account.type]}

+ {account.accountNumber && ( +

+ {account.accountNumber} +

+ )}
- @@ -70,26 +89,26 @@ export function AccountCard({
- +
= 0 ? "text-emerald-600" : "text-red-600" )} > {formatCurrency(account.balance)}
-
+
{transactionCount} transactions - {folder && {folder.name}} + {folder && {folder.name}}
{account.lastImport && ( -

+

Dernier import:{" "} {new Date(account.lastImport).toLocaleDateString("fr-FR")}

@@ -99,7 +118,7 @@ export function AccountCard({ href={account.externalUrl} target="_blank" rel="noopener noreferrer" - className="inline-flex items-center gap-1 text-xs text-primary hover:underline mt-2" + className="inline-flex items-center gap-1 text-xs text-primary hover:underline mt-1.5" > Accéder au portail banque diff --git a/components/accounts/index.ts b/components/accounts/index.ts index 360f02f..322cfad 100644 --- a/components/accounts/index.ts +++ b/components/accounts/index.ts @@ -1,4 +1,5 @@ export { AccountCard } from "./account-card"; export { AccountEditDialog } from "./account-edit-dialog"; +export { AccountBulkActions } from "./account-bulk-actions"; export { accountTypeIcons, accountTypeLabels } from "./constants"; diff --git a/components/folders/draggable-account-item.tsx b/components/folders/draggable-account-item.tsx new file mode 100644 index 0000000..80370d6 --- /dev/null +++ b/components/folders/draggable-account-item.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Button } from "@/components/ui/button"; +import { Building2, GripVertical, Pencil } from "lucide-react"; +import { cn } from "@/lib/utils"; +import Link from "next/link"; +import type { Account } from "@/lib/types"; + +interface DraggableAccountItemProps { + account: Account; + onEditAccount: (account: Account) => void; + formatCurrency: (amount: number) => string; +} + +export function DraggableAccountItem({ + account, + onEditAccount, + formatCurrency, +}: DraggableAccountItemProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: `account-${account.id}`, + data: { + type: "account", + account, + }, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+ + + + {account.name} + {account.accountNumber && ( + + {" "}({account.accountNumber}) + + )} + + = 0 ? "text-emerald-600" : "text-red-600" + )} + > + {formatCurrency(account.balance)} + + +
+ ); +} + diff --git a/components/folders/draggable-folder-item.tsx b/components/folders/draggable-folder-item.tsx new file mode 100644 index 0000000..01bdb1d --- /dev/null +++ b/components/folders/draggable-folder-item.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + MoreVertical, + Pencil, + Trash2, + Folder, + FolderOpen, + ChevronRight, + ChevronDown, + GripVertical, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { Folder as FolderType, Account } from "@/lib/types"; + +interface DraggableFolderItemProps { + folder: FolderType; + accounts?: Account[]; + allFolders?: FolderType[]; + level: number; + isExpanded: boolean; + onToggleExpand: () => void; + onEdit: (folder: FolderType) => void; + onDelete: (folderId: string) => void; + onEditAccount?: (account: Account) => void; + formatCurrency: (amount: number) => string; + folderAccounts: Account[]; + childFolders: FolderType[]; + folderTotal: number; +} + +export function DraggableFolderItem({ + folder, + level, + isExpanded, + onToggleExpand, + onEdit, + onDelete, + formatCurrency, + folderAccounts, + childFolders, + folderTotal, +}: DraggableFolderItemProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: `folder-${folder.id}`, + data: { + type: "folder", + folder, + }, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + return ( +
+
0 && "ml-6", + isDragging && "bg-muted/80" + )} + > + + + +
+ {isExpanded ? ( + + ) : ( + + )} +
+ + {folder.name} + + {folderAccounts.length > 0 && ( + = 0 ? "text-emerald-600" : "text-red-600" + )} + > + {formatCurrency(folderTotal)} + + )} + + + + + + + onEdit(folder)}> + + Modifier + + {folder.id !== "folder-root" && ( + onDelete(folder.id)} + className="text-red-600" + > + + Supprimer + + )} + + +
+
+ ); +} + diff --git a/components/folders/folder-tree-item.tsx b/components/folders/folder-tree-item.tsx index 715353b..809c094 100644 --- a/components/folders/folder-tree-item.tsx +++ b/components/folders/folder-tree-item.tsx @@ -1,25 +1,8 @@ "use client"; import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - MoreVertical, - Pencil, - Trash2, - Folder, - FolderOpen, - ChevronRight, - ChevronDown, - Building2, -} from "lucide-react"; -import { cn } from "@/lib/utils"; -import Link from "next/link"; +import { DraggableFolderItem } from "./draggable-folder-item"; +import { DraggableAccountItem } from "./draggable-account-item"; import type { Folder as FolderType, Account } from "@/lib/types"; interface FolderTreeItemProps { @@ -52,120 +35,35 @@ export function FolderTreeItem({ (folder.id === "folder-root" && a.folderId === null) ); const childFolders = allFolders.filter((f) => f.parentId === folder.id); - const hasChildren = childFolders.length > 0 || folderAccounts.length > 0; - const folderTotal = folderAccounts.reduce((sum, a) => sum + a.balance, 0); return (
-
0 && "ml-6" - )} - > - - -
- {isExpanded ? ( - - ) : ( - - )} -
- - {folder.name} - - {folderAccounts.length > 0 && ( - = 0 ? "text-emerald-600" : "text-red-600" - )} - > - {formatCurrency(folderTotal)} - - )} - - - - - - - onEdit(folder)}> - - Modifier - - {folder.id !== "folder-root" && ( - onDelete(folder.id)} - className="text-red-600" - > - - Supprimer - - )} - - -
+ setIsExpanded(!isExpanded)} + onEdit={onEdit} + onDelete={onDelete} + onEditAccount={onEditAccount} + formatCurrency={formatCurrency} + folderAccounts={folderAccounts} + childFolders={childFolders} + folderTotal={folderTotal} + /> {isExpanded && (
{folderAccounts.map((account) => ( -
- - - {account.name} - - = 0 ? "text-emerald-600" : "text-red-600" - )} - > - {formatCurrency(account.balance)} - - -
+ account={account} + onEditAccount={onEditAccount} + formatCurrency={formatCurrency} + /> ))} {childFolders.map((child) => ( diff --git a/components/folders/index.ts b/components/folders/index.ts index 427d1b5..91d4c75 100644 --- a/components/folders/index.ts +++ b/components/folders/index.ts @@ -1,5 +1,7 @@ export { FolderTreeItem } from "./folder-tree-item"; export { FolderEditDialog } from "./folder-edit-dialog"; export { AccountFolderDialog } from "./account-folder-dialog"; +export { DraggableFolderItem } from "./draggable-folder-item"; +export { DraggableAccountItem } from "./draggable-account-item"; export { folderColors, accountTypeLabels } from "./constants"; diff --git a/components/transactions/transaction-table.tsx b/components/transactions/transaction-table.tsx index bc99442..7550218 100644 --- a/components/transactions/transaction-table.tsx +++ b/components/transactions/transaction-table.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState, useCallback } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; @@ -43,6 +44,8 @@ interface TransactionTableProps { formatDate: (dateStr: string) => string; } +const ROW_HEIGHT = 72; // Hauteur approximative d'une ligne + export function TransactionTable({ transactions, accounts, @@ -61,7 +64,14 @@ export function TransactionTable({ formatDate, }: TransactionTableProps) { const [focusedIndex, setFocusedIndex] = useState(null); - const rowRefs = useRef<(HTMLTableRowElement | null)[]>([]); + const parentRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: transactions.length, + getScrollElement: () => parentRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 10, + }); const handleRowClick = useCallback( (index: number, transactionId: string) => { @@ -81,9 +91,8 @@ export function TransactionTable({ if (newIndex !== focusedIndex) { setFocusedIndex(newIndex); onMarkReconciled(transactions[newIndex].id); - rowRefs.current[newIndex]?.scrollIntoView({ - behavior: "smooth", - block: "nearest", + virtualizer.scrollToIndex(newIndex, { + align: "start", }); } } else if (e.key === "ArrowUp") { @@ -92,14 +101,13 @@ export function TransactionTable({ if (newIndex !== focusedIndex) { setFocusedIndex(newIndex); onMarkReconciled(transactions[newIndex].id); - rowRefs.current[newIndex]?.scrollIntoView({ - behavior: "smooth", - block: "nearest", + virtualizer.scrollToIndex(newIndex, { + align: "start", }); } } }, - [focusedIndex, transactions, onMarkReconciled] + [focusedIndex, transactions, onMarkReconciled, virtualizer] ); useEffect(() => { @@ -111,6 +119,7 @@ export function TransactionTable({ useEffect(() => { setFocusedIndex(null); }, [transactions.length]); + const getAccount = (accountId: string) => { return accounts.find((a) => a.id === accountId); }; @@ -124,87 +133,106 @@ export function TransactionTable({
) : (
- - - - - - - - - - - - - - - {transactions.map((transaction, index) => { + {/* Header fixe */} +
+
+
+ 0 + } + onCheckedChange={onToggleSelectAll} + /> +
+
+ +
+
+ +
+
+ Compte +
+
+ Catégorie +
+
+ +
+
+ Pointé +
+
+
+
+ {/* Body virtualisé */} +
+
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const transaction = transactions[virtualRow.index]; const account = getAccount(transaction.accountId); - const isFocused = focusedIndex === index; + const isFocused = focusedIndex === virtualRow.index; return ( -
{ - rowRefs.current[index] = el; + data-index={virtualRow.index} + ref={virtualizer.measureElement} + style={{ + position: "absolute", + top: 0, + left: 0, + width: "100%", + transform: `translateY(${virtualRow.start}px)`, }} - onClick={() => handleRowClick(index, transaction.id)} + onClick={() => handleRowClick(virtualRow.index, transaction.id)} className={cn( - "border-b border-border last:border-0 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", isFocused && "bg-primary/10 ring-1 ring-primary/30" )} > - - - - - - - - - + + ); })} - -
- 0 - } - onCheckedChange={onToggleSelectAll} - /> - - - - - - Compte - - Catégorie - - - - Pointé -
+
onToggleSelectTransaction(transaction.id) } /> -
+ +
{formatDate(transaction.date)} -
+ +

{transaction.description}

@@ -213,11 +241,11 @@ export function TransactionTable({ {transaction.memo}

)} -
+ +
{account?.name || "-"} -
e.stopPropagation()}> + +
e.stopPropagation()}> -
+
= 0 @@ -238,8 +266,8 @@ export function TransactionTable({ > {transaction.amount >= 0 ? "+" : ""} {formatCurrency(transaction.amount)} -
e.stopPropagation()}> + +
e.stopPropagation()}> -
e.stopPropagation()}> + +
e.stopPropagation()}>
+
+
)}
diff --git a/package.json b/package.json index 96ba628..6f98986 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,9 @@ "seed": "tsx prisma/seed.ts" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^3.10.0", "@prisma/client": "^5.22.0", "@radix-ui/react-accordion": "1.2.2", @@ -46,6 +49,7 @@ "@radix-ui/react-toggle": "1.1.1", "@radix-ui/react-toggle-group": "1.1.1", "@radix-ui/react-tooltip": "1.1.6", + "@tanstack/react-virtual": "^3.13.12", "@vercel/analytics": "1.3.1", "autoprefixer": "^10.4.20", "bcryptjs": "^3.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c5e89c..ac4fe37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,15 @@ importers: .: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.0) '@hookform/resolvers': specifier: ^3.10.0 version: 3.10.0(react-hook-form@7.66.1(react@19.2.0)) @@ -95,6 +104,9 @@ importers: '@radix-ui/react-tooltip': specifier: 1.1.6 version: 1.1.6(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@tanstack/react-virtual': + specifier: ^3.13.12 + version: 3.13.12(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@vercel/analytics': specifier: 1.3.1 version: 1.3.1(next@16.0.3(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) @@ -312,6 +324,28 @@ packages: '@date-fns/tz@1.2.0': resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@emnapi/core@1.7.1': resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} @@ -1571,6 +1605,15 @@ packages: '@tailwindcss/postcss@4.1.17': resolution: {integrity: sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==} + '@tanstack/react-virtual@3.13.12': + resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.13.12': + resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -3518,6 +3561,31 @@ snapshots: '@date-fns/tz@1.2.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.0)': + dependencies: + react: 19.2.0 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.0) + '@dnd-kit/utilities': 3.2.2(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@dnd-kit/utilities': 3.2.2(react@19.2.0) + react: 19.2.0 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.0)': + dependencies: + react: 19.2.0 + tslib: 2.8.1 + '@emnapi/core@1.7.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -4667,6 +4735,14 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.17 + '@tanstack/react-virtual@3.13.12(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@tanstack/virtual-core': 3.13.12 + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + '@tanstack/virtual-core@3.13.12': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 diff --git a/prisma/backup-settings.json b/prisma/backup-settings.json index 80fa8b4..6f8e4cf 100644 --- a/prisma/backup-settings.json +++ b/prisma/backup-settings.json @@ -1,6 +1,6 @@ { "enabled": true, "frequency": "hourly", - "lastBackup": "2025-11-30T06:48:53.740Z", - "nextBackup": "2025-11-30T07:00:00.000Z" + "lastBackup": "2025-11-30T09:50:17.696Z", + "nextBackup": "2025-11-30T10:00:00.000Z" } \ No newline at end of file diff --git a/scripts/import-csv-to-db.ts b/scripts/import-csv-to-db.ts new file mode 100644 index 0000000..015a618 --- /dev/null +++ b/scripts/import-csv-to-db.ts @@ -0,0 +1,307 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { prisma } from '../lib/prisma'; +import { transactionService } from '../services/transaction.service'; +import { generateId } from '../lib/store-db'; + +interface CSVTransaction { + date: string; + amount: string; + libelle: string; + beneficiaire: string; + iban: string; + bic: string; + numeroCompte: string; + codeBanque: string; + categorie: string; + commentaire: string; + numeroCheque: string; + tags: string; + compte: string; +} + +function parseCSVLine(line: string): string[] { + const result: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + + if (char === '"') { + inQuotes = !inQuotes; + } else if (char === ',' && !inQuotes) { + result.push(current.trim()); + current = ''; + } else { + current += char; + } + } + + result.push(current.trim()); + return result; +} + +function parseCSV(csvPath: string): CSVTransaction[] { + const content = fs.readFileSync(csvPath, 'utf-8'); + const lines = content.split('\n'); + + // Skip header lines (first 8 lines) + const dataLines = lines.slice(8); + + const transactions: CSVTransaction[] = []; + + for (const line of dataLines) { + if (!line.trim()) continue; + + const fields = parseCSVLine(line); + if (fields.length < 13) continue; + + // Skip if date or amount is missing + if (!fields[0] || !fields[1] || !fields[2]) continue; + + transactions.push({ + date: fields[0], + amount: fields[1], + libelle: fields[2], + beneficiaire: fields[3], + iban: fields[4], + bic: fields[5], + numeroCompte: fields[6], + codeBanque: fields[7], + categorie: fields[8], + commentaire: fields[9], + numeroCheque: fields[10], + tags: fields[11], + compte: fields[12], + }); + } + + return transactions; +} + +function parseDate(dateStr: string): string { + // Format: DD/MM/YYYY -> YYYY-MM-DD + const [day, month, year] = dateStr.split('/'); + return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`; +} + +function parseAmount(amountStr: string): number { + if (!amountStr || amountStr.trim() === '' || amountStr === '""') { + return 0; + } + // Remove quotes, spaces (including non-breaking spaces), and replace comma with dot + const cleaned = amountStr.replace(/["\s\u00A0]/g, '').replace(',', '.'); + const parsed = parseFloat(cleaned); + return isNaN(parsed) ? 0 : parsed; +} + +function generateFITID(transaction: CSVTransaction, index: number): string { + const date = parseDate(transaction.date); + const dateStr = date.replace(/-/g, ''); + const amountStr = Math.abs(parseAmount(transaction.amount)).toFixed(2).replace('.', ''); + const libelleHash = transaction.libelle.substring(0, 20).replace(/[^A-Z0-9]/gi, ''); + return `${dateStr}-${amountStr}-${libelleHash}-${index}`; +} + +function removeAccountPrefix(accountName: string): string { + // Remove prefixes: LivretA, LDDS, CCP, PEL (case insensitive) + const prefixes = ['LivretA', 'Livret A', 'LDDS', 'CCP', 'PEL']; + let cleaned = accountName; + + for (const prefix of prefixes) { + // Remove prefix followed by optional spaces and dashes + const regex = new RegExp(`^${prefix}\\s*-?\\s*`, 'i'); + cleaned = cleaned.replace(regex, ''); + } + + return cleaned.trim(); +} + +function determineAccountType(accountName: string): "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER" { + const upper = accountName.toUpperCase(); + if (upper.includes('LIVRET') || upper.includes('LDDS') || upper.includes('PEL')) { + return 'SAVINGS'; + } + if (upper.includes('CCP') || upper.includes('COMPTE COURANT')) { + return 'CHECKING'; + } + return 'OTHER'; +} + +async function main() { + const csvPath = path.join(__dirname, '../temp/all account.csv'); + + if (!fs.existsSync(csvPath)) { + console.error(`Fichier CSV introuvable: ${csvPath}`); + process.exit(1); + } + + console.log('Lecture du fichier CSV...'); + const csvTransactions = parseCSV(csvPath); + console.log(`✓ ${csvTransactions.length} transactions trouvées`); + + // Group by account + const accountsMap = new Map(); + for (const transaction of csvTransactions) { + if (!transaction.compte) continue; + const amount = parseAmount(transaction.amount); + if (amount === 0) continue; // Skip zero-amount transactions + + if (!accountsMap.has(transaction.compte)) { + accountsMap.set(transaction.compte, []); + } + accountsMap.get(transaction.compte)!.push(transaction); + } + + console.log(`✓ ${accountsMap.size} comptes trouvés\n`); + + let totalTransactionsCreated = 0; + let totalAccountsCreated = 0; + let totalAccountsUpdated = 0; + + // Process each account + for (const [accountName, transactions] of accountsMap.entries()) { + console.log(`Traitement du compte: ${accountName}`); + console.log(` ${transactions.length} transactions`); + + // Remove prefixes and extract account number from account name + const cleanedAccountName = removeAccountPrefix(accountName); + const accountNumber = cleanedAccountName.replace(/[^A-Z0-9]/gi, '').substring(0, 22); + const bankId = transactions[0]?.codeBanque || 'FR'; + + console.log(` Numéro de compte extrait: ${accountNumber}`); + + // Find account by account number (try multiple strategies) + let account = await prisma.account.findFirst({ + where: { + accountNumber: accountNumber, + bankId: bankId, + }, + }); + + // If not found with bankId, try without bankId constraint + if (!account) { + account = await prisma.account.findFirst({ + where: { + accountNumber: accountNumber, + }, + }); + } + + // If still not found, try to find by account number in existing account numbers + // (some accounts might have been created with prefixes in accountNumber) + if (!account) { + const allAccounts = await prisma.account.findMany({ + where: { + accountNumber: { + contains: accountNumber, + }, + }, + }); + + // Try to find exact match in accountNumber (after cleaning) + for (const acc of allAccounts) { + const cleanedExisting = removeAccountPrefix(acc.accountNumber); + const existingNumber = cleanedExisting.replace(/[^A-Z0-9]/gi, ''); + if (existingNumber === accountNumber) { + account = acc; + break; + } + } + } + + if (!account) { + console.log(` → Création du compte...`); + account = await prisma.account.create({ + data: { + name: accountName, + bankId: bankId, + accountNumber: accountNumber, + type: determineAccountType(accountName), + folderId: null, + balance: 0, + currency: 'EUR', + lastImport: null, + externalUrl: null, + }, + }); + totalAccountsCreated++; + } else { + console.log(` → Compte existant trouvé: ${account.name} (${account.accountNumber})`); + totalAccountsUpdated++; + } + + // Sort transactions by date + transactions.sort((a, b) => { + const dateA = parseDate(a.date); + const dateB = parseDate(b.date); + return dateA.localeCompare(dateB); + }); + + // Calculate balance + const balance = transactions.reduce((sum, t) => sum + parseAmount(t.amount), 0); + + // Prepare transactions for insertion + const dbTransactions = transactions.map((transaction, index) => { + const amount = parseAmount(transaction.amount); + const date = parseDate(transaction.date); + + // Build memo from available fields + let memo = transaction.libelle; + if (transaction.beneficiaire) { + memo += ` - ${transaction.beneficiaire}`; + } + if (transaction.categorie) { + memo += ` [${transaction.categorie}]`; + } + if (transaction.commentaire) { + memo += ` (${transaction.commentaire})`; + } + + return { + id: generateId(), + accountId: account.id, + date: date, + amount: amount, + description: transaction.libelle.substring(0, 255), + type: amount >= 0 ? 'CREDIT' as const : 'DEBIT' as const, + categoryId: null, // Will be auto-categorized later if needed + isReconciled: false, + fitId: generateFITID(transaction, index), + memo: memo.length > 255 ? memo.substring(0, 255) : memo, + checkNum: transaction.numeroCheque || undefined, + }; + }); + + // Insert transactions (will skip duplicates based on fitId) + const result = await transactionService.createMany(dbTransactions); + console.log(` → ${result.count} nouvelles transactions insérées`); + totalTransactionsCreated += result.count; + + // Update account balance and lastImport + await prisma.account.update({ + where: { id: account.id }, + data: { + balance: balance, + lastImport: new Date().toISOString(), + }, + }); + + console.log(` ✓ Solde mis à jour: ${balance.toFixed(2)} EUR\n`); + } + + console.log('\n=== Résumé ==='); + console.log(`Comptes créés: ${totalAccountsCreated}`); + console.log(`Comptes mis à jour: ${totalAccountsUpdated}`); + console.log(`Transactions insérées: ${totalTransactionsCreated}`); + console.log('\n✓ Import terminé!'); + + await prisma.$disconnect(); +} + +main().catch((error) => { + console.error('Erreur:', error); + process.exit(1); +}); + diff --git a/services/account.service.ts b/services/account.service.ts index 3748796..b0a624f 100644 --- a/services/account.service.ts +++ b/services/account.service.ts @@ -70,4 +70,11 @@ export const accountService = { where: { id }, }); }, + + async deleteMany(ids: string[]): Promise { + // Transactions will be deleted automatically due to onDelete: Cascade + await prisma.account.deleteMany({ + where: { id: { in: ids } }, + }); + }, };