refactor: enhance cache invalidation logic across banking API routes and components for improved data consistency and performance

This commit is contained in:
Julien Froidefond
2025-12-08 14:04:12 +01:00
parent 53bae084c4
commit 8d947ad70f
14 changed files with 412 additions and 200 deletions

View File

@@ -28,6 +28,7 @@ import {
updateFolder, updateFolder,
deleteFolder, deleteFolder,
} from "@/lib/store-db"; } from "@/lib/store-db";
import { invalidateAllAccountQueries } from "@/lib/cache-utils";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -67,7 +68,7 @@ function FolderDropZone({
<div <div
ref={setNodeRef} ref={setNodeRef}
className={cn( className={cn(
isOver && "ring-2 ring-primary ring-offset-2 rounded-lg p-2", isOver && "ring-2 ring-primary ring-offset-2 rounded-lg p-2"
)} )}
> >
{children} {children}
@@ -85,13 +86,12 @@ export default function AccountsPage() {
// refresh function is not used directly, invalidations are done inline // refresh function is not used directly, invalidations are done inline
const refreshSilent = async () => { const refreshSilent = async () => {
await queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] }); invalidateAllAccountQueries(queryClient);
await queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
}; };
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>>( const [selectedAccounts, setSelectedAccounts] = useState<Set<string>>(
new Set(), new Set()
); );
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: "", name: "",
@@ -117,7 +117,7 @@ export default function AccountsPage() {
activationConstraint: { activationConstraint: {
distance: 8, distance: 8,
}, },
}), })
); );
if ( if (
@@ -131,7 +131,7 @@ export default function AccountsPage() {
// Convert accountsWithStats to regular accounts for compatibility // Convert accountsWithStats to regular accounts for compatibility
const accounts = accountsWithStats.map( const accounts = accountsWithStats.map(
({ transactionCount: _transactionCount, ...account }) => account, ({ transactionCount: _transactionCount, ...account }) => account
); );
const formatCurrency = (amount: number) => { const formatCurrency = (amount: number) => {
@@ -166,8 +166,7 @@ export default function AccountsPage() {
initialBalance: formData.initialBalance, initialBalance: formData.initialBalance,
}; };
await updateAccount(updatedAccount); await updateAccount(updatedAccount);
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] }); invalidateAllAccountQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
setIsDialogOpen(false); setIsDialogOpen(false);
setEditingAccount(null); setEditingAccount(null);
} catch (error) { } catch (error) {
@@ -181,8 +180,7 @@ export default function AccountsPage() {
try { try {
await deleteAccount(accountId); await deleteAccount(accountId);
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] }); invalidateAllAccountQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
} catch (error) { } catch (error) {
console.error("Error deleting account:", error); console.error("Error deleting account:", error);
alert("Erreur lors de la suppression du compte"); alert("Erreur lors de la suppression du compte");
@@ -193,7 +191,7 @@ export default function AccountsPage() {
const count = selectedAccounts.size; const count = selectedAccounts.size;
if ( if (
!confirm( !confirm(
`Supprimer ${count} compte${count > 1 ? "s" : ""} et toutes leurs transactions ?`, `Supprimer ${count} compte${count > 1 ? "s" : ""} et toutes leurs transactions ?`
) )
) )
return; return;
@@ -204,14 +202,13 @@ export default function AccountsPage() {
`/api/banking/accounts?ids=${ids.join(",")}`, `/api/banking/accounts?ids=${ids.join(",")}`,
{ {
method: "DELETE", method: "DELETE",
}, }
); );
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to delete accounts"); throw new Error("Failed to delete accounts");
} }
setSelectedAccounts(new Set()); setSelectedAccounts(new Set());
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] }); invalidateAllAccountQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
} catch (error) { } catch (error) {
console.error("Error deleting accounts:", error); console.error("Error deleting accounts:", error);
alert("Erreur lors de la suppression des comptes"); alert("Erreur lors de la suppression des comptes");
@@ -267,8 +264,7 @@ export default function AccountsPage() {
icon: "folder", icon: "folder",
}); });
} }
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] }); invalidateAllAccountQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
setIsFolderDialogOpen(false); setIsFolderDialogOpen(false);
} catch (error) { } catch (error) {
console.error("Error saving folder:", error); console.error("Error saving folder:", error);
@@ -279,15 +275,14 @@ export default function AccountsPage() {
const handleDeleteFolder = async (folderId: string) => { const handleDeleteFolder = async (folderId: string) => {
if ( if (
!confirm( !confirm(
"Supprimer ce dossier ? Les comptes seront déplacés à la racine.", "Supprimer ce dossier ? Les comptes seront déplacés à la racine."
) )
) )
return; return;
try { try {
await deleteFolder(folderId); await deleteFolder(folderId);
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] }); invalidateAllAccountQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
} catch (error) { } catch (error) {
console.error("Error deleting folder:", error); console.error("Error deleting folder:", error);
alert("Erreur lors de la suppression du dossier"); alert("Erreur lors de la suppression du dossier");
@@ -320,7 +315,7 @@ export default function AccountsPage() {
// Déplacer vers le dossier du compte cible // Déplacer vers le dossier du compte cible
const targetAccountId = overId.replace("account-", ""); const targetAccountId = overId.replace("account-", "");
const targetAccount = accountsWithStats.find( const targetAccount = accountsWithStats.find(
(a) => a.id === targetAccountId, (a) => a.id === targetAccountId
); );
if (targetAccount) { if (targetAccount) {
targetFolderId = targetAccount.folderId; targetFolderId = targetAccount.folderId;
@@ -342,7 +337,7 @@ export default function AccountsPage() {
(old: Array<Account & { transactionCount: number }> | undefined) => { (old: Array<Account & { transactionCount: number }> | undefined) => {
if (!old) return old; if (!old) return old;
return old.map((a) => (a.id === accountId ? updatedAccount : a)); return old.map((a) => (a.id === accountId ? updatedAccount : a));
}, }
); );
// Faire la requête en arrière-plan // Faire la requête en arrière-plan
@@ -353,8 +348,7 @@ export default function AccountsPage() {
} catch (error) { } catch (error) {
console.error("Error moving account:", error); console.error("Error moving account:", error);
// Rollback en cas d'erreur - refresh data // Rollback en cas d'erreur - refresh data
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] }); invalidateAllAccountQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
alert("Erreur lors du déplacement du compte"); alert("Erreur lors du déplacement du compte");
} }
} }
@@ -368,7 +362,7 @@ export default function AccountsPage() {
const totalBalance = accounts.reduce( const totalBalance = accounts.reduce(
(sum, a) => sum + getAccountBalance(a), (sum, a) => sum + getAccountBalance(a),
0, 0
); );
// Grouper les comptes par folder // Grouper les comptes par folder
@@ -381,7 +375,7 @@ export default function AccountsPage() {
acc[folderId].push(account); acc[folderId].push(account);
return acc; return acc;
}, },
{} as Record<string, Account[]>, {} as Record<string, Account[]>
); );
// Obtenir les folders racine (sans parent) et les trier par nom // Obtenir les folders racine (sans parent) et les trier par nom
@@ -435,7 +429,7 @@ export default function AccountsPage() {
className={cn( className={cn(
isMobile ? "text-xl" : "text-2xl", isMobile ? "text-xl" : "text-2xl",
"font-bold", "font-bold",
totalBalance >= 0 ? "text-emerald-600" : "text-red-600", totalBalance >= 0 ? "text-emerald-600" : "text-red-600"
)} )}
> >
{isMobile && ( {isMobile && (
@@ -492,17 +486,17 @@ export default function AccountsPage() {
"text-xs sm:text-sm font-semibold tabular-nums shrink-0", "text-xs sm:text-sm font-semibold tabular-nums shrink-0",
accountsByFolder["no-folder"].reduce( accountsByFolder["no-folder"].reduce(
(sum, a) => sum + getAccountBalance(a), (sum, a) => sum + getAccountBalance(a),
0, 0
) >= 0 ) >= 0
? "text-emerald-600" ? "text-emerald-600"
: "text-red-600", : "text-red-600"
)} )}
> >
{formatCurrency( {formatCurrency(
accountsByFolder["no-folder"].reduce( accountsByFolder["no-folder"].reduce(
(sum, a) => sum + getAccountBalance(a), (sum, a) => sum + getAccountBalance(a),
0, 0
), )
)} )}
</span> </span>
</div> </div>
@@ -511,7 +505,7 @@ export default function AccountsPage() {
<div className="grid gap-2 sm:gap-3 md:gap-4 grid-cols-2 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-2 sm:gap-3 md:gap-4 grid-cols-2 sm:grid-cols-2 lg:grid-cols-3">
{accountsByFolder["no-folder"].map((account) => { {accountsByFolder["no-folder"].map((account) => {
const folder = metadata.folders.find( const folder = metadata.folders.find(
(f: FolderType) => f.id === account.folderId, (f: FolderType) => f.id === account.folderId
); );
return ( return (
@@ -539,7 +533,7 @@ export default function AccountsPage() {
const folderAccounts = accountsByFolder[folder.id] || []; const folderAccounts = accountsByFolder[folder.id] || [];
const folderBalance = folderAccounts.reduce( const folderBalance = folderAccounts.reduce(
(sum, a) => sum + getAccountBalance(a), (sum, a) => sum + getAccountBalance(a),
0, 0
); );
return ( return (
@@ -568,7 +562,7 @@ export default function AccountsPage() {
"text-xs sm:text-sm font-semibold tabular-nums shrink-0", "text-xs sm:text-sm font-semibold tabular-nums shrink-0",
folderBalance >= 0 folderBalance >= 0
? "text-emerald-600" ? "text-emerald-600"
: "text-red-600", : "text-red-600"
)} )}
> >
{formatCurrency(folderBalance)} {formatCurrency(folderBalance)}
@@ -628,7 +622,7 @@ export default function AccountsPage() {
<div className="grid gap-2 sm:gap-3 md:gap-4 grid-cols-2 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-2 sm:gap-3 md:gap-4 grid-cols-2 sm:grid-cols-2 lg:grid-cols-3">
{folderAccounts.map((account) => { {folderAccounts.map((account) => {
const accountFolder = metadata.folders.find( const accountFolder = metadata.folders.find(
(f: FolderType) => f.id === account.folderId, (f: FolderType) => f.id === account.folderId
); );
return ( return (
@@ -672,7 +666,7 @@ export default function AccountsPage() {
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
{accounts.find( {accounts.find(
(a) => a.id === activeId.replace("account-", ""), (a) => a.id === activeId.replace("account-", "")
)?.name || ""} )?.name || ""}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { accountService } from "@/services/account.service"; import { accountService } from "@/services/account.service";
import { bankingService } from "@/services/banking.service"; import { bankingService } from "@/services/banking.service";
import { requireAuth } from "@/lib/auth-utils"; import { requireAuth } from "@/lib/auth-utils";
@@ -14,11 +15,7 @@ export async function GET(request: NextRequest) {
if (withStats) { if (withStats) {
const accountsWithStats = await bankingService.getAccountsWithStats(); const accountsWithStats = await bankingService.getAccountsWithStats();
return NextResponse.json(accountsWithStats, { return NextResponse.json(accountsWithStats);
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
});
} }
return NextResponse.json({ error: "Invalid request" }, { status: 400 }); return NextResponse.json({ error: "Invalid request" }, { status: 400 });
@@ -26,7 +23,7 @@ export async function GET(request: NextRequest) {
console.error("Error fetching accounts:", error); console.error("Error fetching accounts:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to fetch accounts" }, { error: "Failed to fetch accounts" },
{ status: 500 }, { status: 500 }
); );
} }
} }
@@ -38,12 +35,23 @@ export async function POST(request: Request) {
try { try {
const data: Omit<Account, "id"> = await request.json(); const data: Omit<Account, "id"> = await request.json();
const created = await accountService.create(data); const created = await accountService.create(data);
return NextResponse.json(created);
// Revalider le cache serveur
revalidatePath("/accounts", "page");
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/dashboard", "page");
return NextResponse.json(created, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) { } catch (error) {
console.error("Error creating account:", error); console.error("Error creating account:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to create account" }, { error: "Failed to create account" },
{ status: 500 }, { status: 500 }
); );
} }
} }
@@ -55,12 +63,23 @@ export async function PUT(request: Request) {
try { try {
const account: Account = await request.json(); const account: Account = await request.json();
const updated = await accountService.update(account.id, account); const updated = await accountService.update(account.id, account);
return NextResponse.json(updated);
// Revalider le cache serveur
revalidatePath("/accounts", "page");
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/dashboard", "page");
return NextResponse.json(updated, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) { } catch (error) {
console.error("Error updating account:", error); console.error("Error updating account:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to update account" }, { error: "Failed to update account" },
{ status: 500 }, { status: 500 }
); );
} }
} }
@@ -80,27 +99,55 @@ export async function DELETE(request: Request) {
if (accountIds.length === 0) { if (accountIds.length === 0) {
return NextResponse.json( return NextResponse.json(
{ error: "At least one account ID is required" }, { error: "At least one account ID is required" },
{ status: 400 }, { status: 400 }
); );
} }
await accountService.deleteMany(accountIds); await accountService.deleteMany(accountIds);
return NextResponse.json({ success: true, count: accountIds.length });
// Revalider le cache serveur
revalidatePath("/accounts", "page");
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/dashboard", "page");
return NextResponse.json(
{ success: true, count: accountIds.length },
{
headers: {
"Cache-Control": "no-store",
},
}
);
} }
if (!id) { if (!id) {
return NextResponse.json( return NextResponse.json(
{ error: "Account ID is required" }, { error: "Account ID is required" },
{ status: 400 }, { status: 400 }
); );
} }
await accountService.delete(id); await accountService.delete(id);
return NextResponse.json({ success: true });
// Revalider le cache serveur
revalidatePath("/accounts", "page");
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/dashboard", "page");
return NextResponse.json(
{ success: true },
{
headers: {
"Cache-Control": "no-store",
},
}
);
} catch (error) { } catch (error) {
console.error("Error deleting account:", error); console.error("Error deleting account:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to delete account" }, { error: "Failed to delete account" },
{ status: 500 }, { status: 500 }
); );
} }
} }

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { categoryService } from "@/services/category.service"; import { categoryService } from "@/services/category.service";
import { bankingService } from "@/services/banking.service"; import { bankingService } from "@/services/banking.service";
import { requireAuth } from "@/lib/auth-utils"; import { requireAuth } from "@/lib/auth-utils";
@@ -14,11 +15,7 @@ export async function GET(request: NextRequest) {
if (statsOnly) { if (statsOnly) {
const stats = await bankingService.getCategoryStats(); const stats = await bankingService.getCategoryStats();
return NextResponse.json(stats, { return NextResponse.json(stats);
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
});
} }
return NextResponse.json({ error: "Invalid request" }, { status: 400 }); return NextResponse.json({ error: "Invalid request" }, { status: 400 });
@@ -26,7 +23,7 @@ export async function GET(request: NextRequest) {
console.error("Error fetching category stats:", error); console.error("Error fetching category stats:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to fetch category stats" }, { error: "Failed to fetch category stats" },
{ status: 500 }, { status: 500 }
); );
} }
} }
@@ -37,12 +34,23 @@ export async function POST(request: Request) {
try { try {
const data: Omit<Category, "id"> = await request.json(); const data: Omit<Category, "id"> = await request.json();
const created = await categoryService.create(data); const created = await categoryService.create(data);
return NextResponse.json(created);
// Revalider le cache serveur
revalidatePath("/categories", "page");
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/rules", "page");
return NextResponse.json(created, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) { } catch (error) {
console.error("Error creating category:", error); console.error("Error creating category:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to create category" }, { error: "Failed to create category" },
{ status: 500 }, { status: 500 }
); );
} }
} }
@@ -54,12 +62,23 @@ export async function PUT(request: Request) {
try { try {
const category: Category = await request.json(); const category: Category = await request.json();
const updated = await categoryService.update(category.id, category); const updated = await categoryService.update(category.id, category);
return NextResponse.json(updated);
// Revalider le cache serveur
revalidatePath("/categories", "page");
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/rules", "page");
return NextResponse.json(updated, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) { } catch (error) {
console.error("Error updating category:", error); console.error("Error updating category:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to update category" }, { error: "Failed to update category" },
{ status: 500 }, { status: 500 }
); );
} }
} }
@@ -75,17 +94,31 @@ export async function DELETE(request: Request) {
if (!id) { if (!id) {
return NextResponse.json( return NextResponse.json(
{ error: "Category ID is required" }, { error: "Category ID is required" },
{ status: 400 }, { status: 400 }
); );
} }
await categoryService.delete(id); await categoryService.delete(id);
return NextResponse.json({ success: true });
// Revalider le cache serveur
revalidatePath("/categories", "page");
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/rules", "page");
return NextResponse.json(
{ success: true },
{
headers: {
"Cache-Control": "no-store",
},
}
);
} catch (error) { } catch (error) {
console.error("Error deleting category:", error); console.error("Error deleting category:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to delete category" }, { error: "Failed to delete category" },
{ status: 500 }, { status: 500 }
); );
} }
} }

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { folderService, FolderNotFoundError } from "@/services/folder.service"; import { folderService, FolderNotFoundError } from "@/services/folder.service";
import { requireAuth } from "@/lib/auth-utils"; import { requireAuth } from "@/lib/auth-utils";
import type { Folder } from "@/lib/types"; import type { Folder } from "@/lib/types";
@@ -9,12 +10,20 @@ export async function POST(request: Request) {
try { try {
const data: Omit<Folder, "id"> = await request.json(); const data: Omit<Folder, "id"> = await request.json();
const created = await folderService.create(data); const created = await folderService.create(data);
return NextResponse.json(created);
// Revalider le cache des pages
revalidatePath("/accounts", "page");
return NextResponse.json(created, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) { } catch (error) {
console.error("Error creating folder:", error); console.error("Error creating folder:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to create folder" }, { error: "Failed to create folder" },
{ status: 500 }, { status: 500 }
); );
} }
} }
@@ -26,12 +35,20 @@ export async function PUT(request: Request) {
try { try {
const folder: Folder = await request.json(); const folder: Folder = await request.json();
const updated = await folderService.update(folder.id, folder); const updated = await folderService.update(folder.id, folder);
return NextResponse.json(updated);
// Revalider le cache des pages
revalidatePath("/accounts", "page");
return NextResponse.json(updated, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) { } catch (error) {
console.error("Error updating folder:", error); console.error("Error updating folder:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to update folder" }, { error: "Failed to update folder" },
{ status: 500 }, { status: 500 }
); );
} }
} }
@@ -47,12 +64,23 @@ export async function DELETE(request: Request) {
if (!id) { if (!id) {
return NextResponse.json( return NextResponse.json(
{ error: "Folder ID is required" }, { error: "Folder ID is required" },
{ status: 400 }, { status: 400 }
); );
} }
await folderService.delete(id); await folderService.delete(id);
return NextResponse.json({ success: true });
// Revalider le cache des pages
revalidatePath("/accounts", "page");
return NextResponse.json(
{ success: true },
{
headers: {
"Cache-Control": "no-store",
},
}
);
} catch (error) { } catch (error) {
if (error instanceof FolderNotFoundError) { if (error instanceof FolderNotFoundError) {
return NextResponse.json({ error: "Folder not found" }, { status: 404 }); return NextResponse.json({ error: "Folder not found" }, { status: 404 });
@@ -60,7 +88,7 @@ export async function DELETE(request: Request) {
console.error("Error deleting folder:", error); console.error("Error deleting folder:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to delete folder" }, { error: "Failed to delete folder" },
{ status: 500 }, { status: 500 }
); );
} }
} }

