feat: add account merging functionality with dialog support; update bulk actions to include merge option
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m39s

This commit is contained in:
Julien Froidefond
2025-12-20 11:42:42 +01:00
parent 376bc8f84e
commit 8b81dfe8c0
5 changed files with 367 additions and 2 deletions

View File

@@ -16,6 +16,7 @@ import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
import {
AccountCard,
AccountEditDialog,
AccountMergeSelectDialog,
AccountBulkActions,
} from "@/components/accounts";
import { FolderEditDialog } from "@/components/folders";
@@ -112,6 +113,7 @@ export default function AccountsPage() {
});
const [activeId, setActiveId] = useState<string | null>(null);
const [isCompactView, setIsCompactView] = useState(false);
const [isMergeDialogOpen, setIsMergeDialogOpen] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -300,6 +302,43 @@ export default function AccountsPage() {
}
};
const handleMergeAccounts = async (
sourceAccountId: string,
targetAccountId: string,
) => {
try {
const response = await fetch("/api/banking/accounts/merge", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
sourceAccountId,
targetAccountId,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to merge accounts");
}
const result = await response.json();
invalidateAllAccountQueries(queryClient);
// Réinitialiser la sélection
setSelectedAccounts(new Set());
// Afficher un message de succès
alert(
`Fusion réussie ! ${result.transactionCount} transactions déplacées vers le compte de destination.`,
);
} catch (error) {
console.error("Error merging accounts:", error);
throw error;
}
};
// Drag and drop handlers
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
@@ -469,7 +508,13 @@ export default function AccountsPage() {
<>
<AccountBulkActions
selectedCount={selectedAccounts.size}
selectedAccountIds={Array.from(selectedAccounts)}
onDelete={handleBulkDelete}
onMerge={
selectedAccounts.size === 2
? () => setIsMergeDialogOpen(true)
: undefined
}
/>
<DndContext
sensors={sensors}
@@ -715,6 +760,21 @@ export default function AccountsPage() {
folders={metadata.folders}
onSave={handleSaveFolder}
/>
<AccountMergeSelectDialog
open={isMergeDialogOpen}
onOpenChange={(open) => {
setIsMergeDialogOpen(open);
if (!open) {
// Réinitialiser la sélection après fusion
setSelectedAccounts(new Set());
}
}}
accounts={accounts}
selectedAccountIds={Array.from(selectedAccounts)}
onMerge={handleMergeAccounts}
formatCurrency={formatCurrency}
/>
</PageLayout>
);
}

View File

@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth-utils";
export async function POST(request: NextRequest) {
const authError = await requireAuth();
if (authError) return authError;
try {
const { sourceAccountId, targetAccountId } = await request.json();
if (!sourceAccountId || !targetAccountId) {
return NextResponse.json(
{ error: "Source and target account IDs are required" },
{ status: 400 },
);
}
if (sourceAccountId === targetAccountId) {
return NextResponse.json(
{ error: "Source and target accounts must be different" },
{ status: 400 },
);
}
// Vérifier que les comptes existent
const [sourceAccount, targetAccount] = await Promise.all([
prisma.account.findUnique({
where: { id: sourceAccountId },
include: {
transactions: {
select: {
id: true,
},
},
},
}),
prisma.account.findUnique({
where: { id: targetAccountId },
}),
]);
if (!sourceAccount || !targetAccount) {
return NextResponse.json(
{ error: "One or both accounts not found" },
{ status: 404 },
);
}
const transactionCount = sourceAccount.transactions.length;
// Déplacer toutes les transactions vers le compte cible
await prisma.transaction.updateMany({
where: {
accountId: sourceAccountId,
},
data: {
accountId: targetAccountId,
},
});
// Recalculer le solde du compte cible à partir de toutes ses transactions
const allTransactions = await prisma.transaction.findMany({
where: {
accountId: targetAccountId,
},
select: {
amount: true,
},
});
const calculatedBalance = allTransactions.reduce(
(sum, t) => sum + t.amount,
0,
);
// Calculer l'initialBalance total (somme des deux comptes)
const totalInitialBalance =
sourceAccount.initialBalance + targetAccount.initialBalance;
// Mettre à jour le compte cible
await prisma.account.update({
where: {
id: targetAccountId,
},
data: {
balance: calculatedBalance,
initialBalance: totalInitialBalance,
// Garder le bankId du compte cible (celui qui est conservé)
// Garder le dernier import le plus récent
lastImport:
sourceAccount.lastImport && targetAccount.lastImport
? sourceAccount.lastImport > targetAccount.lastImport
? sourceAccount.lastImport
: targetAccount.lastImport
: sourceAccount.lastImport || targetAccount.lastImport,
},
});
// Supprimer le compte source
await prisma.account.delete({
where: {
id: sourceAccountId,
},
});
// Revalider les caches
revalidatePath("/accounts", "page");
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/dashboard", "page");
return NextResponse.json({
success: true,
message: `Fusion réussie : ${transactionCount} transactions déplacées`,
targetAccountId,
transactionCount,
calculatedBalance,
});
} catch (error) {
console.error("Error merging accounts:", error);
return NextResponse.json(
{ error: "Failed to merge accounts" },
{ status: 500 },
);
}
}

View File

