From c4e7df40919ee90cbe60368ec807cad8cde0efb8 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Sun, 30 Nov 2025 16:34:53 +0100 Subject: [PATCH] feat: implement folder management and drag-and-drop functionality for accounts, enhancing organization and user experience --- app/accounts/page.tsx | 506 ++++++++++++++++++++------- app/folders/page.tsx | 338 ------------------ components/accounts/account-card.tsx | 157 ++++++--- components/dashboard/sidebar.tsx | 2 - lib/hooks.ts | 11 +- 5 files changed, 514 insertions(+), 500 deletions(-) delete mode 100644 app/folders/page.tsx diff --git a/app/accounts/page.tsx b/app/accounts/page.tsx index b29d6d5..9c0ef58 100644 --- a/app/accounts/page.tsx +++ b/app/accounts/page.tsx @@ -1,22 +1,61 @@ "use client"; import { useState } from "react"; +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + useSensor, + useSensors, + closestCenter, + useDroppable, +} from "@dnd-kit/core"; import { PageLayout, LoadingState, PageHeader } from "@/components/layout"; import { AccountCard, AccountEditDialog, AccountBulkActions, } from "@/components/accounts"; +import { + FolderEditDialog, +} from "@/components/folders"; import { useBankingData } from "@/lib/hooks"; -import { updateAccount, deleteAccount } from "@/lib/store-db"; +import { updateAccount, deleteAccount, addFolder, updateFolder, deleteFolder } from "@/lib/store-db"; import { Card, CardContent } from "@/components/ui/card"; -import { Building2, Folder } from "lucide-react"; -import type { Account } from "@/lib/types"; +import { Button } from "@/components/ui/button"; +import { Building2, Folder, Plus, List, LayoutGrid } from "lucide-react"; +import type { Account, Folder as FolderType } from "@/lib/types"; import { cn } from "@/lib/utils"; import { getAccountBalance } from "@/lib/account-utils"; +// Composant wrapper pour les zones de drop des dossiers +function FolderDropZone({ + folderId, + children, +}: { + folderId: string; + children: React.ReactNode; +}) { + const { setNodeRef, isOver } = useDroppable({ + id: `folder-${folderId}`, + }); + + return ( +
+ {children} +
+ ); +} + export default function AccountsPage() { - const { data, isLoading, refresh } = useBankingData(); + const { data, isLoading, refresh, refreshSilent, update } = useBankingData(); const [editingAccount, setEditingAccount] = useState(null); const [isDialogOpen, setIsDialogOpen] = useState(false); const [selectedAccounts, setSelectedAccounts] = useState>( @@ -30,6 +69,25 @@ export default function AccountsPage() { initialBalance: 0, }); + // Folder management state + const [isFolderDialogOpen, setIsFolderDialogOpen] = useState(false); + const [editingFolder, setEditingFolder] = useState(null); + const [folderFormData, setFolderFormData] = useState({ + name: "", + parentId: "folder-root" as string | null, + color: "#6366f1", + }); + const [activeId, setActiveId] = useState(null); + const [isCompactView, setIsCompactView] = useState(false); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + if (isLoading || !data) { return ; } @@ -125,6 +183,134 @@ export default function AccountsPage() { setSelectedAccounts(newSelected); }; + // Folder management handlers + const handleNewFolder = () => { + setEditingFolder(null); + setFolderFormData({ name: "", parentId: "folder-root", color: "#6366f1" }); + setIsFolderDialogOpen(true); + }; + + const handleEditFolder = (folder: FolderType) => { + setEditingFolder(folder); + setFolderFormData({ + name: folder.name, + parentId: folder.parentId || "folder-root", + color: folder.color, + }); + setIsFolderDialogOpen(true); + }; + + const handleSaveFolder = async () => { + const parentId = + folderFormData.parentId === "folder-root" ? null : folderFormData.parentId; + + try { + if (editingFolder) { + await updateFolder({ + ...editingFolder, + name: folderFormData.name, + parentId, + color: folderFormData.color, + }); + } else { + await addFolder({ + name: folderFormData.name, + parentId, + color: folderFormData.color, + icon: "folder", + }); + } + refresh(); + setIsFolderDialogOpen(false); + } catch (error) { + console.error("Error saving folder:", error); + alert("Erreur lors de la sauvegarde du dossier"); + } + }; + + const handleDeleteFolder = async (folderId: string) => { + if ( + !confirm( + "Supprimer ce dossier ? Les comptes seront déplacés à la racine." + ) + ) + return; + + try { + await deleteFolder(folderId); + refresh(); + } catch (error) { + console.error("Error deleting folder:", error); + alert("Erreur lors de la suppression du dossier"); + } + }; + + // Drag and drop handlers + 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 || !data) 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 === "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) { + const account = data.accounts.find((a) => a.id === accountId); + if (!account) return; + + // Sauvegarder l'état précédent pour rollback en cas d'erreur + const previousData = data; + + // Optimistic update : mettre à jour immédiatement l'interface + const updatedAccount = { + ...account, + folderId: targetFolderId, + }; + const updatedAccounts = data.accounts.map((a) => + a.id === accountId ? updatedAccount : a + ); + update({ + ...data, + accounts: updatedAccounts, + }); + + // Faire la requête en arrière-plan + try { + await updateAccount(updatedAccount); + // Refresh silencieux pour synchroniser avec le serveur sans loader + refreshSilent(); + } catch (error) { + console.error("Error moving account:", error); + // Rollback en cas d'erreur + update(previousData); + alert("Erreur lors du déplacement du compte"); + } + } + } + }; + const getTransactionCount = (accountId: string) => { return data.transactions.filter((t) => t.accountId === accountId).length; @@ -157,7 +343,27 @@ export default function AccountsPage() { + + + + } rightContent={

Solde total

@@ -190,122 +396,178 @@ export default function AccountsPage() { selectedCount={selectedAccounts.size} onDelete={handleBulkDelete} /> -
- {/* Afficher d'abord les comptes sans dossier */} - {accountsByFolder["no-folder"] && - accountsByFolder["no-folder"].length > 0 && ( -
-
- -

Sans dossier

- - ({accountsByFolder["no-folder"].length}) - - sum + getAccountBalance(a), - 0, - ) >= 0 - ? "text-emerald-600" - : "text-red-600", - )} - > - {formatCurrency( - accountsByFolder["no-folder"].reduce( - (sum, a) => sum + getAccountBalance(a), - 0, - ), - )} - -
-
- {accountsByFolder["no-folder"].map((account) => { - const folder = data.folders.find( - (f) => f.id === account.folderId, - ); - - return ( - - ); - })} -
-
- )} - - {/* Afficher les comptes groupés par folder */} - {rootFolders.map((folder) => { - const folderAccounts = accountsByFolder[folder.id] || []; - if (folderAccounts.length === 0) return null; - - const folderBalance = folderAccounts.reduce( - (sum, a) => sum + getAccountBalance(a), - 0, - ); - - return ( -
-
-
- + +
+ {/* Afficher d'abord les comptes sans dossier */} + {accountsByFolder["no-folder"] && + accountsByFolder["no-folder"].length > 0 && ( + +
+ +

Sans dossier

+ + ({accountsByFolder["no-folder"].length}) + + sum + getAccountBalance(a), + 0, + ) >= 0 + ? "text-emerald-600" + : "text-red-600", + )} + > + {formatCurrency( + accountsByFolder["no-folder"].reduce( + (sum, a) => sum + getAccountBalance(a), + 0, + ), + )} +
-

{folder.name}

- - ({folderAccounts.length}) - - = 0 - ? "text-emerald-600" - : "text-red-600", - )} - > - {formatCurrency(folderBalance)} - -
-
- {folderAccounts.map((account) => { - const accountFolder = data.folders.find( - (f) => f.id === account.folderId, - ); +
+ {accountsByFolder["no-folder"].map((account) => { + const folder = data.folders.find( + (f) => f.id === account.folderId, + ); - return ( - + ); + })} +
+ + )} + + {/* Afficher les comptes groupés par folder */} + {rootFolders.map((folder) => { + const folderAccounts = accountsByFolder[folder.id] || []; + const folderBalance = folderAccounts.reduce( + (sum, a) => sum + getAccountBalance(a), + 0, + ); + + return ( + +
+
+ - ); - })} -
+
+

{folder.name}

+ + ({folderAccounts.length}) + + {folderAccounts.length > 0 && ( + = 0 + ? "text-emerald-600" + : "text-red-600", + )} + > + {formatCurrency(folderBalance)} + + )} + + +
+ {folderAccounts.length > 0 ? ( +
+ {folderAccounts.map((account) => { + const accountFolder = data.folders.find( + (f) => f.id === account.folderId, + ); + + return ( + + ); + })} +
+ ) : ( + + + +

+ Aucun compte dans ce dossier +

+

+ Glissez-déposez un compte ici pour l'ajouter +

+
+
+ )} + + ); + })} +
+ + {activeId ? ( +
+ {activeId.startsWith("account-") ? ( + + + {data.accounts.find( + (a) => a.id === activeId.replace("account-", "") + )?.name || ""} + + + ) : null}
- ); - })} -
+ ) : null} + + )} @@ -317,6 +579,16 @@ export default function AccountsPage() { folders={data.folders} onSave={handleSave} /> + + ); } diff --git a/app/folders/page.tsx b/app/folders/page.tsx deleted file mode 100644 index 0dd3336..0000000 --- a/app/folders/page.tsx +++ /dev/null @@ -1,338 +0,0 @@ -"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, - FolderEditDialog, - AccountFolderDialog, -} from "@/components/folders"; -import { useBankingData } from "@/lib/hooks"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Plus } from "lucide-react"; -import { - addFolder, - updateFolder, - deleteFolder, - updateAccount, -} from "@/lib/store-db"; -import type { Folder as FolderType, Account } from "@/lib/types"; - -export default function FoldersPage() { - const { data, isLoading, refresh } = useBankingData(); - const [isDialogOpen, setIsDialogOpen] = useState(false); - const [editingFolder, setEditingFolder] = useState(null); - const [formData, setFormData] = useState({ - name: "", - parentId: "folder-root" as string | null, - color: "#6366f1", - }); - const [activeId, setActiveId] = useState(null); - - // Account editing state - const [isAccountDialogOpen, setIsAccountDialogOpen] = useState(false); - const [editingAccount, setEditingAccount] = useState(null); - const [accountFormData, setAccountFormData] = useState({ - name: "", - type: "CHECKING" as Account["type"], - folderId: "folder-root", - }); - - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }) - ); - - if (isLoading || !data) { - return ; - } - - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat("fr-FR", { - style: "currency", - currency: "EUR", - }).format(amount); - }; - - const rootFolders = data.folders.filter((f) => f.parentId === null); - - const handleNewFolder = () => { - setEditingFolder(null); - setFormData({ name: "", parentId: "folder-root", color: "#6366f1" }); - setIsDialogOpen(true); - }; - - const handleEdit = (folder: FolderType) => { - setEditingFolder(folder); - setFormData({ - name: folder.name, - parentId: folder.parentId || "folder-root", - color: folder.color, - }); - setIsDialogOpen(true); - }; - - const handleSave = async () => { - const parentId = - formData.parentId === "folder-root" ? null : formData.parentId; - - try { - if (editingFolder) { - await updateFolder({ - ...editingFolder, - name: formData.name, - parentId, - color: formData.color, - }); - } else { - await addFolder({ - name: formData.name, - parentId, - color: formData.color, - icon: "folder", - }); - } - refresh(); - setIsDialogOpen(false); - } catch (error) { - console.error("Error saving folder:", error); - alert("Erreur lors de la sauvegarde du dossier"); - } - }; - - const handleDelete = async (folderId: string) => { - if ( - !confirm( - "Supprimer ce dossier ? Les comptes seront déplacés à la racine." - ) - ) - return; - - try { - await deleteFolder(folderId); - refresh(); - } catch (error) { - console.error("Error deleting folder:", error); - alert("Erreur lors de la suppression du dossier"); - } - }; - - const handleEditAccount = (account: Account) => { - setEditingAccount(account); - setAccountFormData({ - name: account.name, - type: account.type, - folderId: account.folderId || "folder-root", - }); - setIsAccountDialogOpen(true); - }; - - const handleSaveAccount = async () => { - if (!editingAccount) return; - - try { - await updateAccount({ - ...editingAccount, - name: accountFormData.name, - type: accountFormData.type, - folderId: - accountFormData.folderId === "folder-root" - ? null - : accountFormData.folderId, - }); - refresh(); - setIsAccountDialogOpen(false); - setEditingAccount(null); - } catch (error) { - console.error("Error updating account:", error); - alert("Erreur lors de la mise à jour du compte"); - } - }; - - 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 ( - - - - Nouveau dossier - - } - /> - - - - Arborescence des comptes - - - - `folder-${f.id}`), - ...data.accounts.map((a) => `account-${a.id}`), - ]} - strategy={verticalListSortingStrategy} - > -
- {rootFolders.map((folder) => ( - - ))} -
-
- - {activeId ? ( -
- {activeId.startsWith("account-") ? ( -
- {data.accounts.find( - (a) => a.id === activeId.replace("account-", "") - )?.name || ""} -
- ) : ( -
- {data.folders.find( - (f) => f.id === activeId.replace("folder-", "") - )?.name || ""} -
- )} -
- ) : null} -
-
-
-
- - - - -
- ); -} diff --git a/components/accounts/account-card.tsx b/components/accounts/account-card.tsx index f60018e..6e3e515 100644 --- a/components/accounts/account-card.tsx +++ b/components/accounts/account-card.tsx @@ -1,5 +1,7 @@ "use client"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { @@ -8,7 +10,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { MoreVertical, Pencil, Trash2, ExternalLink } from "lucide-react"; +import { MoreVertical, Pencil, Trash2, ExternalLink, GripVertical } from "lucide-react"; import { cn } from "@/lib/utils"; import Link from "next/link"; import type { Account, Folder } from "@/lib/types"; @@ -25,6 +27,8 @@ interface AccountCardProps { formatCurrency: (amount: number) => string; isSelected?: boolean; onSelect?: (accountId: string, selected: boolean) => void; + draggableId?: string; + compact?: boolean; } export function AccountCard({ @@ -36,15 +40,49 @@ export function AccountCard({ formatCurrency, isSelected = false, onSelect, + draggableId, + compact = false, }: AccountCardProps) { const Icon = accountTypeIcons[account.type]; const realBalance = getAccountBalance(account); - return ( - + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: draggableId || `account-${account.id}`, + disabled: !draggableId, + data: { + type: "account", + account, + }, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + const cardContent = ( +
+ {draggableId && ( + + )} {onSelect && (
{account.name} -

- {accountTypeLabels[account.type]} -

- {account.accountNumber && ( -

- {account.accountNumber} -

+ {!compact && ( + <> +

+ {accountTypeLabels[account.type]} +

+ {account.accountNumber && ( +

+ {account.accountNumber} +

+ )} + )}
@@ -91,43 +133,74 @@ export function AccountCard({
- -
= 0 ? "text-emerald-600" : "text-red-600" + +
+
= 0 ? "text-emerald-600" : "text-red-600" + )} + > + {formatCurrency(realBalance)} +
+ {compact && ( + + {transactionCount} transactions + )} - > - {formatCurrency(realBalance)}
-
- - {transactionCount} transactions - - {folder && {folder.name}} -
- {account.lastImport && ( -

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

- )} - {account.externalUrl && ( - - - Accéder au portail banque - + {!compact && ( + <> +
+ + {transactionCount} transactions + + {folder && {folder.name}} +
+ {account.initialBalance !== undefined && account.initialBalance !== null && ( +

+ Solde initial: {formatCurrency(account.initialBalance)} +

+ )} + {account.lastImport && ( +

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

+ )} + {account.externalUrl && ( + + + Accéder au portail banque + + )} + )}
); + + if (draggableId) { + return ( +
+ {cardContent} +
+ ); + } + + return cardContent; } diff --git a/components/dashboard/sidebar.tsx b/components/dashboard/sidebar.tsx index 6517c3e..5907f83 100644 --- a/components/dashboard/sidebar.tsx +++ b/components/dashboard/sidebar.tsx @@ -9,7 +9,6 @@ import { Button } from "@/components/ui/button"; import { LayoutDashboard, Wallet, - FolderTree, Tags, BarChart3, Upload, @@ -24,7 +23,6 @@ import { toast } from "sonner"; const navItems = [ { href: "/", label: "Tableau de bord", icon: LayoutDashboard }, { href: "/accounts", label: "Comptes", icon: Wallet }, - { href: "/folders", label: "Organisation", icon: FolderTree }, { href: "/transactions", label: "Transactions", icon: Upload }, { href: "/categories", label: "Catégories", icon: Tags }, { href: "/rules", label: "Règles", icon: Wand2 }, diff --git a/lib/hooks.ts b/lib/hooks.ts index 7b599b8..c15c2e7 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -31,12 +31,21 @@ export function useBankingData() { fetchData(); }, [fetchData]); + const refreshSilent = useCallback(async () => { + try { + const fetchedData = await loadData(); + setData(fetchedData); + } catch (err) { + console.error("Error silently refreshing banking data:", err); + } + }, []); + const update = useCallback((newData: BankingData) => { // Optimistic update - the actual save happens in individual operations setData(newData); }, []); - return { data, isLoading, error, refresh, update }; + return { data, isLoading, error, refresh, refreshSilent, update }; } export function useLocalStorage(key: string, initialValue: T) {