View File

@@ -12,19 +12,11 @@ export async function GET(request: NextRequest) {
if (metadataOnly) { if (metadataOnly) {
const metadata = await bankingService.getMetadata(); const metadata = await bankingService.getMetadata();
return NextResponse.json(metadata, { return NextResponse.json(metadata);
headers: {
"Cache-Control": "public, s-maxage=300, stale-while-revalidate=600",
},
});
} }
const data = await bankingService.getAllData(); const data = await bankingService.getAllData();
return NextResponse.json(data, { return NextResponse.json(data);
headers: {
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
},
});
} catch (error) { } catch (error) {
console.error("Error fetching banking data:", error); console.error("Error fetching banking data:", error);
return NextResponse.json( return NextResponse.json(

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { requireAuth } from "@/lib/auth-utils"; import { requireAuth } from "@/lib/auth-utils";
@@ -15,10 +16,23 @@ export async function POST() {
}, },
}); });
return NextResponse.json({ // Revalider le cache des pages
success: true, revalidatePath("/transactions", "page");
count: result.count, revalidatePath("/statistics", "page");
}); revalidatePath("/categories", "page");
revalidatePath("/dashboard", "page");
return NextResponse.json(
{
success: true,
count: result.count,
},
{
headers: {
"Cache-Control": "no-store",
},
}
);
} catch (error) { } catch (error) {
console.error("Error clearing categories:", error); console.error("Error clearing categories:", error);
return NextResponse.json( return NextResponse.json(

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { transactionService } from "@/services/transaction.service"; import { transactionService } from "@/services/transaction.service";
import { requireAuth } from "@/lib/auth-utils"; import { requireAuth } from "@/lib/auth-utils";
@@ -8,7 +9,17 @@ export async function POST() {
try { try {
const result = await transactionService.deduplicate(); const result = await transactionService.deduplicate();
return NextResponse.json(result);
// Revalider le cache des pages
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/dashboard", "page");
return NextResponse.json(result, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) { } catch (error) {
console.error("Error deduplicating transactions:", error); console.error("Error deduplicating transactions:", error);
return NextResponse.json( return NextResponse.json(

View File

@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { transactionService } from "@/services/transaction.service"; import { transactionService } from "@/services/transaction.service";
import { bankingService } from "@/services/banking.service"; import { bankingService } from "@/services/banking.service";
import { requireAuth } from "@/lib/auth-utils"; import { requireAuth } from "@/lib/auth-utils";
@@ -28,7 +29,7 @@ export async function GET(request: NextRequest) {
searchParams.get("includeUncategorized") === "true"; searchParams.get("includeUncategorized") === "true";
const search = searchParams.get("search") || undefined; const search = searchParams.get("search") || undefined;
const isReconciledParam = searchParams.get("isReconciled"); const isReconciledParam = searchParams.get("isReconciled");
const isReconciled = const isReconciled: boolean | "all" =
isReconciledParam === "true" isReconciledParam === "true"
? true ? true
: isReconciledParam === "false" : isReconciledParam === "false"
@@ -40,7 +41,7 @@ export async function GET(request: NextRequest) {
const sortOrder = const sortOrder =
(searchParams.get("sortOrder") as "asc" | "desc") || "desc"; (searchParams.get("sortOrder") as "asc" | "desc") || "desc";
const result = await bankingService.getTransactionsPaginated({ const params = {
limit, limit,
offset, offset,
startDate, startDate,
@@ -52,18 +53,18 @@ export async function GET(request: NextRequest) {
isReconciled, isReconciled,
sortField, sortField,
sortOrder, sortOrder,
}); };
return NextResponse.json(result, { // Pas de cache serveur pour garantir des données toujours à jour
headers: { // Le cache client React Query gère déjà la mise en cache côté client
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120", const result = await bankingService.getTransactionsPaginated(params);
},
}); return NextResponse.json(result);
} catch (error) { } catch (error) {
console.error("Error fetching transactions:", error); console.error("Error fetching transactions:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to fetch transactions" }, { error: "Failed to fetch transactions" },
{ status: 500 }, { status: 500 }
); );
} }
} }
@@ -74,12 +75,22 @@ export async function POST(request: Request) {
try { try {
const transactions: Transaction[] = await request.json(); const transactions: Transaction[] = await request.json();
const result = await transactionService.createMany(transactions); const result = await transactionService.createMany(transactions);
return NextResponse.json(result);
// Revalider le cache des pages
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/dashboard", "page");
return NextResponse.json(result, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) { } catch (error) {
console.error("Error creating transactions:", error); console.error("Error creating transactions:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to create transactions" }, { error: "Failed to create transactions" },
{ status: 500 }, { status: 500 }
); );
} }
} }
@@ -92,14 +103,24 @@ export async function PUT(request: Request) {
const transaction: Transaction = await request.json(); const transaction: Transaction = await request.json();
const updated = await transactionService.update( const updated = await transactionService.update(
transaction.id, transaction.id,
transaction, transaction
); );
return NextResponse.json(updated);
// Revalider le cache des pages
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/dashboard", "page");
return NextResponse.json(updated, {
headers: {
"Cache-Control": "no-store",
},
});
} catch (error) { } catch (error) {
console.error("Error updating transaction:", error); console.error("Error updating transaction:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to update transaction" }, { error: "Failed to update transaction" },
{ status: 500 }, { status: 500 }
); );
} }
} }
@@ -115,12 +136,25 @@ export async function DELETE(request: Request) {
if (!id) { if (!id) {
return NextResponse.json( return NextResponse.json(
{ error: "Transaction ID is required" }, { error: "Transaction ID is required" },
{ status: 400 }, { status: 400 }
); );
} }
await transactionService.delete(id); await transactionService.delete(id);
return NextResponse.json({ success: true });
// Revalider le cache des pages
revalidatePath("/transactions", "page");
revalidatePath("/statistics", "page");
revalidatePath("/dashboard", "page");
return NextResponse.json(
{ success: true },
{
headers: {
"Cache-Control": "no-store",
},
}
);
} catch (error) { } catch (error) {
console.error("Error deleting transaction:", error); console.error("Error deleting transaction:", error);
const errorMessage = const errorMessage =

View File

@@ -28,6 +28,7 @@ import {
deleteCategory, deleteCategory,
} from "@/lib/store-db"; } from "@/lib/store-db";
import type { Category, Transaction } from "@/lib/types"; import type { Category, Transaction } from "@/lib/types";
import { invalidateAllCategoryQueries } from "@/lib/cache-utils";
interface RecategorizationResult { interface RecategorizationResult {
transaction: Transaction; transaction: Transaction;
@@ -41,7 +42,7 @@ export default function CategoriesPage() {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState<Category | null>(null); const [editingCategory, setEditingCategory] = useState<Category | null>(null);
const [expandedParents, setExpandedParents] = useState<Set<string>>( const [expandedParents, setExpandedParents] = useState<Set<string>>(
new Set(), new Set()
); );
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: "", name: "",
@@ -52,7 +53,7 @@ export default function CategoriesPage() {
}); });
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [recatResults, setRecatResults] = useState<RecategorizationResult[]>( const [recatResults, setRecatResults] = useState<RecategorizationResult[]>(
[], []
); );
const [isRecatDialogOpen, setIsRecatDialogOpen] = useState(false); const [isRecatDialogOpen, setIsRecatDialogOpen] = useState(false);
const [isRecategorizing, setIsRecategorizing] = useState(false); const [isRecategorizing, setIsRecategorizing] = useState(false);
@@ -68,7 +69,7 @@ export default function CategoriesPage() {
}; };
const parents = metadata.categories.filter( const parents = metadata.categories.filter(
(c: Category) => c.parentId === null, (c: Category) => c.parentId === null
); );
const children: Record<string, Category[]> = {}; const children: Record<string, Category[]> = {};
const orphans: Category[] = []; const orphans: Category[] = [];
@@ -77,7 +78,7 @@ export default function CategoriesPage() {
.filter((c: Category) => c.parentId !== null) .filter((c: Category) => c.parentId !== null)
.forEach((child: Category) => { .forEach((child: Category) => {
const parentExists = parents.some( const parentExists = parents.some(
(p: Category) => p.id === child.parentId, (p: Category) => p.id === child.parentId
); );
if (parentExists) { if (parentExists) {
if (!children[child.parentId!]) { if (!children[child.parentId!]) {
@@ -105,8 +106,7 @@ export default function CategoriesPage() {
}, [parentCategories.length]); }, [parentCategories.length]);
const refresh = useCallback(() => { const refresh = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] }); invalidateAllCategoryQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["category-stats"] });
}, [queryClient]); }, [queryClient]);
const getCategoryStats = useCallback( const getCategoryStats = useCallback(
@@ -136,7 +136,7 @@ export default function CategoriesPage() {
return { total, count }; return { total, count };
}, },
[categoryStats, childrenByParent], [categoryStats, childrenByParent]
); );
if (isLoadingMetadata || !metadata || isLoadingStats || !categoryStats) { if (isLoadingMetadata || !metadata || isLoadingStats || !categoryStats) {
@@ -248,7 +248,7 @@ export default function CategoriesPage() {
try { try {
// Fetch uncategorized transactions // Fetch uncategorized transactions
const uncategorizedResponse = await fetch( const uncategorizedResponse = await fetch(
"/api/banking/transactions?limit=1000&offset=0&includeUncategorized=true", "/api/banking/transactions?limit=1000&offset=0&includeUncategorized=true"
); );
if (!uncategorizedResponse.ok) { if (!uncategorizedResponse.ok) {
throw new Error("Failed to fetch uncategorized transactions"); throw new Error("Failed to fetch uncategorized transactions");
@@ -261,11 +261,11 @@ export default function CategoriesPage() {
for (const transaction of uncategorized) { for (const transaction of uncategorized) {
const categoryId = autoCategorize( const categoryId = autoCategorize(
transaction.description + " " + (transaction.memo || ""), transaction.description + " " + (transaction.memo || ""),
metadata.categories, metadata.categories
); );
if (categoryId) { if (categoryId) {
const category = metadata.categories.find( const category = metadata.categories.find(
(c: Category) => c.id === categoryId, (c: Category) => c.id === categoryId
); );
if (category) { if (category) {
results.push({ transaction, category }); results.push({ transaction, category });
@@ -299,9 +299,9 @@ export default function CategoriesPage() {
return children.some( return children.some(
(c) => (c) =>
c.name.toLowerCase().includes(query) || c.name.toLowerCase().includes(query) ||
c.keywords.some((k) => k.toLowerCase().includes(query)), c.keywords.some((k) => k.toLowerCase().includes(query))
); );
}, }
); );
return ( return (
@@ -346,9 +346,9 @@ export default function CategoriesPage() {
(c) => (c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) || c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.keywords.some((k) => c.keywords.some((k) =>
k.toLowerCase().includes(searchQuery.toLowerCase()), k.toLowerCase().includes(searchQuery.toLowerCase())
) || ) ||
parent.name.toLowerCase().includes(searchQuery.toLowerCase()), parent.name.toLowerCase().includes(searchQuery.toLowerCase())
) )
: allChildren; : allChildren;
const stats = getCategoryStats(parent.id, true); const stats = getCategoryStats(parent.id, true);
@@ -435,7 +435,7 @@ export default function CategoriesPage() {
</p> </p>
<p className="text-xs text-muted-foreground truncate"> <p className="text-xs text-muted-foreground truncate">
{new Date(result.transaction.date).toLocaleDateString( {new Date(result.transaction.date).toLocaleDateString(
"fr-FR", "fr-FR"
)} )}
{" • "} {" • "}
{new Intl.NumberFormat("fr-FR", { {new Intl.NumberFormat("fr-FR", {

View File

@@ -18,6 +18,10 @@ import {
suggestKeyword, suggestKeyword,
} from "@/components/rules/constants"; } from "@/components/rules/constants";
import type { Transaction } from "@/lib/types"; import type { Transaction } from "@/lib/types";
import {
invalidateAllTransactionQueries,
invalidateAllCategoryQueries,
} from "@/lib/cache-utils";
interface TransactionGroup { interface TransactionGroup {
key: string; key: string;
@@ -42,21 +46,19 @@ export default function RulesPage() {
offset: 0, offset: 0,
includeUncategorized: true, includeUncategorized: true,
}, },
!!metadata, !!metadata
); );
const refresh = useCallback(() => { const refresh = useCallback(() => {
invalidateTransactions(); invalidateAllTransactionQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] }); invalidateAllCategoryQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["category-stats"] }); }, [queryClient]);
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
}, [invalidateTransactions, queryClient]);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState<"count" | "amount" | "name">("count"); const [sortBy, setSortBy] = useState<"count" | "amount" | "name">("count");
const [filterMinCount, setFilterMinCount] = useState(2); const [filterMinCount, setFilterMinCount] = useState(2);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set()); const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [selectedGroup, setSelectedGroup] = useState<TransactionGroup | null>( const [selectedGroup, setSelectedGroup] = useState<TransactionGroup | null>(
null, null
); );
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [isAutoCategorizing, setIsAutoCategorizing] = useState(false); const [isAutoCategorizing, setIsAutoCategorizing] = useState(false);
@@ -87,7 +89,7 @@ export default function RulesPage() {
totalAmount: transactions.reduce((sum, t) => sum + t.amount, 0), totalAmount: transactions.reduce((sum, t) => sum + t.amount, 0),
suggestedKeyword: suggestKeyword(descriptions), suggestedKeyword: suggestKeyword(descriptions),
}; };
}, }
); );
// Filter by search query // Filter by search query
@@ -98,7 +100,7 @@ export default function RulesPage() {
(g) => (g) =>
g.displayName.toLowerCase().includes(query) || g.displayName.toLowerCase().includes(query) ||
g.key.includes(query) || g.key.includes(query) ||
g.suggestedKeyword.toLowerCase().includes(query), g.suggestedKeyword.toLowerCase().includes(query)
); );
} }
@@ -167,7 +169,7 @@ export default function RulesPage() {
// 1. Add keyword to category // 1. Add keyword to category
const category = metadata.categories.find( const category = metadata.categories.find(
(c: { id: string }) => c.id === ruleData.categoryId, (c: { id: string }) => c.id === ruleData.categoryId
); );
if (!category) { if (!category) {
throw new Error("Category not found"); throw new Error("Category not found");
@@ -175,7 +177,7 @@ export default function RulesPage() {
// Check if keyword already exists // Check if keyword already exists
const keywordExists = category.keywords.some( const keywordExists = category.keywords.some(
(k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase(), (k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase()
); );
if (!keywordExists) { if (!keywordExists) {
@@ -193,14 +195,16 @@ export default function RulesPage() {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, categoryId: ruleData.categoryId }), body: JSON.stringify({ id, categoryId: ruleData.categoryId }),
}), })
), )
); );
} }
refresh(); // Invalider toutes les queries liées
invalidateAllTransactionQueries(queryClient);
invalidateAllCategoryQueries(queryClient);
}, },
[metadata, refresh], [metadata, queryClient]
); );
const handleAutoCategorize = useCallback(async () => { const handleAutoCategorize = useCallback(async () => {
@@ -214,7 +218,7 @@ export default function RulesPage() {
for (const transaction of uncategorized) { for (const transaction of uncategorized) {
const categoryId = autoCategorize( const categoryId = autoCategorize(
transaction.description + " " + (transaction.memo || ""), transaction.description + " " + (transaction.memo || ""),
metadata.categories, metadata.categories
); );
if (categoryId) { if (categoryId) {
await fetch("/api/banking/transactions", { await fetch("/api/banking/transactions", {
@@ -226,9 +230,11 @@ export default function RulesPage() {
} }
} }
refresh(); // Invalider toutes les queries liées
invalidateAllTransactionQueries(queryClient);
invalidateAllCategoryQueries(queryClient);
alert( alert(
`${categorizedCount} transaction(s) catégorisée(s) automatiquement`, `${categorizedCount} transaction(s) catégorisée(s) automatiquement`
); );
} catch (error) { } catch (error) {
console.error("Error auto-categorizing:", error); console.error("Error auto-categorizing:", error);
@@ -236,7 +242,7 @@ export default function RulesPage() {
} finally { } finally {
setIsAutoCategorizing(false); setIsAutoCategorizing(false);
} }
}, [metadata, transactionsData, refresh]); }, [metadata, transactionsData, queryClient]);
const handleCategorizeGroup = useCallback( const handleCategorizeGroup = useCallback(
async (group: TransactionGroup, categoryId: string | null) => { async (group: TransactionGroup, categoryId: string | null) => {
@@ -247,16 +253,18 @@ export default function RulesPage() {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...t, categoryId }), body: JSON.stringify({ ...t, categoryId }),
}), })
), )
); );
refresh(); // Invalider toutes les queries liées
invalidateAllTransactionQueries(queryClient);
invalidateAllCategoryQueries(queryClient);
} catch (error) { } catch (error) {
console.error("Error categorizing group:", error); console.error("Error categorizing group:", error);
alert("Erreur lors de la catégorisation"); alert("Erreur lors de la catégorisation");
} }
}, },
[refresh], [queryClient]
); );
if ( if (

View File

@@ -74,7 +74,6 @@ export default function TransactionsPage() {
} = useTransactionMutations({ } = useTransactionMutations({
transactionParams, transactionParams,
transactionsData, transactionsData,
invalidateTransactions,
}); });
// Transaction rules // Transaction rules

View File

@@ -5,17 +5,16 @@ import { useQueryClient } from "@tanstack/react-query";
import type { Transaction } from "@/lib/types"; import type { Transaction } from "@/lib/types";
import { getTransactionsQueryKey } from "@/lib/hooks"; import { getTransactionsQueryKey } from "@/lib/hooks";
import type { TransactionsPaginatedParams } from "@/services/banking.service"; import type { TransactionsPaginatedParams } from "@/services/banking.service";
import { invalidateAllTransactionQueries } from "@/lib/cache-utils";
interface UseTransactionMutationsProps { interface UseTransactionMutationsProps {
transactionParams: TransactionsPaginatedParams; transactionParams: TransactionsPaginatedParams;
transactionsData: { transactions: Transaction[]; total: number } | undefined; transactionsData: { transactions: Transaction[]; total: number } | undefined;
invalidateTransactions: () => void;
} }
export function useTransactionMutations({ export function useTransactionMutations({
transactionParams, transactionParams,
transactionsData, transactionsData,
invalidateTransactions,
}: UseTransactionMutationsProps) { }: UseTransactionMutationsProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [updatingTransactionIds, setUpdatingTransactionIds] = useState< const [updatingTransactionIds, setUpdatingTransactionIds] = useState<
@@ -64,21 +63,19 @@ export function useTransactionMutations({
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
// TOUJOURS revalider après succès pour garantir la cohérence
invalidateAllTransactionQueries(queryClient);
} catch (error) { } catch (error) {
console.error("Failed to update transaction:", error); console.error("Failed to update transaction:", error);
// Rollback on error // Rollback on error
if (previousData) { if (previousData) {
queryClient.setQueryData(queryKey, previousData); queryClient.setQueryData(queryKey, previousData);
} }
invalidateTransactions(); invalidateAllTransactionQueries(queryClient);
} }
}, },
[ [transactionsData, transactionParams, queryClient]
transactionsData,
transactionParams,
queryClient,
invalidateTransactions,
]
); );
const markReconciled = useCallback( const markReconciled = useCallback(
@@ -120,21 +117,19 @@ export function useTransactionMutations({
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
// TOUJOURS revalider après succès pour garantir la cohérence
invalidateAllTransactionQueries(queryClient);
} catch (error) { } catch (error) {
console.error("Failed to update transaction:", error); console.error("Failed to update transaction:", error);
// Rollback on error // Rollback on error
if (previousData) { if (previousData) {
queryClient.setQueryData(queryKey, previousData); queryClient.setQueryData(queryKey, previousData);
} }
invalidateTransactions(); invalidateAllTransactionQueries(queryClient);
} }
}, },
[ [transactionsData, transactionParams, queryClient]
transactionsData,
transactionParams,
queryClient,
invalidateTransactions,
]
); );
const setCategory = useCallback( const setCategory = useCallback(
@@ -148,6 +143,22 @@ export function useTransactionMutations({
setUpdatingTransactionIds((prev) => new Set(prev).add(transactionId)); setUpdatingTransactionIds((prev) => new Set(prev).add(transactionId));
// Optimistic cache update
const queryKey = getTransactionsQueryKey(transactionParams);
const previousData =
queryClient.getQueryData<typeof transactionsData>(queryKey);
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
t.id === transactionId ? { ...t, categoryId } : t
),
};
});
try { try {
const response = await fetch("/api/banking/transactions", { const response = await fetch("/api/banking/transactions", {
method: "PUT", method: "PUT",
@@ -159,21 +170,15 @@ export function useTransactionMutations({
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
// Optimistic cache update // TOUJOURS revalider après succès pour garantir la cohérence
const queryKey = getTransactionsQueryKey(transactionParams); invalidateAllTransactionQueries(queryClient);
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
t.id === transactionId ? { ...t, categoryId } : t
),
};
});
} catch (error) { } catch (error) {
console.error("Failed to update transaction:", error); console.error("Failed to update transaction:", error);
invalidateTransactions(); // Rollback on error
if (previousData) {
queryClient.setQueryData(queryKey, previousData);
}
invalidateAllTransactionQueries(queryClient);
} finally { } finally {
setUpdatingTransactionIds((prev) => { setUpdatingTransactionIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -182,7 +187,7 @@ export function useTransactionMutations({
}); });
} }
}, },
[transactionsData, transactionParams, queryClient, invalidateTransactions] [transactionsData, transactionParams, queryClient]
); );
const deleteTransaction = useCallback( const deleteTransaction = useCallback(
@@ -217,22 +222,23 @@ export function useTransactionMutations({
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
throw new Error( throw new Error(
errorData.error || `Failed to delete transaction: ${response.status}` errorData.error ||
`Failed to delete transaction: ${response.status}`
); );
} }
// Invalidate related queries // TOUJOURS revalider après succès pour garantir la cohérence
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] }); invalidateAllTransactionQueries(queryClient);
} catch (error) { } catch (error) {
console.error("Failed to delete transaction:", error); console.error("Failed to delete transaction:", error);
// Rollback on error // Rollback on error
if (previousData) { if (previousData) {
queryClient.setQueryData(queryKey, previousData); queryClient.setQueryData(queryKey, previousData);
} }
invalidateTransactions(); invalidateAllTransactionQueries(queryClient);
} }
}, },
[transactionsData, transactionParams, queryClient, invalidateTransactions] [transactionsData, transactionParams, queryClient]
); );
const bulkReconcile = useCallback( const bulkReconcile = useCallback(
@@ -272,21 +278,19 @@ export function useTransactionMutations({
}) })
) )
); );
// TOUJOURS revalider après succès pour garantir la cohérence
invalidateAllTransactionQueries(queryClient);
} catch (error) { } catch (error) {
console.error("Failed to update transactions:", error); console.error("Failed to update transactions:", error);
// Rollback on error // Rollback on error
if (previousData) { if (previousData) {
queryClient.setQueryData(queryKey, previousData); queryClient.setQueryData(queryKey, previousData);
} }
invalidateTransactions(); invalidateAllTransactionQueries(queryClient);
} }
}, },
[ [transactionsData, transactionParams, queryClient]
transactionsData,
transactionParams,
queryClient,
invalidateTransactions,
]
); );
const bulkSetCategory = useCallback( const bulkSetCategory = useCallback(
@@ -304,6 +308,21 @@ export function useTransactionMutations({
return next; return next;
}); });
// Optimistic cache update
const queryKey = getTransactionsQueryKey(transactionParams);
const previousData =
queryClient.getQueryData<typeof transactionsData>(queryKey);
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
transactionIds.includes(t.id) ? { ...t, categoryId } : t
),
};
});
try { try {
await Promise.all( await Promise.all(
transactionsToUpdate.map((t) => transactionsToUpdate.map((t) =>
@@ -315,20 +334,15 @@ export function useTransactionMutations({
) )
); );
// Optimistic cache update // TOUJOURS revalider après succès pour garantir la cohérence
const queryKey = getTransactionsQueryKey(transactionParams); invalidateAllTransactionQueries(queryClient);
queryClient.setQueryData<typeof transactionsData>(queryKey, (oldData) => {
if (!oldData) return oldData;
return {
...oldData,
transactions: oldData.transactions.map((t) =>
transactionIds.includes(t.id) ? { ...t, categoryId } : t
),
};
});
} catch (error) { } catch (error) {
console.error("Failed to update transactions:", error); console.error("Failed to update transactions:", error);
invalidateTransactions(); // Rollback on error
if (previousData) {
queryClient.setQueryData(queryKey, previousData);
}
invalidateAllTransactionQueries(queryClient);
} finally { } finally {
setUpdatingTransactionIds((prev) => { setUpdatingTransactionIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -337,7 +351,7 @@ export function useTransactionMutations({
}); });
} }
}, },
[transactionsData, transactionParams, queryClient, invalidateTransactions] [transactionsData, transactionParams, queryClient]
); );
return { return {
@@ -350,4 +364,3 @@ export function useTransactionMutations({
updatingTransactionIds, updatingTransactionIds,
}; };
} }

