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 {
|
import {
|
||||||
AccountCard,
|
AccountCard,
|
||||||
AccountEditDialog,
|
AccountEditDialog,
|
||||||
|
AccountMergeSelectDialog,
|
||||||
AccountBulkActions,
|
AccountBulkActions,
|
||||||
} from "@/components/accounts";
|
} from "@/components/accounts";
|
||||||
import { FolderEditDialog } from "@/components/folders";
|
import { FolderEditDialog } from "@/components/folders";
|
||||||
@@ -112,6 +113,7 @@ export default function AccountsPage() {
|
|||||||
});
|
});
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [isCompactView, setIsCompactView] = useState(false);
|
const [isCompactView, setIsCompactView] = useState(false);
|
||||||
|
const [isMergeDialogOpen, setIsMergeDialogOpen] = useState(false);
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
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
|
// Drag and drop handlers
|
||||||
const handleDragStart = (event: DragStartEvent) => {
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
setActiveId(event.active.id as string);
|
setActiveId(event.active.id as string);
|
||||||
@@ -469,7 +508,13 @@ export default function AccountsPage() {
|
|||||||
<>
|
<>
|
||||||
<AccountBulkActions
|
<AccountBulkActions
|
||||||
selectedCount={selectedAccounts.size}
|
selectedCount={selectedAccounts.size}
|
||||||
|
selectedAccountIds={Array.from(selectedAccounts)}
|
||||||
onDelete={handleBulkDelete}
|
onDelete={handleBulkDelete}
|
||||||
|
onMerge={
|
||||||
|
selectedAccounts.size === 2
|
||||||
|
? () => setIsMergeDialogOpen(true)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
@@ -715,6 +760,21 @@ export default function AccountsPage() {
|
|||||||
folders={metadata.folders}
|
folders={metadata.folders}
|
||||||
onSave={handleSaveFolder}
|
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>
|
</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 { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
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 { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface AccountBulkActionsProps {
|
interface AccountBulkActionsProps {
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
|
selectedAccountIds: string[];
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
|
onMerge?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountBulkActions({
|
export function AccountBulkActions({
|
||||||
selectedCount,
|
selectedCount,
|
||||||
|
selectedAccountIds: _selectedAccountIds,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onMerge,
|
||||||
}: AccountBulkActionsProps) {
|
}: AccountBulkActionsProps) {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
if (selectedCount === 0) return null;
|
if (selectedCount === 0) return null;
|
||||||
|
|
||||||
|
const canMerge = selectedCount === 2 && onMerge;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-destructive/5 border-destructive/20 sticky top-0 z-10 mb-4">
|
<Card className="bg-destructive/5 border-destructive/20 sticky top-0 z-10 mb-4">
|
||||||
<CardContent className={cn("py-3", isMobile && "px-3")}>
|
<CardContent className={cn("py-3", isMobile && "px-3")}>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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",
|
isMobile && "flex-col sm:flex-row",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -32,6 +38,19 @@ export function AccountBulkActions({
|
|||||||
{selectedCount} compte{selectedCount > 1 ? "s" : ""} sélectionné
|
{selectedCount} compte{selectedCount > 1 ? "s" : ""} sélectionné
|
||||||
{selectedCount > 1 ? "s" : ""}
|
{selectedCount > 1 ? "s" : ""}
|
||||||
</span>
|
</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
|
<Button
|
||||||
size={isMobile ? "sm" : "sm"}
|
size={isMobile ? "sm" : "sm"}
|
||||||
variant="destructive"
|
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 { AccountCard } from "./account-card";
|
||||||
export { AccountEditDialog } from "./account-edit-dialog";
|
export { AccountEditDialog } from "./account-edit-dialog";
|
||||||
|
export { AccountMergeSelectDialog } from "./account-merge-select-dialog";
|
||||||
export { AccountBulkActions } from "./account-bulk-actions";
|
export { AccountBulkActions } from "./account-bulk-actions";
|
||||||
export { accountTypeIcons, accountTypeLabels } from "./constants";
|
export { accountTypeIcons, accountTypeLabels } from "./constants";
|
||||||
|
|||||||
Reference in New Issue
Block a user