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 { useState } from "react";
|
||||||
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
|
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 { useBankingData } from "@/lib/hooks";
|
||||||
import { updateAccount, deleteAccount } from "@/lib/store-db";
|
import { updateAccount, deleteAccount } from "@/lib/store-db";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
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 type { Account } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -14,6 +18,9 @@ export default function AccountsPage() {
|
|||||||
const { data, isLoading, refresh } = useBankingData();
|
const { data, isLoading, refresh } = useBankingData();
|
||||||
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
|
const [editingAccount, setEditingAccount] = useState<Account | null>(null);
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
const [selectedAccounts, setSelectedAccounts] = useState<Set<string>>(
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: "",
|
name: "",
|
||||||
type: "CHECKING" as Account["type"],
|
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) => {
|
const getTransactionCount = (accountId: string) => {
|
||||||
return data.transactions.filter((t) => t.accountId === accountId).length;
|
return data.transactions.filter((t) => t.accountId === accountId).length;
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalBalance = data.accounts.reduce((sum, a) => sum + a.balance, 0);
|
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 (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -114,23 +178,110 @@ export default function AccountsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<>
|
||||||
{data.accounts.map((account) => {
|
<AccountBulkActions
|
||||||
const folder = data.folders.find((f) => f.id === account.folderId);
|
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 (
|
return (
|
||||||
<AccountCard
|
<AccountCard
|
||||||
key={account.id}
|
key={account.id}
|
||||||
account={account}
|
account={account}
|
||||||
folder={folder}
|
folder={folder}
|
||||||
transactionCount={getTransactionCount(account.id)}
|
transactionCount={getTransactionCount(account.id)}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
/>
|
isSelected={selectedAccounts.has(account.id)}
|
||||||
);
|
onSelect={toggleSelectAccount}
|
||||||
})}
|
/>
|
||||||
</div>
|
);
|
||||||
|
})}
|
||||||
|
</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
|
<AccountEditDialog
|
||||||
|
|||||||
@@ -44,6 +44,20 @@ export async function DELETE(request: Request) {
|
|||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const id = searchParams.get("id");
|
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) {
|
if (!id) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
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 { PageLayout, LoadingState, PageHeader } from "@/components/layout";
|
||||||
import {
|
import {
|
||||||
FolderTreeItem,
|
FolderTreeItem,
|
||||||
@@ -28,6 +39,7 @@ export default function FoldersPage() {
|
|||||||
parentId: "folder-root" as string | null,
|
parentId: "folder-root" as string | null,
|
||||||
color: "#6366f1",
|
color: "#6366f1",
|
||||||
});
|
});
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Account editing state
|
// Account editing state
|
||||||
const [isAccountDialogOpen, setIsAccountDialogOpen] = useState(false);
|
const [isAccountDialogOpen, setIsAccountDialogOpen] = useState(false);
|
||||||
@@ -38,6 +50,14 @@ export default function FoldersPage() {
|
|||||||
folderId: "folder-root",
|
folderId: "folder-root",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading || !data) {
|
if (isLoading || !data) {
|
||||||
return <LoadingState />;
|
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 (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -162,21 +263,55 @@ export default function FoldersPage() {
|
|||||||
<CardTitle>Arborescence des comptes</CardTitle>
|
<CardTitle>Arborescence des comptes</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-1">
|
<DndContext
|
||||||
{rootFolders.map((folder) => (
|
sensors={sensors}
|
||||||
<FolderTreeItem
|
collisionDetection={closestCenter}
|
||||||
key={folder.id}
|
onDragStart={handleDragStart}
|
||||||
folder={folder}
|
onDragEnd={handleDragEnd}
|
||||||
accounts={data.accounts}
|
>
|
||||||
allFolders={data.folders}
|
<SortableContext
|
||||||
level={0}
|
items={[
|
||||||
onEdit={handleEdit}
|
...rootFolders.map((f) => `folder-${f.id}`),
|
||||||
onDelete={handleDelete}
|
...data.accounts.map((a) => `account-${a.id}`),
|
||||||
onEditAccount={handleEditAccount}
|
]}
|
||||||
formatCurrency={formatCurrency}
|
strategy={verticalListSortingStrategy}
|
||||||
/>
|
>
|
||||||
))}
|
<div className="space-y-1">
|
||||||
</div>
|
{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>
|
</CardContent>
|
||||||
</Card>
|
</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 Link from "next/link";
|
||||||
import type { Account, Folder } from "@/lib/types";
|
import type { Account, Folder } from "@/lib/types";
|
||||||
import { accountTypeIcons, accountTypeLabels } from "./constants";
|
import { accountTypeIcons, accountTypeLabels } from "./constants";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
interface AccountCardProps {
|
interface AccountCardProps {
|
||||||
account: Account;
|
account: Account;
|
||||||
@@ -21,6 +22,8 @@ interface AccountCardProps {
|
|||||||
onEdit: (account: Account) => void;
|
onEdit: (account: Account) => void;
|
||||||
onDelete: (accountId: string) => void;
|
onDelete: (accountId: string) => void;
|
||||||
formatCurrency: (amount: number) => string;
|
formatCurrency: (amount: number) => string;
|
||||||
|
isSelected?: boolean;
|
||||||
|
onSelect?: (accountId: string, selected: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountCard({
|
export function AccountCard({
|
||||||
@@ -30,28 +33,44 @@ export function AccountCard({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
|
isSelected = false,
|
||||||
|
onSelect,
|
||||||
}: AccountCardProps) {
|
}: AccountCardProps) {
|
||||||
const Icon = accountTypeIcons[account.type];
|
const Icon = accountTypeIcons[account.type];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="relative">
|
<Card className={cn("relative", isSelected && "ring-2 ring-primary")}>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-1.5">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2 flex-1">
|
||||||
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
{onSelect && (
|
||||||
<Icon className="w-5 h-5 text-primary" />
|
<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>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<CardTitle className="text-base">{account.name}</CardTitle>
|
<CardTitle className="text-sm font-semibold truncate">{account.name}</CardTitle>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{accountTypeLabels[account.type]}
|
{accountTypeLabels[account.type]}
|
||||||
</p>
|
</p>
|
||||||
|
{account.accountNumber && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||||
|
{account.accountNumber}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
|
||||||
<MoreVertical className="w-4 h-4" />
|
<MoreVertical className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
@@ -70,26 +89,26 @@ export function AccountCard({
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="pt-1.5">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-2xl font-bold mb-2",
|
"text-xl font-bold mb-1.5",
|
||||||
account.balance >= 0 ? "text-emerald-600" : "text-red-600"
|
account.balance >= 0 ? "text-emerald-600" : "text-red-600"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatCurrency(account.balance)}
|
{formatCurrency(account.balance)}
|
||||||
</div>
|
</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
|
<Link
|
||||||
href={`/transactions?accountId=${account.id}`}
|
href={`/transactions?accountId=${account.id}`}
|
||||||
className="hover:text-primary hover:underline"
|
className="hover:text-primary hover:underline truncate"
|
||||||
>
|
>
|
||||||
{transactionCount} transactions
|
{transactionCount} transactions
|
||||||
</Link>
|
</Link>
|
||||||
{folder && <span>{folder.name}</span>}
|
{folder && <span className="truncate ml-2">{folder.name}</span>}
|
||||||
</div>
|
</div>
|
||||||
{account.lastImport && (
|
{account.lastImport && (
|
||||||
<p className="text-xs text-muted-foreground mt-2">
|
<p className="text-xs text-muted-foreground mt-1.5">
|
||||||
Dernier import:{" "}
|
Dernier import:{" "}
|
||||||
{new Date(account.lastImport).toLocaleDateString("fr-FR")}
|
{new Date(account.lastImport).toLocaleDateString("fr-FR")}
|
||||||
</p>
|
</p>
|
||||||
@@ -99,7 +118,7 @@ export function AccountCard({
|
|||||||
href={account.externalUrl}
|
href={account.externalUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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" />
|
<ExternalLink className="w-3 h-3" />
|
||||||
Accéder au portail banque
|
Accéder au portail banque
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { AccountCard } from "./account-card";
|
export { AccountCard } from "./account-card";
|
||||||
export { AccountEditDialog } from "./account-edit-dialog";
|
export { AccountEditDialog } from "./account-edit-dialog";
|
||||||
|
export { AccountBulkActions } from "./account-bulk-actions";
|
||||||
export { accountTypeIcons, accountTypeLabels } from "./constants";
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { DraggableFolderItem } from "./draggable-folder-item";
|
||||||
import {
|
import { DraggableAccountItem } from "./draggable-account-item";
|
||||||
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 type { Folder as FolderType, Account } from "@/lib/types";
|
import type { Folder as FolderType, Account } from "@/lib/types";
|
||||||
|
|
||||||
interface FolderTreeItemProps {
|
interface FolderTreeItemProps {
|
||||||
@@ -52,120 +35,35 @@ export function FolderTreeItem({
|
|||||||
(folder.id === "folder-root" && a.folderId === null)
|
(folder.id === "folder-root" && a.folderId === null)
|
||||||
);
|
);
|
||||||
const childFolders = allFolders.filter((f) => f.parentId === folder.id);
|
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);
|
const folderTotal = folderAccounts.reduce((sum, a) => sum + a.balance, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<DraggableFolderItem
|
||||||
className={cn(
|
folder={folder}
|
||||||
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group",
|
accounts={accounts}
|
||||||
level > 0 && "ml-6"
|
allFolders={allFolders}
|
||||||
)}
|
level={level}
|
||||||
>
|
isExpanded={isExpanded}
|
||||||
<button
|
onToggleExpand={() => setIsExpanded(!isExpanded)}
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onEdit={onEdit}
|
||||||
className="p-1 hover:bg-muted rounded"
|
onDelete={onDelete}
|
||||||
disabled={!hasChildren}
|
onEditAccount={onEditAccount}
|
||||||
>
|
formatCurrency={formatCurrency}
|
||||||
{hasChildren ? (
|
folderAccounts={folderAccounts}
|
||||||
isExpanded ? (
|
childFolders={childFolders}
|
||||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
folderTotal={folderTotal}
|
||||||
) : (
|
/>
|
||||||
<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>
|
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div>
|
<div>
|
||||||
{folderAccounts.map((account) => (
|
{folderAccounts.map((account) => (
|
||||||
<div
|
<DraggableAccountItem
|
||||||
key={account.id}
|
key={account.id}
|
||||||
className={cn(
|
account={account}
|
||||||
"flex items-center gap-2 p-2 rounded-lg hover:bg-muted/50 group",
|
onEditAccount={onEditAccount}
|
||||||
"ml-12"
|
formatCurrency={formatCurrency}
|
||||||
)}
|
/>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{childFolders.map((child) => (
|
{childFolders.map((child) => (
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export { FolderTreeItem } from "./folder-tree-item";
|
export { FolderTreeItem } from "./folder-tree-item";
|
||||||
export { FolderEditDialog } from "./folder-edit-dialog";
|
export { FolderEditDialog } from "./folder-edit-dialog";
|
||||||
export { AccountFolderDialog } from "./account-folder-dialog";
|
export { AccountFolderDialog } from "./account-folder-dialog";
|
||||||
|
export { DraggableFolderItem } from "./draggable-folder-item";
|
||||||
|
export { DraggableAccountItem } from "./draggable-account-item";
|
||||||
export { folderColors, accountTypeLabels } from "./constants";
|
export { folderColors, accountTypeLabels } from "./constants";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
@@ -43,6 +44,8 @@ interface TransactionTableProps {
|
|||||||
formatDate: (dateStr: string) => string;
|
formatDate: (dateStr: string) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ROW_HEIGHT = 72; // Hauteur approximative d'une ligne
|
||||||
|
|
||||||
export function TransactionTable({
|
export function TransactionTable({
|
||||||
transactions,
|
transactions,
|
||||||
accounts,
|
accounts,
|
||||||
@@ -61,7 +64,14 @@ export function TransactionTable({
|
|||||||
formatDate,
|
formatDate,
|
||||||
}: TransactionTableProps) {
|
}: TransactionTableProps) {
|
||||||
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
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(
|
const handleRowClick = useCallback(
|
||||||
(index: number, transactionId: string) => {
|
(index: number, transactionId: string) => {
|
||||||
@@ -81,9 +91,8 @@ export function TransactionTable({
|
|||||||
if (newIndex !== focusedIndex) {
|
if (newIndex !== focusedIndex) {
|
||||||
setFocusedIndex(newIndex);
|
setFocusedIndex(newIndex);
|
||||||
onMarkReconciled(transactions[newIndex].id);
|
onMarkReconciled(transactions[newIndex].id);
|
||||||
rowRefs.current[newIndex]?.scrollIntoView({
|
virtualizer.scrollToIndex(newIndex, {
|
||||||
behavior: "smooth",
|
align: "start",
|
||||||
block: "nearest",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (e.key === "ArrowUp") {
|
} else if (e.key === "ArrowUp") {
|
||||||
@@ -92,14 +101,13 @@ export function TransactionTable({
|
|||||||
if (newIndex !== focusedIndex) {
|
if (newIndex !== focusedIndex) {
|
||||||
setFocusedIndex(newIndex);
|
setFocusedIndex(newIndex);
|
||||||
onMarkReconciled(transactions[newIndex].id);
|
onMarkReconciled(transactions[newIndex].id);
|
||||||
rowRefs.current[newIndex]?.scrollIntoView({
|
virtualizer.scrollToIndex(newIndex, {
|
||||||
behavior: "smooth",
|
align: "start",
|
||||||
block: "nearest",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[focusedIndex, transactions, onMarkReconciled]
|
[focusedIndex, transactions, onMarkReconciled, virtualizer]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -111,6 +119,7 @@ export function TransactionTable({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFocusedIndex(null);
|
setFocusedIndex(null);
|
||||||
}, [transactions.length]);
|
}, [transactions.length]);
|
||||||
|
|
||||||
const getAccount = (accountId: string) => {
|
const getAccount = (accountId: string) => {
|
||||||
return accounts.find((a) => a.id === accountId);
|
return accounts.find((a) => a.id === accountId);
|
||||||
};
|
};
|
||||||
@@ -124,87 +133,106 @@ export function TransactionTable({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
{/* Header fixe */}
|
||||||
<thead>
|
<div className="sticky top-0 z-10 bg-[var(--card)] border-b border-border">
|
||||||
<tr className="border-b border-border">
|
<div className="grid grid-cols-[auto_120px_2fr_150px_180px_140px_auto_auto] gap-0">
|
||||||
<th className="p-3 text-left">
|
<div className="p-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={
|
checked={
|
||||||
selectedTransactions.size === transactions.length &&
|
selectedTransactions.size === transactions.length &&
|
||||||
transactions.length > 0
|
transactions.length > 0
|
||||||
}
|
}
|
||||||
onCheckedChange={onToggleSelectAll}
|
onCheckedChange={onToggleSelectAll}
|
||||||
/>
|
/>
|
||||||
</th>
|
</div>
|
||||||
<th className="p-3 text-left">
|
<div className="p-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => onSortChange("date")}
|
onClick={() => onSortChange("date")}
|
||||||
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
|
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
Date
|
Date
|
||||||
<ArrowUpDown className="w-3 h-3" />
|
<ArrowUpDown className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</div>
|
||||||
<th className="p-3 text-left">
|
<div className="p-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => onSortChange("description")}
|
onClick={() => onSortChange("description")}
|
||||||
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
|
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
Description
|
Description
|
||||||
<ArrowUpDown className="w-3 h-3" />
|
<ArrowUpDown className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</div>
|
||||||
<th className="p-3 text-left text-sm font-medium text-muted-foreground">
|
<div className="p-3 text-sm font-medium text-muted-foreground">
|
||||||
Compte
|
Compte
|
||||||
</th>
|
</div>
|
||||||
<th className="p-3 text-left text-sm font-medium text-muted-foreground">
|
<div className="p-3 text-sm font-medium text-muted-foreground">
|
||||||
Catégorie
|
Catégorie
|
||||||
</th>
|
</div>
|
||||||
<th className="p-3 text-right">
|
<div className="p-3 text-right">
|
||||||
<button
|
<button
|
||||||
onClick={() => onSortChange("amount")}
|
onClick={() => onSortChange("amount")}
|
||||||
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground ml-auto"
|
className="flex items-center gap-1 text-sm font-medium text-muted-foreground hover:text-foreground ml-auto"
|
||||||
>
|
>
|
||||||
Montant
|
Montant
|
||||||
<ArrowUpDown className="w-3 h-3" />
|
<ArrowUpDown className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</th>
|
</div>
|
||||||
<th className="p-3 text-center text-sm font-medium text-muted-foreground">
|
<div className="p-3 text-center text-sm font-medium text-muted-foreground">
|
||||||
Pointé
|
Pointé
|
||||||
</th>
|
</div>
|
||||||
<th className="p-3"></th>
|
<div className="p-3"></div>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
{/* Body virtualisé */}
|
||||||
{transactions.map((transaction, index) => {
|
<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 account = getAccount(transaction.accountId);
|
||||||
const isFocused = focusedIndex === index;
|
const isFocused = focusedIndex === virtualRow.index;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<div
|
||||||
key={transaction.id}
|
key={transaction.id}
|
||||||
ref={(el) => {
|
data-index={virtualRow.index}
|
||||||
rowRefs.current[index] = el;
|
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(
|
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",
|
transaction.isReconciled && "bg-emerald-500/5",
|
||||||
isFocused && "bg-primary/10 ring-1 ring-primary/30"
|
isFocused && "bg-primary/10 ring-1 ring-primary/30"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<td className="p-3">
|
<div className="p-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedTransactions.has(transaction.id)}
|
checked={selectedTransactions.has(transaction.id)}
|
||||||
onCheckedChange={() =>
|
onCheckedChange={() =>
|
||||||
onToggleSelectTransaction(transaction.id)
|
onToggleSelectTransaction(transaction.id)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</td>
|
</div>
|
||||||
<td className="p-3 text-sm text-muted-foreground whitespace-nowrap">
|
<div className="p-3 text-sm text-muted-foreground whitespace-nowrap">
|
||||||
{formatDate(transaction.date)}
|
{formatDate(transaction.date)}
|
||||||
</td>
|
</div>
|
||||||
<td className="p-3">
|
<div className="p-3">
|
||||||
<p className="font-medium text-sm">
|
<p className="font-medium text-sm">
|
||||||
{transaction.description}
|
{transaction.description}
|
||||||
</p>
|
</p>
|
||||||
@@ -213,11 +241,11 @@ export function TransactionTable({
|
|||||||
{transaction.memo}
|
{transaction.memo}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</td>
|
</div>
|
||||||
<td className="p-3 text-sm text-muted-foreground">
|
<div className="p-3 text-sm text-muted-foreground">
|
||||||
{account?.name || "-"}
|
{account?.name || "-"}
|
||||||
</td>
|
</div>
|
||||||
<td className="p-3" onClick={(e) => e.stopPropagation()}>
|
<div className="p-3" onClick={(e) => e.stopPropagation()}>
|
||||||
<CategoryCombobox
|
<CategoryCombobox
|
||||||
categories={categories}
|
categories={categories}
|
||||||
value={transaction.categoryId}
|
value={transaction.categoryId}
|
||||||
@@ -227,8 +255,8 @@ export function TransactionTable({
|
|||||||
showBadge
|
showBadge
|
||||||
align="start"
|
align="start"
|
||||||
/>
|
/>
|
||||||
</td>
|
</div>
|
||||||
<td
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"p-3 text-right font-semibold tabular-nums",
|
"p-3 text-right font-semibold tabular-nums",
|
||||||
transaction.amount >= 0
|
transaction.amount >= 0
|
||||||
@@ -238,8 +266,8 @@ export function TransactionTable({
|
|||||||
>
|
>
|
||||||
{transaction.amount >= 0 ? "+" : ""}
|
{transaction.amount >= 0 ? "+" : ""}
|
||||||
{formatCurrency(transaction.amount)}
|
{formatCurrency(transaction.amount)}
|
||||||
</td>
|
</div>
|
||||||
<td className="p-3 text-center" onClick={(e) => e.stopPropagation()}>
|
<div className="p-3 text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
onClick={() => onToggleReconciled(transaction.id)}
|
onClick={() => onToggleReconciled(transaction.id)}
|
||||||
className="p-1 hover:bg-muted rounded"
|
className="p-1 hover:bg-muted rounded"
|
||||||
@@ -250,8 +278,8 @@ export function TransactionTable({
|
|||||||
<Circle className="w-5 h-5 text-muted-foreground" />
|
<Circle className="w-5 h-5 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</div>
|
||||||
<td className="p-3" onClick={(e) => e.stopPropagation()}>
|
<div className="p-3" onClick={(e) => e.stopPropagation()}>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -293,12 +321,12 @@ export function TransactionTable({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
"seed": "tsx prisma/seed.ts"
|
"seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"@radix-ui/react-accordion": "1.2.2",
|
"@radix-ui/react-accordion": "1.2.2",
|
||||||
@@ -46,6 +49,7 @@
|
|||||||
"@radix-ui/react-toggle": "1.1.1",
|
"@radix-ui/react-toggle": "1.1.1",
|
||||||
"@radix-ui/react-toggle-group": "1.1.1",
|
"@radix-ui/react-toggle-group": "1.1.1",
|
||||||
"@radix-ui/react-tooltip": "1.1.6",
|
"@radix-ui/react-tooltip": "1.1.6",
|
||||||
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"@vercel/analytics": "1.3.1",
|
"@vercel/analytics": "1.3.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
|||||||
76
pnpm-lock.yaml
generated
76
pnpm-lock.yaml
generated
@@ -8,6 +8,15 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
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':
|
'@hookform/resolvers':
|
||||||
specifier: ^3.10.0
|
specifier: ^3.10.0
|
||||||
version: 3.10.0(react-hook-form@7.66.1(react@19.2.0))
|
version: 3.10.0(react-hook-form@7.66.1(react@19.2.0))
|
||||||
@@ -95,6 +104,9 @@ importers:
|
|||||||
'@radix-ui/react-tooltip':
|
'@radix-ui/react-tooltip':
|
||||||
specifier: 1.1.6
|
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)
|
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':
|
'@vercel/analytics':
|
||||||
specifier: 1.3.1
|
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)
|
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':
|
'@date-fns/tz@1.2.0':
|
||||||
resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==}
|
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':
|
'@emnapi/core@1.7.1':
|
||||||
resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==}
|
resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==}
|
||||||
|
|
||||||
@@ -1571,6 +1605,15 @@ packages:
|
|||||||
'@tailwindcss/postcss@4.1.17':
|
'@tailwindcss/postcss@4.1.17':
|
||||||
resolution: {integrity: sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==}
|
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':
|
'@tybys/wasm-util@0.10.1':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
@@ -3518,6 +3561,31 @@ snapshots:
|
|||||||
|
|
||||||
'@date-fns/tz@1.2.0': {}
|
'@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':
|
'@emnapi/core@1.7.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/wasi-threads': 1.1.0
|
'@emnapi/wasi-threads': 1.1.0
|
||||||
@@ -4667,6 +4735,14 @@ snapshots:
|
|||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
tailwindcss: 4.1.17
|
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':
|
'@tybys/wasm-util@0.10.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"frequency": "hourly",
|
"frequency": "hourly",
|
||||||
"lastBackup": "2025-11-30T06:48:53.740Z",
|
"lastBackup": "2025-11-30T09:50:17.696Z",
|
||||||
"nextBackup": "2025-11-30T07:00:00.000Z"
|
"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 },
|
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