View File

@@ -8,6 +8,10 @@ import {
normalizeDescription, normalizeDescription,
suggestKeyword, suggestKeyword,
} from "@/components/rules/constants"; } from "@/components/rules/constants";
import {
invalidateAllTransactionQueries,
invalidateAllCategoryQueries,
} from "@/lib/cache-utils";
interface UseTransactionRulesProps { interface UseTransactionRulesProps {
transactionsData: { transactions: Transaction[] } | undefined; transactionsData: { transactions: Transaction[] } | undefined;
@@ -94,9 +98,9 @@ export function useTransactionRules({
); );
} }
// Invalidate queries // Invalider toutes les queries liées
queryClient.invalidateQueries({ queryKey: ["transactions"] }); invalidateAllTransactionQueries(queryClient);
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] }); invalidateAllCategoryQueries(queryClient);
setRuleDialogOpen(false); setRuleDialogOpen(false);
}, },
[metadata, queryClient] [metadata, queryClient]
@@ -110,4 +114,3 @@ export function useTransactionRules({
handleSaveRule, handleSaveRule,
}; };
} }

36
lib/cache-utils.ts Normal file
View File

@@ -0,0 +1,36 @@
import { QueryClient } from "@tanstack/react-query";
/**
* Invalide toutes les queries liées aux transactions
* Utilisé après toute mutation de transaction (création, modification, suppression)
*/
export function invalidateAllTransactionQueries(queryClient: QueryClient) {
// Invalider toutes les queries de transactions (tous les paramètres)
queryClient.invalidateQueries({ queryKey: ["transactions"] });
// Invalider les queries liées qui peuvent être affectées
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
queryClient.invalidateQueries({ queryKey: ["category-stats"] });
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
queryClient.invalidateQueries({ queryKey: ["duplicate-ids"] });
}
/**
* Invalide toutes les queries liées aux catégories
* Utilisé après toute mutation de catégorie (création, modification, suppression)
*/
export function invalidateAllCategoryQueries(queryClient: QueryClient) {
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
queryClient.invalidateQueries({ queryKey: ["category-stats"] });
queryClient.invalidateQueries({ queryKey: ["transactions"] });
}
/**
* Invalide toutes les queries liées aux comptes
* Utilisé après toute mutation de compte ou dossier (création, modification, suppression)
*/
export function invalidateAllAccountQueries(queryClient: QueryClient) {
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
queryClient.invalidateQueries({ queryKey: ["transactions"] });
}