feat: implement bulk account deletion and enhance account management with folder organization and drag-and-drop functionality

This commit is contained in:
Julien Froidefond
2025-11-30 12:00:29 +01:00
parent d663fbcbd0
commit c26ba9ddc6
16 changed files with 1188 additions and 261 deletions

View File

@@ -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<Account | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedAccounts, setSelectedAccounts] = useState<Set<string>>(
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<string, Account[]>,
);
// 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 (
<PageLayout>
<PageHeader
@@ -114,23 +178,110 @@ export default function AccountsPage() {
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{data.accounts.map((account) => {
const folder = data.folders.find((f) => f.id === account.folderId);
<>
<AccountBulkActions
selectedCount={selectedAccounts.size}
onDelete={handleBulkDelete}
/>
<div className="space-y-6">
{/* Afficher d'abord les comptes sans dossier */}
{accountsByFolder["no-folder"] &&
accountsByFolder["no-folder"].length > 0 && (
<div>
<div className="flex items-center gap-2 mb-4">
<Folder className="w-5 h-5 text-muted-foreground" />
<h2 className="text-lg font-semibold">Sans dossier</h2>
<span className="text-sm text-muted-foreground">
({accountsByFolder["no-folder"].length})
</span>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{accountsByFolder["no-folder"].map((account) => {
const folder = data.folders.find(
(f) => f.id === account.folderId,
);
return (
<AccountCard
key={account.id}
account={account}
folder={folder}
transactionCount={getTransactionCount(account.id)}
onEdit={handleEdit}
onDelete={handleDelete}
formatCurrency={formatCurrency}
/>
);
})}
</div>
return (
<AccountCard
key={account.id}
account={account}
folder={folder}
transactionCount={getTransactionCount(account.id)}
onEdit={handleEdit}
onDelete={handleDelete}
formatCurrency={formatCurrency}
isSelected={selectedAccounts.has(account.id)}
onSelect={toggleSelectAccount}
/>
);
})}
</div>
</div>
)}
{/* 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 (
<div key={folder.id}>
<div className="flex items-center gap-2 mb-4">
<div
className="w-5 h-5 rounded flex items-center justify-center"
style={{ backgroundColor: `${folder.color}20` }}
>
<Folder
className="w-4 h-4"
style={{ color: folder.color }}
/>
</div>
<h2 className="text-lg font-semibold">{folder.name}</h2>
<span className="text-sm text-muted-foreground">
({folderAccounts.length})
</span>
<span
className={cn(
"text-sm font-semibold tabular-nums ml-auto",
folderBalance >= 0
? "text-emerald-600"
: "text-red-600",
)}
>
{formatCurrency(folderBalance)}
</span>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{folderAccounts.map((account) => {
const accountFolder = data.folders.find(
(f) => f.id === account.folderId,
);
return (
<AccountCard
key={account.id}
account={account}
folder={accountFolder}
transactionCount={getTransactionCount(account.id)}
onEdit={handleEdit}
onDelete={handleDelete}
formatCurrency={formatCurrency}
isSelected={selectedAccounts.has(account.id)}
onSelect={toggleSelectAccount}
/>
);
})}
</div>
</div>
);
})}
</div>
</>
)}
<AccountEditDialog

View File

@@ -44,6 +44,20 @@ export async function DELETE(request: Request) {
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get("id");
const ids = searchParams.get("ids");
if (ids) {
// Multiple deletion
const accountIds = ids.split(",").filter(Boolean);
if (accountIds.length === 0) {
return NextResponse.json(
{ error: "At least one account ID is required" },
{ status: 400 },
);
}
await accountService.deleteMany(accountIds);
return NextResponse.json({ success: true, count: accountIds.length });
}
if (!id) {
return NextResponse.json(

View File

@@ -1,6 +1,17 @@
"use client";
import { useState } from "react";
import {
DndContext,
DragEndEvent,
DragOverlay,
DragStartEvent,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
import {
FolderTreeItem,
@@ -28,6 +39,7 @@ export default function FoldersPage() {
parentId: "folder-root" as string | null,
color: "#6366f1",
});
const [activeId, setActiveId] = useState<string | null>(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 <LoadingState />;
}
@@ -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 (
<PageLayout>
<PageHeader
@@ -162,21 +263,55 @@ export default function FoldersPage() {
<CardTitle>Arborescence des comptes</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1">
{rootFolders.map((folder) => (
<FolderTreeItem
key={folder.id}
folder={folder}
accounts={data.accounts}
allFolders={data.folders}
level={0}
onEdit={handleEdit}
onDelete={handleDelete}
onEditAccount={handleEditAccount}
formatCurrency={formatCurrency}
/>
))}
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={[
...rootFolders.map((f) => `folder-${f.id}`),
...data.accounts.map((a) => `account-${a.id}`),
]}
strategy={verticalListSortingStrategy}
>
<div className="space-y-1">
{rootFolders.map((folder) => (
<FolderTreeItem
key={folder.id}
folder={folder}
accounts={data.accounts}
allFolders={data.folders}
level={0}
onEdit={handleEdit}
onDelete={handleDelete}
onEditAccount={handleEditAccount}
formatCurrency={formatCurrency}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeId ? (
<div className="opacity-50">
{activeId.startsWith("account-") ? (
<div className="p-2 bg-muted rounded">
{data.accounts.find(
(a) => a.id === activeId.replace("account-", "")
)?.name || ""}
</div>
) : (
<div className="p-2 bg-muted rounded">
{data.folders.find(
(f) => f.id === activeId.replace("folder-", "")
)?.name || ""}
</div>
)}
</div>
) : null}
</DragOverlay>
</DndContext>
</CardContent>
</Card>

View File

@@ -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 (
<Card className="bg-destructive/5 border-destructive/20">
<CardContent className="py-3">
<div className="flex items-center gap-4">
<span className="text-sm font-medium">
{selectedCount} compte{selectedCount > 1 ? "s" : ""} sélectionné
{selectedCount > 1 ? "s" : ""}
</span>
<Button size="sm" variant="destructive" onClick={onDelete}>
<Trash2 className="w-4 h-4 mr-1" />
Supprimer
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -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 (
<Card className="relative">
<CardHeader className="pb-2">
<Card className={cn("relative", isSelected && "ring-2 ring-primary")}>
<CardHeader className="pb-1.5">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<Icon className="w-5 h-5 text-primary" />
<div className="flex items-center gap-2 flex-1">
{onSelect && (
<Checkbox
checked={isSelected}
onCheckedChange={(checked) =>
onSelect(account.id, checked === true)
}
onClick={(e) => e.stopPropagation()}
/>
)}
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<Icon className="w-4 h-4 text-primary" />
</div>
<div>
<CardTitle className="text-base">{account.name}</CardTitle>
<div className="min-w-0">
<CardTitle className="text-sm font-semibold truncate">{account.name}</CardTitle>
<p className="text-xs text-muted-foreground">
{accountTypeLabels[account.type]}
</p>
{account.accountNumber && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{account.accountNumber}
</p>
)}
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="w-4 h-4" />
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
<MoreVertical className="w-3.5 h-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -70,26 +89,26 @@ export function AccountCard({
</DropdownMenu>
</div>
</CardHeader>
<CardContent>
<CardContent className="pt-1.5">
<div
className={cn(
"text-2xl font-bold mb-2",
"text-xl font-bold mb-1.5",
account.balance >= 0 ? "text-emerald-600" : "text-red-600"
)}
>
{formatCurrency(account.balance)}
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<Link
href={`/transactions?accountId=${account.id}`}
className="hover:text-primary hover:underline"
className="hover:text-primary hover:underline truncate"
>
{transactionCount} transactions
</Link>
{folder && <span>{folder.name}</span>}
{folder && <span className="truncate ml-2">{folder.name}</span>}
</div>
{account.lastImport && (
<p className="text-xs text-muted-foreground mt-2">
<p className="text-xs text-muted-foreground mt-1.5">
Dernier import:{" "}
{new Date(account.lastImport).toLocaleDateString("fr-FR")}
</p>
@@ -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"
>
<ExternalLink className="w-3 h-3" />
Accéder au portail banque

View File

@@ -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";

View File

@@ -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 (
<div
ref={setNodeRef}
style={style}
className={cn(
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group ml-12",
isDragging && "bg-muted/80"
)}
>
<button
{...attributes}
{...listeners}
className="p-1 hover:bg-muted rounded cursor-grab active:cursor-grabbing"
>
<GripVertical className="w-4 h-4 text-muted-foreground" />
</button>
<Building2 className="w-4 h-4 text-muted-foreground" />
<Link
href={`/transactions?accountId=${account.id}`}
className="flex-1 text-sm hover:text-primary hover:underline truncate"
>
{account.name}
{account.accountNumber && (
<span className="text-muted-foreground">
{" "}({account.accountNumber})
</span>
)}
</Link>
<span
className={cn(
"text-sm tabular-nums",
account.balance >= 0 ? "text-emerald-600" : "text-red-600"
)}
>
{formatCurrency(account.balance)}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100"
onClick={() => onEditAccount(account)}
>
<Pencil className="w-4 h-4" />
</Button>
</div>
);
}

View File

@@ -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 (
<div ref={setNodeRef} style={style}>
<div
className={cn(
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group",
level > 0 && "ml-6",
isDragging && "bg-muted/80"
)}
>
<button
{...attributes}
{...listeners}
className="p-1 hover:bg-muted rounded cursor-grab active:cursor-grabbing"
>
<GripVertical className="w-4 h-4 text-muted-foreground" />
</button>
<button
onClick={onToggleExpand}
className="p-1 hover:bg-muted rounded"
disabled={folderAccounts.length === 0 && childFolders.length === 0}
>
{folderAccounts.length > 0 || childFolders.length > 0 ? (
isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)
) : (
<div className="w-4 h-4" />
)}
</button>
<div
className="w-6 h-6 rounded flex items-center justify-center"
style={{ backgroundColor: `${folder.color}20` }}
>
{isExpanded ? (
<FolderOpen className="w-4 h-4" style={{ color: folder.color }} />
) : (
<Folder className="w-4 h-4" style={{ color: folder.color }} />
)}
</div>
<span className="flex-1 font-medium text-sm">{folder.name}</span>
{folderAccounts.length > 0 && (
<span
className={cn(
"text-sm font-semibold tabular-nums",
folderTotal >= 0 ? "text-emerald-600" : "text-red-600"
)}
>
{formatCurrency(folderTotal)}
</span>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100"
>
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(folder)}>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</DropdownMenuItem>
{folder.id !== "folder-root" && (
<DropdownMenuItem
onClick={() => onDelete(folder.id)}
className="text-red-600"
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}

View File

@@ -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 (
<div>
<div
className={cn(
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group",
level > 0 && "ml-6"
)}
>
<button
onClick={() => setIsExpanded(!isExpanded)}
className="p-1 hover:bg-muted rounded"
disabled={!hasChildren}
>
{hasChildren ? (
isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)
) : (
<div className="w-4 h-4" />
)}
</button>
<div
className="w-6 h-6 rounded flex items-center justify-center"
style={{ backgroundColor: `${folder.color}20` }}
>
{isExpanded ? (
<FolderOpen className="w-4 h-4" style={{ color: folder.color }} />
) : (
<Folder className="w-4 h-4" style={{ color: folder.color }} />
)}
</div>
<span className="flex-1 font-medium text-sm">{folder.name}</span>
{folderAccounts.length > 0 && (
<span
className={cn(
"text-sm font-semibold tabular-nums",
folderTotal >= 0 ? "text-emerald-600" : "text-red-600"
)}
>
{formatCurrency(folderTotal)}
</span>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100"
>
<MoreVertical className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(folder)}>
<Pencil className="w-4 h-4 mr-2" />
Modifier
</DropdownMenuItem>
{folder.id !== "folder-root" && (
<DropdownMenuItem
onClick={() => onDelete(folder.id)}
className="text-red-600"
>
<Trash2 className="w-4 h-4 mr-2" />
Supprimer
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<DraggableFolderItem
folder={folder}
accounts={accounts}
allFolders={allFolders}
level={level}
isExpanded={isExpanded}
onToggleExpand={() => setIsExpanded(!isExpanded)}
onEdit={onEdit}
onDelete={onDelete}
onEditAccount={onEditAccount}
formatCurrency={formatCurrency}
folderAccounts={folderAccounts}
childFolders={childFolders}
folderTotal={folderTotal}
/>
{isExpanded && (
<div>
{folderAccounts.map((account) => (
<div
<DraggableAccountItem
key={account.id}
className={cn(
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group",
"ml-12"
)}
>
<Building2 className="w-4 h-4 text-muted-foreground" />
<Link
href={`/transactions?accountId=${account.id}`}
className="flex-1 text-sm hover:text-primary hover:underline"
>
{account.name}
</Link>
<span
className={cn(
"text-sm tabular-nums",
account.balance >= 0 ? "text-emerald-600" : "text-red-600"
)}
>
{formatCurrency(account.balance)}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100"
onClick={() => onEditAccount(account)}
>
<Pencil className="w-4 h-4" />
</Button>
</div>
account={account}
onEditAccount={onEditAccount}
formatCurrency={formatCurrency}
/>
))}
{childFolders.map((child) => (

View File

@@ -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";

View File

@@ -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<number | null>(null);
const rowRefs = useRef<(HTMLTableRowElement | null)[]>([]);
const parentRef = useRef<HTMLDivElement>(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({
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="p-3 text-left">
<Checkbox
checked={
selectedTransactions.size === transactions.length &&
transactions.length > 0
}
onCheckedChange={onToggleSelectAll}
/>
</th>
<th className="p-3 text-left">
<button
onClick={() => onSortChange("date")}
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
>
Date
<ArrowUpDown className="w-3 h-3" />
</button>
</th>
<th className="p-3 text-left">
<button
onClick={() => onSortChange("description")}
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
>
Description
<ArrowUpDown className="w-3 h-3" />
</button>
</th>
<th className="p-3 text-left text-sm font-medium text-muted-foreground">
Compte
</th>
<th className="p-3 text-left text-sm font-medium text-muted-foreground">
Catégorie
</th>
<th className="p-3 text-right">
<button
onClick={() => onSortChange("amount")}
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground ml-auto"
>
Montant
<ArrowUpDown className="w-3 h-3" />
</button>
</th>
<th className="p-3 text-center text-sm font-medium text-muted-foreground">
Pointé
</th>
<th className="p-3"></th>
</tr>
</thead>
<tbody>
{transactions.map((transaction, index) => {
{/* Header fixe */}
<div className="sticky top-0 z-10 bg-[var(--card)] border-b border-border">
<div className="grid grid-cols-[auto_120px_2fr_150px_180px_140px_auto_auto] gap-0">
<div className="p-3">
<Checkbox
checked={
selectedTransactions.size === transactions.length &&
transactions.length > 0
}
onCheckedChange={onToggleSelectAll}
/>
</div>
<div className="p-3">
<button
onClick={() => onSortChange("date")}
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
>
Date
<ArrowUpDown className="w-3 h-3" />
</button>
</div>
<div className="p-3">
<button
onClick={() => onSortChange("description")}
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
>
Description
<ArrowUpDown className="w-3 h-3" />
</button>
</div>
<div className="p-3 text-sm font-medium text-muted-foreground">
Compte
</div>
<div className="p-3 text-sm font-medium text-muted-foreground">
Catégorie
</div>
<div className="p-3 text-right">
<button
onClick={() => onSortChange("amount")}
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground ml-auto"
>
Montant
<ArrowUpDown className="w-3 h-3" />
</button>
</div>
<div className="p-3 text-center text-sm font-medium text-muted-foreground">
Pointé
</div>
<div className="p-3"></div>
</div>
</div>
{/* Body virtualisé */}
<div
ref={parentRef}
className="overflow-auto"
style={{ height: "calc(100vh - 400px)", minHeight: "400px" }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const transaction = transactions[virtualRow.index];
const account = getAccount(transaction.accountId);
const isFocused = focusedIndex === index;
const isFocused = focusedIndex === virtualRow.index;
return (
<tr
<div
key={transaction.id}
ref={(el) => {
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"
)}
>
<td className="p-3">
<div className="p-3">
<Checkbox
checked={selectedTransactions.has(transaction.id)}
onCheckedChange={() =>
onToggleSelectTransaction(transaction.id)
}
/>
</td>
<td className="p-3 text-sm text-muted-foreground whitespace-nowrap">
</div>
<div className="p-3 text-sm text-muted-foreground whitespace-nowrap">
{formatDate(transaction.date)}
</td>
<td className="p-3">
</div>
<div className="p-3">
<p className="font-medium text-sm">
{transaction.description}
</p>
@@ -213,11 +241,11 @@ export function TransactionTable({
{transaction.memo}
</p>
)}
</td>
<td className="p-3 text-sm text-muted-foreground">
</div>
<div className="p-3 text-sm text-muted-foreground">
{account?.name || "-"}
</td>
<td className="p-3" onClick={(e) => e.stopPropagation()}>
</div>
<div className="p-3" onClick={(e) => e.stopPropagation()}>
<CategoryCombobox
categories={categories}
value={transaction.categoryId}
@@ -227,8 +255,8 @@ export function TransactionTable({
showBadge
align="start"
/>
</td>
<td
</div>
<div
className={cn(
"p-3 text-right font-semibold tabular-nums",
transaction.amount >= 0
@@ -238,8 +266,8 @@ export function TransactionTable({
>
{transaction.amount >= 0 ? "+" : ""}
{formatCurrency(transaction.amount)}
</td>
<td className="p-3 text-center" onClick={(e) => e.stopPropagation()}>
</div>
<div className="p-3 text-center" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => onToggleReconciled(transaction.id)}
className="p-1 hover:bg-muted rounded"
@@ -250,8 +278,8 @@ export function TransactionTable({
<Circle className="w-5 h-5 text-muted-foreground" />
)}
</button>
</td>
<td className="p-3" onClick={(e) => e.stopPropagation()}>
</div>
<div className="p-3" onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -293,12 +321,12 @@ export function TransactionTable({
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
</div>
</div>
);
})}
</tbody>
</table>
</div>
</div>
</div>
)}
</CardContent>

View File

@@ -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",

76
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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"
}

307
scripts/import-csv-to-db.ts Normal file
View File

@@ -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<string, CSVTransaction[]>();
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);
});

View File

@@ -70,4 +70,11 @@ export const accountService = {
where: { id },
});
},
async deleteMany(ids: string[]): Promise<void> {
// Transactions will be deleted automatically due to onDelete: Cascade
await prisma.account.deleteMany({
where: { id: { in: ids } },
});
},
};