@@ -2,29 +2,35 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Trash2 } from "lucide-react";
import { Trash2, GitMerge } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
interface AccountBulkActionsProps {
selectedCount: number;
selectedAccountIds: string[];
onDelete: () => void;
onMerge?: () => void;
}
export function AccountBulkActions({
selectedCount,
selectedAccountIds: _selectedAccountIds,
onDelete,
onMerge,
}: AccountBulkActionsProps) {
const isMobile = useIsMobile();
if (selectedCount === 0) return null;
const canMerge = selectedCount === 2 && onMerge;
return (
<Card className="bg-destructive/5 border-destructive/20 sticky top-0 z-10 mb-4">
<CardContent className={cn("py-3", isMobile && "px-3")}>
<div
className={cn(
"flex items-center gap-2 sm:gap-4",
"flex items-center gap-2 sm:gap-4 flex-wrap",
isMobile && "flex-col sm:flex-row",
)}
>
@@ -32,6 +38,19 @@ export function AccountBulkActions({
{selectedCount} compte{selectedCount > 1 ? "s" : ""} sélectionné
{selectedCount > 1 ? "s" : ""}
</span>
{canMerge && (
<Button
size={isMobile ? "sm" : "sm"}
variant="default"
onClick={onMerge}
className={cn(isMobile && "w-full sm:w-auto")}
>
<GitMerge
className={cn(isMobile ? "w-3.5 h-3.5" : "w-4 h-4", "mr-1")}
/>
Fusionner
</Button>
)}
<Button
size={isMobile ? "sm" : "sm"}
variant="destructive"

View File

@@ -0,0 +1,156 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertTriangle, Info } from "lucide-react";
import type { Account } from "@/lib/types";
import { getAccountBalance } from "@/lib/account-utils";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
interface AccountMergeSelectDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
accounts: Account[];
selectedAccountIds: string[];
onMerge: (sourceAccountId: string, targetAccountId: string) => Promise<void>;
formatCurrency: (amount: number) => string;
}
export function AccountMergeSelectDialog({
open,
onOpenChange,
accounts,
selectedAccountIds,
onMerge,
formatCurrency,
}: AccountMergeSelectDialogProps) {
const [targetAccountId, setTargetAccountId] = useState<string>("");
const [isMerging, setIsMerging] = useState(false);
const selectedAccounts = accounts.filter((a) =>
selectedAccountIds.includes(a.id),
);
const sourceAccountId =
selectedAccounts.length === 2 && targetAccountId
? selectedAccounts.find((a) => a.id !== targetAccountId)?.id || ""
: "";
const sourceAccount = accounts.find((a) => a.id === sourceAccountId);
const targetAccount = accounts.find((a) => a.id === targetAccountId);
// Initialiser avec le premier compte si pas encore sélectionné
if (open && selectedAccounts.length === 2 && !targetAccountId) {
setTargetAccountId(selectedAccounts[0].id);
}
const handleMerge = async () => {
if (!sourceAccountId || !targetAccountId || sourceAccountId === targetAccountId) {
return;
}
setIsMerging(true);
try {
await onMerge(sourceAccountId, targetAccountId);
setTargetAccountId("");
onOpenChange(false);
} catch (error) {
console.error("Error merging accounts:", error);
alert("Erreur lors de la fusion des comptes");
} finally {
setIsMerging(false);
}
};
const canMerge =
selectedAccounts.length === 2 &&
sourceAccountId &&
targetAccountId &&
sourceAccountId !== targetAccountId &&
sourceAccount &&
targetAccount;
if (selectedAccounts.length !== 2) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Fusionner des comptes</DialogTitle>
<DialogDescription>
Choisissez quel compte conserver. Toutes les transactions de l'autre
compte seront déplacées vers celui-ci.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Le compte sélectionné sera conservé et utilisé pour les prochains
imports. Choisissez celui qui a le bon bankId.
</AlertDescription>
</Alert>
<div className="space-y-3">
<Label>Compte à conserver (destination)</Label>
<RadioGroup value={targetAccountId} onValueChange={setTargetAccountId}>
{selectedAccounts.map((account) => (
<div
key={account.id}
className="flex items-start space-x-3 rounded-lg border p-3 hover:bg-accent"
>
<RadioGroupItem value={account.id} id={account.id} className="mt-1" />
<Label
htmlFor={account.id}
className="flex-1 cursor-pointer space-y-1"
>
<div className="font-medium">{account.name}</div>
<div className="text-xs text-muted-foreground space-y-0.5">
<div>Numéro: {account.accountNumber}</div>
<div>Bank ID: {account.bankId}</div>
<div>Solde: {formatCurrency(getAccountBalance(account))}</div>
</div>
</Label>
</div>
))}
</RadioGroup>
</div>
{canMerge && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<strong>Attention :</strong> Toutes les transactions du compte "
{sourceAccount.name}" seront déplacées vers "{targetAccount.name}". Le
compte "{sourceAccount.name}" sera supprimé après la fusion. Cette
action est irréversible.
</AlertDescription>
</Alert>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Annuler
</Button>
<Button onClick={handleMerge} disabled={!canMerge || isMerging}>
{isMerging ? "Fusion en cours..." : "Fusionner"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,4 +1,5 @@
export { AccountCard } from "./account-card";
export { AccountEditDialog } from "./account-edit-dialog";
export { AccountMergeSelectDialog } from "./account-merge-select-dialog";
export { AccountBulkActions } from "./account-bulk-actions";
export { accountTypeIcons, accountTypeLabels } from "./constants";