feat: implement bulk account deletion and enhance account management with folder organization and drag-and-drop functionality
This commit is contained in:
@@ -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,9 +178,28 @@ export default function AccountsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<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">
|
||||
{data.accounts.map((account) => {
|
||||
const folder = data.folders.find((f) => f.id === account.folderId);
|
||||
{accountsByFolder["no-folder"].map((account) => {
|
||||
const folder = data.folders.find(
|
||||
(f) => f.id === account.folderId,
|
||||
);
|
||||
|
||||
return (
|
||||
<AccountCard
|
||||
@@ -127,10 +210,78 @@ export default function AccountsPage() {
|
||||
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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,6 +263,19 @@ export default function FoldersPage() {
|
||||
<CardTitle>Arborescence des comptes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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
|
||||
@@ -177,6 +291,27 @@ export default function FoldersPage() {
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
|
||||
|
||||
35
components/accounts/account-bulk-actions.tsx
Normal file
35
components/accounts/account-bulk-actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
90
components/folders/draggable-account-item.tsx
Normal file
90
components/folders/draggable-account-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
160
components/folders/draggable-folder-item.tsx
Normal file
160
components/folders/draggable-folder-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,10 +133,10 @@ 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">
|
||||
{/* 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 &&
|
||||
@@ -135,8 +144,8 @@ export function TransactionTable({
|
||||
}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
<th className="p-3 text-left">
|
||||
</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"
|
||||
@@ -144,8 +153,8 @@ export function TransactionTable({
|
||||
Date
|
||||
<ArrowUpDown className="w-3 h-3" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="p-3 text-left">
|
||||
</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"
|
||||
@@ -153,14 +162,14 @@ export function TransactionTable({
|
||||
Description
|
||||
<ArrowUpDown className="w-3 h-3" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="p-3 text-left text-sm font-medium text-muted-foreground">
|
||||
</div>
|
||||
<div className="p-3 text-sm font-medium text-muted-foreground">
|
||||
Compte
|
||||
</th>
|
||||
<th className="p-3 text-left text-sm font-medium text-muted-foreground">
|
||||
</div>
|
||||
<div className="p-3 text-sm font-medium text-muted-foreground">
|
||||
Catégorie
|
||||
</th>
|
||||
<th className="p-3 text-right">
|
||||
</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"
|
||||
@@ -168,43 +177,62 @@ export function TransactionTable({
|
||||
Montant
|
||||
<ArrowUpDown className="w-3 h-3" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="p-3 text-center text-sm font-medium text-muted-foreground">
|
||||
</div>
|
||||
<div 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) => {
|
||||
</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>
|
||||
|
||||
@@ -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
76
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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
307
scripts/import-csv-to-db.ts
Normal 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);
|
||||
});
|
||||
|
||||
@@ -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 } },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user