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
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m39s
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
129
app/api/banking/accounts/merge/route.ts
Normal file
129
app/api/banking/accounts/merge/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
156
components/accounts/account-merge-select-dialog.tsx
Normal file
156
components/accounts/account-merge-select-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user