feat: integrate React Query for improved data fetching and state management across banking and transactions components
This commit is contained in:
@@ -19,7 +19,8 @@ import {
|
|||||||
AccountBulkActions,
|
AccountBulkActions,
|
||||||
} from "@/components/accounts";
|
} from "@/components/accounts";
|
||||||
import { FolderEditDialog } from "@/components/folders";
|
import { FolderEditDialog } from "@/components/folders";
|
||||||
import { useBankingData } from "@/lib/hooks";
|
import { useBankingMetadata, useAccountsWithStats } from "@/lib/hooks";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
updateAccount,
|
updateAccount,
|
||||||
deleteAccount,
|
deleteAccount,
|
||||||
@@ -59,7 +60,19 @@ function FolderDropZone({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AccountsPage() {
|
export default function AccountsPage() {
|
||||||
const { data, isLoading, refresh, refreshSilent, update } = useBankingData();
|
const queryClient = useQueryClient();
|
||||||
|
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
|
||||||
|
const {
|
||||||
|
data: accountsWithStats,
|
||||||
|
isLoading: isLoadingAccounts,
|
||||||
|
} = useAccountsWithStats();
|
||||||
|
|
||||||
|
// refresh function is not used directly, invalidations are done inline
|
||||||
|
|
||||||
|
const refreshSilent = async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
|
||||||
|
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>>(
|
||||||
@@ -92,10 +105,13 @@ export default function AccountsPage() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading || !data) {
|
if (isLoadingMetadata || !metadata || isLoadingAccounts || !accountsWithStats) {
|
||||||
return <LoadingState />;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert accountsWithStats to regular accounts for compatibility
|
||||||
|
const accounts = accountsWithStats.map(({ transactionCount: _transactionCount, ...account }) => account);
|
||||||
|
|
||||||
const formatCurrency = (amount: number) => {
|
const formatCurrency = (amount: number) => {
|
||||||
return new Intl.NumberFormat("fr-FR", {
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
@@ -128,7 +144,8 @@ export default function AccountsPage() {
|
|||||||
initialBalance: formData.initialBalance,
|
initialBalance: formData.initialBalance,
|
||||||
};
|
};
|
||||||
await updateAccount(updatedAccount);
|
await updateAccount(updatedAccount);
|
||||||
refresh();
|
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
||||||
setIsDialogOpen(false);
|
setIsDialogOpen(false);
|
||||||
setEditingAccount(null);
|
setEditingAccount(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -142,7 +159,8 @@ export default function AccountsPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteAccount(accountId);
|
await deleteAccount(accountId);
|
||||||
refresh();
|
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
|
||||||
|
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");
|
||||||
@@ -170,7 +188,8 @@ export default function AccountsPage() {
|
|||||||
throw new Error("Failed to delete accounts");
|
throw new Error("Failed to delete accounts");
|
||||||
}
|
}
|
||||||
setSelectedAccounts(new Set());
|
setSelectedAccounts(new Set());
|
||||||
refresh();
|
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
|
||||||
|
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");
|
||||||
@@ -226,7 +245,8 @@ export default function AccountsPage() {
|
|||||||
icon: "folder",
|
icon: "folder",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
refresh();
|
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
|
||||||
|
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);
|
||||||
@@ -244,7 +264,8 @@ export default function AccountsPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteFolder(folderId);
|
await deleteFolder(folderId);
|
||||||
refresh();
|
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
|
||||||
|
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");
|
||||||
@@ -260,7 +281,7 @@ export default function AccountsPage() {
|
|||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
|
|
||||||
if (!over || active.id === over.id || !data) return;
|
if (!over || active.id === over.id || !accountsWithStats) return;
|
||||||
|
|
||||||
const activeId = active.id as string;
|
const activeId = active.id as string;
|
||||||
const overId = over.id as string;
|
const overId = over.id as string;
|
||||||
@@ -276,7 +297,7 @@ export default function AccountsPage() {
|
|||||||
} else if (overId.startsWith("account-")) {
|
} else if (overId.startsWith("account-")) {
|
||||||
// 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 = data.accounts.find(
|
const targetAccount = accountsWithStats.find(
|
||||||
(a) => a.id === targetAccountId,
|
(a) => a.id === targetAccountId,
|
||||||
);
|
);
|
||||||
if (targetAccount) {
|
if (targetAccount) {
|
||||||
@@ -285,34 +306,33 @@ export default function AccountsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (targetFolderId !== undefined) {
|
if (targetFolderId !== undefined) {
|
||||||
const account = data.accounts.find((a) => a.id === accountId);
|
const account = accountsWithStats.find((a) => a.id === accountId);
|
||||||
if (!account) return;
|
if (!account) return;
|
||||||
|
|
||||||
// Sauvegarder l'état précédent pour rollback en cas d'erreur
|
|
||||||
const previousData = data;
|
|
||||||
|
|
||||||
// Optimistic update : mettre à jour immédiatement l'interface
|
// Optimistic update : mettre à jour immédiatement l'interface
|
||||||
const updatedAccount = {
|
const updatedAccount = {
|
||||||
...account,
|
...account,
|
||||||
folderId: targetFolderId,
|
folderId: targetFolderId,
|
||||||
};
|
};
|
||||||
const updatedAccounts = data.accounts.map((a) =>
|
// Update cache directly
|
||||||
a.id === accountId ? updatedAccount : a,
|
queryClient.setQueryData(
|
||||||
|
["accounts-with-stats"],
|
||||||
|
(old: Array<Account & { transactionCount: number }> | undefined) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return old.map((a) => (a.id === accountId ? updatedAccount : a));
|
||||||
|
},
|
||||||
);
|
);
|
||||||
update({
|
|
||||||
...data,
|
|
||||||
accounts: updatedAccounts,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Faire la requête en arrière-plan
|
// Faire la requête en arrière-plan
|
||||||
try {
|
try {
|
||||||
await updateAccount(updatedAccount);
|
await updateAccount(updatedAccount);
|
||||||
// Refresh silencieux pour synchroniser avec le serveur sans loader
|
// Refresh silencieux pour synchroniser avec le serveur sans loader
|
||||||
refreshSilent();
|
await refreshSilent();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error moving account:", error);
|
console.error("Error moving account:", error);
|
||||||
// Rollback en cas d'erreur
|
// Rollback en cas d'erreur - refresh data
|
||||||
update(previousData);
|
queryClient.invalidateQueries({ queryKey: ["accounts-with-stats"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
||||||
alert("Erreur lors du déplacement du compte");
|
alert("Erreur lors du déplacement du compte");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -320,16 +340,17 @@ export default function AccountsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getTransactionCount = (accountId: string) => {
|
const getTransactionCount = (accountId: string) => {
|
||||||
return data.transactions.filter((t) => t.accountId === accountId).length;
|
const account = accountsWithStats.find((a) => a.id === accountId);
|
||||||
|
return account?.transactionCount || 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalBalance = data.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
|
||||||
const accountsByFolder = data.accounts.reduce(
|
const accountsByFolder = accounts.reduce(
|
||||||
(acc, account) => {
|
(acc, account) => {
|
||||||
const folderId = account.folderId || "no-folder";
|
const folderId = account.folderId || "no-folder";
|
||||||
if (!acc[folderId]) {
|
if (!acc[folderId]) {
|
||||||
@@ -342,9 +363,9 @@ export default function AccountsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Obtenir les folders racine (sans parent) et les trier par nom
|
// Obtenir les folders racine (sans parent) et les trier par nom
|
||||||
const rootFolders = data.folders
|
const rootFolders = metadata.folders
|
||||||
.filter((f) => !f.parentId)
|
.filter((f: FolderType) => !f.parentId)
|
||||||
.sort((a, b) => a.name.localeCompare(b.name));
|
.sort((a: FolderType, b: FolderType) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
@@ -386,7 +407,7 @@ export default function AccountsPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{data.accounts.length === 0 ? (
|
{accounts.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
<Building2 className="w-16 h-16 text-muted-foreground mb-4" />
|
<Building2 className="w-16 h-16 text-muted-foreground mb-4" />
|
||||||
@@ -441,8 +462,8 @@ export default function AccountsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{accountsByFolder["no-folder"].map((account) => {
|
{accountsByFolder["no-folder"].map((account) => {
|
||||||
const folder = data.folders.find(
|
const folder = metadata.folders.find(
|
||||||
(f) => f.id === account.folderId,
|
(f: FolderType) => f.id === account.folderId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -466,7 +487,7 @@ export default function AccountsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Afficher les comptes groupés par folder */}
|
{/* Afficher les comptes groupés par folder */}
|
||||||
{rootFolders.map((folder) => {
|
{rootFolders.map((folder: FolderType) => {
|
||||||
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),
|
||||||
@@ -521,8 +542,8 @@ export default function AccountsPage() {
|
|||||||
{folderAccounts.length > 0 ? (
|
{folderAccounts.length > 0 ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{folderAccounts.map((account) => {
|
{folderAccounts.map((account) => {
|
||||||
const accountFolder = data.folders.find(
|
const accountFolder = metadata.folders.find(
|
||||||
(f) => f.id === account.folderId,
|
(f: FolderType) => f.id === account.folderId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -565,7 +586,7 @@ export default function AccountsPage() {
|
|||||||
{activeId.startsWith("account-") ? (
|
{activeId.startsWith("account-") ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
{data.accounts.find(
|
{accounts.find(
|
||||||
(a) => a.id === activeId.replace("account-", ""),
|
(a) => a.id === activeId.replace("account-", ""),
|
||||||
)?.name || ""}
|
)?.name || ""}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -583,7 +604,7 @@ export default function AccountsPage() {
|
|||||||
onOpenChange={setIsDialogOpen}
|
onOpenChange={setIsDialogOpen}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
onFormDataChange={setFormData}
|
onFormDataChange={setFormData}
|
||||||
folders={data.folders}
|
folders={metadata.folders}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -593,7 +614,7 @@ export default function AccountsPage() {
|
|||||||
editingFolder={editingFolder}
|
editingFolder={editingFolder}
|
||||||
formData={folderFormData}
|
formData={folderFormData}
|
||||||
onFormDataChange={setFolderFormData}
|
onFormDataChange={setFolderFormData}
|
||||||
folders={data.folders}
|
folders={metadata.folders}
|
||||||
onSave={handleSaveFolder}
|
onSave={handleSaveFolder}
|
||||||
/>
|
/>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
@@ -1,8 +1,39 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { accountService } from "@/services/account.service";
|
import { accountService } from "@/services/account.service";
|
||||||
|
import { bankingService } from "@/services/banking.service";
|
||||||
import { requireAuth } from "@/lib/auth-utils";
|
import { requireAuth } from "@/lib/auth-utils";
|
||||||
import type { Account } from "@/lib/types";
|
import type { Account } from "@/lib/types";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const authError = await requireAuth();
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const withStats = searchParams.get("withStats") === "true";
|
||||||
|
|
||||||
|
if (withStats) {
|
||||||
|
const accountsWithStats = await bankingService.getAccountsWithStats();
|
||||||
|
return NextResponse.json(accountsWithStats, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching accounts:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch accounts" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const authError = await requireAuth();
|
const authError = await requireAuth();
|
||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
|
|||||||
@@ -1,8 +1,39 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { categoryService } from "@/services/category.service";
|
import { categoryService } from "@/services/category.service";
|
||||||
|
import { bankingService } from "@/services/banking.service";
|
||||||
import { requireAuth } from "@/lib/auth-utils";
|
import { requireAuth } from "@/lib/auth-utils";
|
||||||
import type { Category } from "@/lib/types";
|
import type { Category } from "@/lib/types";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const authError = await requireAuth();
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const statsOnly = searchParams.get("statsOnly") === "true";
|
||||||
|
|
||||||
|
if (statsOnly) {
|
||||||
|
const stats = await bankingService.getCategoryStats();
|
||||||
|
return NextResponse.json(stats, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid request" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching category stats:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch category stats" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const authError = await requireAuth();
|
const authError = await requireAuth();
|
||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { bankingService } from "@/services/banking.service";
|
import { bankingService } from "@/services/banking.service";
|
||||||
import { requireAuth } from "@/lib/auth-utils";
|
import { requireAuth } from "@/lib/auth-utils";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
const authError = await requireAuth();
|
const authError = await requireAuth();
|
||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const metadataOnly = searchParams.get("metadataOnly") === "true";
|
||||||
|
|
||||||
|
if (metadataOnly) {
|
||||||
|
const metadata = await bankingService.getMetadata();
|
||||||
|
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(
|
||||||
|
|||||||
@@ -1,8 +1,73 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { transactionService } from "@/services/transaction.service";
|
import { transactionService } from "@/services/transaction.service";
|
||||||
|
import { bankingService } from "@/services/banking.service";
|
||||||
import { requireAuth } from "@/lib/auth-utils";
|
import { requireAuth } from "@/lib/auth-utils";
|
||||||
import type { Transaction } from "@/lib/types";
|
import type { Transaction } from "@/lib/types";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const authError = await requireAuth();
|
||||||
|
if (authError) return authError;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
// Parse pagination params
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "50", 10);
|
||||||
|
const offset = parseInt(searchParams.get("offset") || "0", 10);
|
||||||
|
|
||||||
|
// Parse filter params
|
||||||
|
const startDate = searchParams.get("startDate") || undefined;
|
||||||
|
const endDate = searchParams.get("endDate") || undefined;
|
||||||
|
const accountIds = searchParams.get("accountIds")
|
||||||
|
? searchParams.get("accountIds")!.split(",")
|
||||||
|
: undefined;
|
||||||
|
const categoryIds = searchParams.get("categoryIds")
|
||||||
|
? searchParams.get("categoryIds")!.split(",")
|
||||||
|
: undefined;
|
||||||
|
const includeUncategorized =
|
||||||
|
searchParams.get("includeUncategorized") === "true";
|
||||||
|
const search = searchParams.get("search") || undefined;
|
||||||
|
const isReconciledParam = searchParams.get("isReconciled");
|
||||||
|
const isReconciled =
|
||||||
|
isReconciledParam === "true"
|
||||||
|
? true
|
||||||
|
: isReconciledParam === "false"
|
||||||
|
? false
|
||||||
|
: "all";
|
||||||
|
const sortField =
|
||||||
|
(searchParams.get("sortField") as "date" | "amount" | "description") ||
|
||||||
|
"date";
|
||||||
|
const sortOrder =
|
||||||
|
(searchParams.get("sortOrder") as "asc" | "desc") || "desc";
|
||||||
|
|
||||||
|
const result = await bankingService.getTransactionsPaginated({
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
accountIds,
|
||||||
|
categoryIds,
|
||||||
|
includeUncategorized,
|
||||||
|
search,
|
||||||
|
isReconciled,
|
||||||
|
sortField,
|
||||||
|
sortOrder,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(result, {
|
||||||
|
headers: {
|
||||||
|
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching transactions:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch transactions" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
const authError = await requireAuth();
|
const authError = await requireAuth();
|
||||||
if (authError) return authError;
|
if (authError) return authError;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useEffect, useCallback } from "react";
|
||||||
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
|
import { PageLayout, LoadingState, PageHeader } from "@/components/layout";
|
||||||
import {
|
import {
|
||||||
CategoryCard,
|
CategoryCard,
|
||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
ParentCategoryRow,
|
ParentCategoryRow,
|
||||||
CategorySearchBar,
|
CategorySearchBar,
|
||||||
} from "@/components/categories";
|
} from "@/components/categories";
|
||||||
import { useBankingData } from "@/lib/hooks";
|
import { useBankingMetadata, useCategoryStats } from "@/lib/hooks";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -34,11 +35,13 @@ interface RecategorizationResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CategoriesPage() {
|
export default function CategoriesPage() {
|
||||||
const { data, isLoading, refresh } = useBankingData();
|
const queryClient = useQueryClient();
|
||||||
|
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
|
||||||
|
const { data: categoryStats, isLoading: isLoadingStats } = useCategoryStats();
|
||||||
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: "",
|
||||||
@@ -49,7 +52,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);
|
||||||
@@ -57,21 +60,25 @@ export default function CategoriesPage() {
|
|||||||
// Organiser les catégories par parent
|
// Organiser les catégories par parent
|
||||||
const { parentCategories, childrenByParent, orphanCategories } =
|
const { parentCategories, childrenByParent, orphanCategories } =
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
if (!data?.categories)
|
if (!metadata?.categories)
|
||||||
return {
|
return {
|
||||||
parentCategories: [],
|
parentCategories: [],
|
||||||
childrenByParent: {},
|
childrenByParent: {},
|
||||||
orphanCategories: [],
|
orphanCategories: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const parents = data.categories.filter((c) => c.parentId === null);
|
const parents = metadata.categories.filter(
|
||||||
|
(c: Category) => c.parentId === null
|
||||||
|
);
|
||||||
const children: Record<string, Category[]> = {};
|
const children: Record<string, Category[]> = {};
|
||||||
const orphans: Category[] = [];
|
const orphans: Category[] = [];
|
||||||
|
|
||||||
data.categories
|
metadata.categories
|
||||||
.filter((c) => c.parentId !== null)
|
.filter((c: Category) => c.parentId !== null)
|
||||||
.forEach((child) => {
|
.forEach((child: Category) => {
|
||||||
const parentExists = parents.some((p) => p.id === child.parentId);
|
const parentExists = parents.some(
|
||||||
|
(p: Category) => p.id === child.parentId
|
||||||
|
);
|
||||||
if (parentExists) {
|
if (parentExists) {
|
||||||
if (!children[child.parentId!]) {
|
if (!children[child.parentId!]) {
|
||||||
children[child.parentId!] = [];
|
children[child.parentId!] = [];
|
||||||
@@ -87,16 +94,52 @@ export default function CategoriesPage() {
|
|||||||
childrenByParent: children,
|
childrenByParent: children,
|
||||||
orphanCategories: orphans,
|
orphanCategories: orphans,
|
||||||
};
|
};
|
||||||
}, [data?.categories]);
|
}, [metadata?.categories]);
|
||||||
|
|
||||||
// Initialiser tous les parents comme ouverts
|
// Initialiser tous les parents comme ouverts
|
||||||
useState(() => {
|
useEffect(() => {
|
||||||
if (parentCategories.length > 0 && expandedParents.size === 0) {
|
if (parentCategories.length > 0 && expandedParents.size === 0) {
|
||||||
setExpandedParents(new Set(parentCategories.map((p) => p.id)));
|
setExpandedParents(new Set(parentCategories.map((p: Category) => p.id)));
|
||||||
}
|
}
|
||||||
});
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [parentCategories.length]);
|
||||||
|
|
||||||
if (isLoading || !data) {
|
const refresh = useCallback(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["category-stats"] });
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
const getCategoryStats = useCallback(
|
||||||
|
(categoryId: string, includeChildren = false) => {
|
||||||
|
if (!categoryStats) return { total: 0, count: 0 };
|
||||||
|
|
||||||
|
let categoryIds = [categoryId];
|
||||||
|
|
||||||
|
if (includeChildren && childrenByParent[categoryId]) {
|
||||||
|
categoryIds = [
|
||||||
|
...categoryIds,
|
||||||
|
...childrenByParent[categoryId].map((c) => c.id),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sum stats from all category IDs
|
||||||
|
let total = 0;
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
categoryIds.forEach((id) => {
|
||||||
|
const stats = categoryStats[id];
|
||||||
|
if (stats) {
|
||||||
|
total += stats.total;
|
||||||
|
count += stats.count;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { total, count };
|
||||||
|
},
|
||||||
|
[categoryStats, childrenByParent]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoadingMetadata || !metadata || isLoadingStats || !categoryStats) {
|
||||||
return <LoadingState />;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,27 +150,6 @@ export default function CategoriesPage() {
|
|||||||
}).format(amount);
|
}).format(amount);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getCategoryStats = (categoryId: string, includeChildren = false) => {
|
|
||||||
let categoryIds = [categoryId];
|
|
||||||
|
|
||||||
if (includeChildren && childrenByParent[categoryId]) {
|
|
||||||
categoryIds = [
|
|
||||||
...categoryIds,
|
|
||||||
...childrenByParent[categoryId].map((c) => c.id),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const categoryTransactions = data.transactions.filter((t) =>
|
|
||||||
categoryIds.includes(t.categoryId || ""),
|
|
||||||
);
|
|
||||||
const total = categoryTransactions.reduce(
|
|
||||||
(sum, t) => sum + Math.abs(t.amount),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const count = categoryTransactions.length;
|
|
||||||
return { total, count };
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleExpanded = (parentId: string) => {
|
const toggleExpanded = (parentId: string) => {
|
||||||
const newExpanded = new Set(expandedParents);
|
const newExpanded = new Set(expandedParents);
|
||||||
if (newExpanded.has(parentId)) {
|
if (newExpanded.has(parentId)) {
|
||||||
@@ -139,7 +161,7 @@ export default function CategoriesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const expandAll = () => {
|
const expandAll = () => {
|
||||||
setExpandedParents(new Set(parentCategories.map((p) => p.id)));
|
setExpandedParents(new Set(parentCategories.map((p: Category) => p.id)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const collapseAll = () => {
|
const collapseAll = () => {
|
||||||
@@ -224,16 +246,27 @@ export default function CategoriesPage() {
|
|||||||
const results: RecategorizationResult[] = [];
|
const results: RecategorizationResult[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Fetch uncategorized transactions
|
||||||
|
const uncategorizedResponse = await fetch(
|
||||||
|
"/api/banking/transactions?limit=1000&offset=0&includeUncategorized=true"
|
||||||
|
);
|
||||||
|
if (!uncategorizedResponse.ok) {
|
||||||
|
throw new Error("Failed to fetch uncategorized transactions");
|
||||||
|
}
|
||||||
|
const { transactions: uncategorized } =
|
||||||
|
await uncategorizedResponse.json();
|
||||||
|
|
||||||
const { updateTransaction } = await import("@/lib/store-db");
|
const { updateTransaction } = await import("@/lib/store-db");
|
||||||
const uncategorized = data.transactions.filter((t) => !t.categoryId);
|
|
||||||
|
|
||||||
for (const transaction of uncategorized) {
|
for (const transaction of uncategorized) {
|
||||||
const categoryId = autoCategorize(
|
const categoryId = autoCategorize(
|
||||||
transaction.description + " " + (transaction.memo || ""),
|
transaction.description + " " + (transaction.memo || ""),
|
||||||
data.categories,
|
metadata.categories
|
||||||
);
|
);
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
const category = data.categories.find((c) => c.id === categoryId);
|
const category = metadata.categories.find(
|
||||||
|
(c: Category) => c.id === categoryId
|
||||||
|
);
|
||||||
if (category) {
|
if (category) {
|
||||||
results.push({ transaction, category });
|
results.push({ transaction, category });
|
||||||
await updateTransaction({ ...transaction, categoryId });
|
await updateTransaction({ ...transaction, categoryId });
|
||||||
@@ -252,30 +285,30 @@ export default function CategoriesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const uncategorizedCount = data.transactions.filter(
|
const uncategorizedCount = categoryStats["uncategorized"]?.count || 0;
|
||||||
(t) => !t.categoryId,
|
|
||||||
).length;
|
|
||||||
|
|
||||||
// Filtrer les catégories selon la recherche
|
// Filtrer les catégories selon la recherche
|
||||||
const filteredParentCategories = parentCategories.filter((parent) => {
|
const filteredParentCategories = parentCategories.filter(
|
||||||
if (!searchQuery.trim()) return true;
|
(parent: Category) => {
|
||||||
const query = searchQuery.toLowerCase();
|
if (!searchQuery.trim()) return true;
|
||||||
if (parent.name.toLowerCase().includes(query)) return true;
|
const query = searchQuery.toLowerCase();
|
||||||
if (parent.keywords.some((k) => k.toLowerCase().includes(query)))
|
if (parent.name.toLowerCase().includes(query)) return true;
|
||||||
return true;
|
if (parent.keywords.some((k: string) => k.toLowerCase().includes(query)))
|
||||||
const children = childrenByParent[parent.id] || [];
|
return true;
|
||||||
return children.some(
|
const children = childrenByParent[parent.id] || [];
|
||||||
(c) =>
|
return children.some(
|
||||||
c.name.toLowerCase().includes(query) ||
|
(c) =>
|
||||||
c.keywords.some((k) => k.toLowerCase().includes(query)),
|
c.name.toLowerCase().includes(query) ||
|
||||||
);
|
c.keywords.some((k) => k.toLowerCase().includes(query))
|
||||||
});
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Catégories"
|
title="Catégories"
|
||||||
description={`${parentCategories.length} catégories principales • ${data.categories.length - parentCategories.length} sous-catégories`}
|
description={`${parentCategories.length} catégories principales • ${metadata.categories.length - parentCategories.length} sous-catégories`}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
{uncategorizedCount > 0 && (
|
{uncategorizedCount > 0 && (
|
||||||
@@ -306,16 +339,16 @@ export default function CategoriesPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{filteredParentCategories.map((parent) => {
|
{filteredParentCategories.map((parent: Category) => {
|
||||||
const allChildren = childrenByParent[parent.id] || [];
|
const allChildren = childrenByParent[parent.id] || [];
|
||||||
const children = searchQuery.trim()
|
const children = searchQuery.trim()
|
||||||
? allChildren.filter(
|
? allChildren.filter(
|
||||||
(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);
|
||||||
@@ -402,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", {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Metadata } from "next";
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { AuthSessionProvider } from "@/components/providers/session-provider";
|
import { AuthSessionProvider } from "@/components/providers/session-provider";
|
||||||
|
import { QueryProvider } from "@/components/providers/query-provider";
|
||||||
|
|
||||||
const _geist = Geist({ subsets: ["latin"] });
|
const _geist = Geist({ subsets: ["latin"] });
|
||||||
const _geistMono = Geist_Mono({ subsets: ["latin"] });
|
const _geistMono = Geist_Mono({ subsets: ["latin"] });
|
||||||
@@ -22,7 +23,9 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
<body className="font-sans antialiased">
|
<body className="font-sans antialiased">
|
||||||
<AuthSessionProvider>{children}</AuthSessionProvider>
|
<QueryProvider>
|
||||||
|
<AuthSessionProvider>{children}</AuthSessionProvider>
|
||||||
|
</QueryProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import {
|
|||||||
RuleCreateDialog,
|
RuleCreateDialog,
|
||||||
RulesSearchBar,
|
RulesSearchBar,
|
||||||
} from "@/components/rules";
|
} from "@/components/rules";
|
||||||
import { useBankingData } from "@/lib/hooks";
|
import { useBankingMetadata, useTransactions } from "@/lib/hooks";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Sparkles, RefreshCw } from "lucide-react";
|
import { Sparkles, RefreshCw } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
updateCategory,
|
updateCategory,
|
||||||
autoCategorize,
|
autoCategorize,
|
||||||
updateTransaction,
|
|
||||||
} from "@/lib/store-db";
|
} from "@/lib/store-db";
|
||||||
import {
|
import {
|
||||||
normalizeDescription,
|
normalizeDescription,
|
||||||
@@ -31,7 +31,27 @@ interface TransactionGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RulesPage() {
|
export default function RulesPage() {
|
||||||
const { data, isLoading, refresh } = useBankingData();
|
const queryClient = useQueryClient();
|
||||||
|
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
|
||||||
|
|
||||||
|
// Fetch uncategorized transactions only
|
||||||
|
const {
|
||||||
|
data: transactionsData,
|
||||||
|
isLoading: isLoadingTransactions,
|
||||||
|
invalidate: invalidateTransactions,
|
||||||
|
} = useTransactions(
|
||||||
|
{
|
||||||
|
limit: 10000, // Large limit to get all uncategorized
|
||||||
|
offset: 0,
|
||||||
|
includeUncategorized: true,
|
||||||
|
},
|
||||||
|
!!metadata,
|
||||||
|
);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
invalidateTransactions();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
||||||
|
}, [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);
|
||||||
@@ -44,9 +64,9 @@ export default function RulesPage() {
|
|||||||
|
|
||||||
// Group uncategorized transactions by normalized description
|
// Group uncategorized transactions by normalized description
|
||||||
const transactionGroups = useMemo(() => {
|
const transactionGroups = useMemo(() => {
|
||||||
if (!data?.transactions) return [];
|
if (!transactionsData?.transactions) return [];
|
||||||
|
|
||||||
const uncategorized = data.transactions.filter((t) => !t.categoryId);
|
const uncategorized = transactionsData.transactions;
|
||||||
const groups: Record<string, Transaction[]> = {};
|
const groups: Record<string, Transaction[]> = {};
|
||||||
|
|
||||||
uncategorized.forEach((transaction) => {
|
uncategorized.forEach((transaction) => {
|
||||||
@@ -101,12 +121,9 @@ export default function RulesPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
}, [data?.transactions, searchQuery, sortBy, filterMinCount]);
|
}, [transactionsData?.transactions, searchQuery, sortBy, filterMinCount]);
|
||||||
|
|
||||||
const uncategorizedCount = useMemo(() => {
|
const uncategorizedCount = transactionsData?.total || 0;
|
||||||
if (!data?.transactions) return 0;
|
|
||||||
return data.transactions.filter((t) => !t.categoryId).length;
|
|
||||||
}, [data?.transactions]);
|
|
||||||
|
|
||||||
const formatCurrency = useCallback((amount: number) => {
|
const formatCurrency = useCallback((amount: number) => {
|
||||||
return new Intl.NumberFormat("fr-FR", {
|
return new Intl.NumberFormat("fr-FR", {
|
||||||
@@ -147,11 +164,11 @@ export default function RulesPage() {
|
|||||||
applyToExisting: boolean;
|
applyToExisting: boolean;
|
||||||
transactionIds: string[];
|
transactionIds: string[];
|
||||||
}) => {
|
}) => {
|
||||||
if (!data) return;
|
if (!metadata) return;
|
||||||
|
|
||||||
// 1. Add keyword to category
|
// 1. Add keyword to category
|
||||||
const category = data.categories.find(
|
const category = metadata.categories.find(
|
||||||
(c) => 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");
|
||||||
@@ -159,7 +176,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) => k.toLowerCase() === ruleData.keyword.toLowerCase(),
|
(k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!keywordExists) {
|
if (!keywordExists) {
|
||||||
@@ -171,37 +188,41 @@ export default function RulesPage() {
|
|||||||
|
|
||||||
// 2. Apply to existing transactions if requested
|
// 2. Apply to existing transactions if requested
|
||||||
if (ruleData.applyToExisting) {
|
if (ruleData.applyToExisting) {
|
||||||
const transactions = data.transactions.filter((t) =>
|
|
||||||
ruleData.transactionIds.includes(t.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
transactions.map((t) =>
|
ruleData.transactionIds.map((id) =>
|
||||||
updateTransaction({ ...t, categoryId: ruleData.categoryId }),
|
fetch("/api/banking/transactions", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id, categoryId: ruleData.categoryId }),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
},
|
},
|
||||||
[data, refresh],
|
[metadata, refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAutoCategorize = useCallback(async () => {
|
const handleAutoCategorize = useCallback(async () => {
|
||||||
if (!data) return;
|
if (!metadata || !transactionsData) return;
|
||||||
|
|
||||||
setIsAutoCategorizing(true);
|
setIsAutoCategorizing(true);
|
||||||
try {
|
try {
|
||||||
const uncategorized = data.transactions.filter((t) => !t.categoryId);
|
const uncategorized = transactionsData.transactions;
|
||||||
let categorizedCount = 0;
|
let categorizedCount = 0;
|
||||||
|
|
||||||
for (const transaction of uncategorized) {
|
for (const transaction of uncategorized) {
|
||||||
const categoryId = autoCategorize(
|
const categoryId = autoCategorize(
|
||||||
transaction.description + " " + (transaction.memo || ""),
|
transaction.description + " " + (transaction.memo || ""),
|
||||||
data.categories,
|
metadata.categories,
|
||||||
);
|
);
|
||||||
if (categoryId) {
|
if (categoryId) {
|
||||||
await updateTransaction({ ...transaction, categoryId });
|
await fetch("/api/banking/transactions", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...transaction, categoryId }),
|
||||||
|
});
|
||||||
categorizedCount++;
|
categorizedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,16 +237,18 @@ export default function RulesPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsAutoCategorizing(false);
|
setIsAutoCategorizing(false);
|
||||||
}
|
}
|
||||||
}, [data, refresh]);
|
}, [metadata, transactionsData, refresh]);
|
||||||
|
|
||||||
const handleCategorizeGroup = useCallback(
|
const handleCategorizeGroup = useCallback(
|
||||||
async (group: TransactionGroup, categoryId: string | null) => {
|
async (group: TransactionGroup, categoryId: string | null) => {
|
||||||
if (!data) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
group.transactions.map((t) =>
|
group.transactions.map((t) =>
|
||||||
updateTransaction({ ...t, categoryId }),
|
fetch("/api/banking/transactions", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ...t, categoryId }),
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
refresh();
|
refresh();
|
||||||
@@ -234,10 +257,10 @@ export default function RulesPage() {
|
|||||||
alert("Erreur lors de la catégorisation");
|
alert("Erreur lors de la catégorisation");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[data, refresh],
|
[refresh],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading || !data) {
|
if (isLoadingMetadata || !metadata || isLoadingTransactions || !transactionsData) {
|
||||||
return <LoadingState />;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +335,7 @@ export default function RulesPage() {
|
|||||||
onCategorize={(categoryId) =>
|
onCategorize={(categoryId) =>
|
||||||
handleCategorizeGroup(group, categoryId)
|
handleCategorizeGroup(group, categoryId)
|
||||||
}
|
}
|
||||||
categories={data.categories}
|
categories={metadata.categories}
|
||||||
formatCurrency={formatCurrency}
|
formatCurrency={formatCurrency}
|
||||||
formatDate={formatDate}
|
formatDate={formatDate}
|
||||||
/>
|
/>
|
||||||
@@ -324,7 +347,7 @@ export default function RulesPage() {
|
|||||||
open={isDialogOpen}
|
open={isDialogOpen}
|
||||||
onOpenChange={setIsDialogOpen}
|
onOpenChange={setIsDialogOpen}
|
||||||
group={selectedGroup}
|
group={selectedGroup}
|
||||||
categories={data.categories}
|
categories={metadata.categories}
|
||||||
onSave={handleSaveRule}
|
onSave={handleSaveRule}
|
||||||
/>
|
/>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ export default function StatisticsPage() {
|
|||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
|
|
||||||
|
// Pre-filter transactions once
|
||||||
let transactions = data.transactions.filter((t) => {
|
let transactions = data.transactions.filter((t) => {
|
||||||
const transactionDate = new Date(t.date);
|
const transactionDate = new Date(t.date);
|
||||||
if (endDate) {
|
if (endDate) {
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import {
|
|||||||
} from "@/components/transactions";
|
} from "@/components/transactions";
|
||||||
import { RuleCreateDialog } from "@/components/rules";
|
import { RuleCreateDialog } from "@/components/rules";
|
||||||
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
|
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
|
||||||
import { useBankingData } from "@/lib/hooks";
|
import { useBankingMetadata, useTransactions } from "@/lib/hooks";
|
||||||
import { updateCategory, updateTransaction } from "@/lib/store-db";
|
import { updateCategory } from "@/lib/store-db";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Upload } from "lucide-react";
|
import { Upload } from "lucide-react";
|
||||||
import type { Transaction } from "@/lib/types";
|
import type { Transaction } from "@/lib/types";
|
||||||
|
import type { TransactionsPaginatedParams } from "@/services/banking.service";
|
||||||
import {
|
import {
|
||||||
normalizeDescription,
|
normalizeDescription,
|
||||||
suggestKeyword,
|
suggestKeyword,
|
||||||
@@ -24,16 +26,31 @@ type SortField = "date" | "amount" | "description";
|
|||||||
type SortOrder = "asc" | "desc";
|
type SortOrder = "asc" | "desc";
|
||||||
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
|
type Period = "1month" | "3months" | "6months" | "12months" | "custom" | "all";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 100;
|
||||||
|
|
||||||
export default function TransactionsPage() {
|
export default function TransactionsPage() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { data, isLoading, refresh, update } = useBankingData();
|
const queryClient = useQueryClient();
|
||||||
|
const { data: metadata, isLoading: isLoadingMetadata } = useBankingMetadata();
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState("");
|
||||||
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
|
const [selectedAccounts, setSelectedAccounts] = useState<string[]>(["all"]);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
|
||||||
|
// Debounce search query
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setDebouncedSearchQuery(searchQuery);
|
||||||
|
setPage(0); // Reset to first page when search changes
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const accountId = searchParams.get("accountId");
|
const accountId = searchParams.get("accountId");
|
||||||
if (accountId) {
|
if (accountId) {
|
||||||
setSelectedAccounts([accountId]);
|
setSelectedAccounts([accountId]);
|
||||||
|
setPage(0);
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
@@ -43,20 +60,20 @@ export default function TransactionsPage() {
|
|||||||
const [showReconciled, setShowReconciled] = useState<string>("all");
|
const [showReconciled, setShowReconciled] = useState<string>("all");
|
||||||
const [period, setPeriod] = useState<Period>("all");
|
const [period, setPeriod] = useState<Period>("all");
|
||||||
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
|
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
|
||||||
undefined,
|
undefined
|
||||||
);
|
);
|
||||||
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(
|
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(
|
||||||
undefined,
|
undefined
|
||||||
);
|
);
|
||||||
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
|
const [isCustomDatePickerOpen, setIsCustomDatePickerOpen] = useState(false);
|
||||||
const [sortField, setSortField] = useState<SortField>("date");
|
const [sortField, setSortField] = useState<SortField>("date");
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
|
||||||
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
|
const [selectedTransactions, setSelectedTransactions] = useState<Set<string>>(
|
||||||
new Set(),
|
new Set()
|
||||||
);
|
);
|
||||||
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
||||||
const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(
|
const [ruleTransaction, setRuleTransaction] = useState<Transaction | null>(
|
||||||
null,
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get start date based on period
|
// Get start date based on period
|
||||||
@@ -86,225 +103,106 @@ export default function TransactionsPage() {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [period, customEndDate]);
|
}, [period, customEndDate]);
|
||||||
|
|
||||||
// Transactions filtered for account filter (by categories, search, reconciled, period - not accounts)
|
// Build transaction query params
|
||||||
const transactionsForAccountFilter = useMemo(() => {
|
const transactionParams = useMemo(() => {
|
||||||
if (!data) return [];
|
const params: TransactionsPaginatedParams = {
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: page * PAGE_SIZE,
|
||||||
|
sortField,
|
||||||
|
sortOrder,
|
||||||
|
};
|
||||||
|
|
||||||
let transactions = [...data.transactions];
|
if (startDate && period !== "all") {
|
||||||
|
params.startDate = startDate.toISOString().split("T")[0];
|
||||||
// Filter by period
|
}
|
||||||
transactions = transactions.filter((t) => {
|
if (endDate) {
|
||||||
const transactionDate = new Date(t.date);
|
params.endDate = endDate.toISOString().split("T")[0];
|
||||||
if (endDate) {
|
}
|
||||||
// Custom date range
|
if (!selectedAccounts.includes("all")) {
|
||||||
const endOfDay = new Date(endDate);
|
params.accountIds = selectedAccounts;
|
||||||
endOfDay.setHours(23, 59, 59, 999);
|
|
||||||
return transactionDate >= startDate && transactionDate <= endOfDay;
|
|
||||||
} else if (period !== "all") {
|
|
||||||
// Standard period
|
|
||||||
return transactionDate >= startDate;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (searchQuery) {
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
transactions = transactions.filter(
|
|
||||||
(t) =>
|
|
||||||
t.description.toLowerCase().includes(query) ||
|
|
||||||
t.memo?.toLowerCase().includes(query),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!selectedCategories.includes("all")) {
|
if (!selectedCategories.includes("all")) {
|
||||||
if (selectedCategories.includes("uncategorized")) {
|
if (selectedCategories.includes("uncategorized")) {
|
||||||
transactions = transactions.filter((t) => !t.categoryId);
|
params.includeUncategorized = true;
|
||||||
} else {
|
} else {
|
||||||
transactions = transactions.filter(
|
params.categoryIds = selectedCategories;
|
||||||
(t) => t.categoryId && selectedCategories.includes(t.categoryId),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (debouncedSearchQuery) {
|
||||||
|
params.search = debouncedSearchQuery;
|
||||||
|
}
|
||||||
if (showReconciled !== "all") {
|
if (showReconciled !== "all") {
|
||||||
const isReconciled = showReconciled === "reconciled";
|
params.isReconciled = showReconciled === "reconciled";
|
||||||
transactions = transactions.filter(
|
|
||||||
(t) => t.isReconciled === isReconciled,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return transactions;
|
return params;
|
||||||
}, [
|
}, [
|
||||||
data,
|
page,
|
||||||
searchQuery,
|
|
||||||
selectedCategories,
|
|
||||||
showReconciled,
|
|
||||||
period,
|
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
]);
|
|
||||||
|
|
||||||
// Transactions filtered for category filter (by accounts, search, reconciled, period - not categories)
|
|
||||||
const transactionsForCategoryFilter = useMemo(() => {
|
|
||||||
if (!data) return [];
|
|
||||||
|
|
||||||
let transactions = [...data.transactions];
|
|
||||||
|
|
||||||
// Filter by period
|
|
||||||
transactions = transactions.filter((t) => {
|
|
||||||
const transactionDate = new Date(t.date);
|
|
||||||
if (endDate) {
|
|
||||||
// Custom date range
|
|
||||||
const endOfDay = new Date(endDate);
|
|
||||||
endOfDay.setHours(23, 59, 59, 999);
|
|
||||||
return transactionDate >= startDate && transactionDate <= endOfDay;
|
|
||||||
} else if (period !== "all") {
|
|
||||||
// Standard period
|
|
||||||
return transactionDate >= startDate;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (searchQuery) {
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
transactions = transactions.filter(
|
|
||||||
(t) =>
|
|
||||||
t.description.toLowerCase().includes(query) ||
|
|
||||||
t.memo?.toLowerCase().includes(query),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedAccounts.includes("all")) {
|
|
||||||
transactions = transactions.filter((t) =>
|
|
||||||
selectedAccounts.includes(t.accountId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showReconciled !== "all") {
|
|
||||||
const isReconciled = showReconciled === "reconciled";
|
|
||||||
transactions = transactions.filter(
|
|
||||||
(t) => t.isReconciled === isReconciled,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return transactions;
|
|
||||||
}, [
|
|
||||||
data,
|
|
||||||
searchQuery,
|
|
||||||
selectedAccounts,
|
|
||||||
showReconciled,
|
|
||||||
period,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const filteredTransactions = useMemo(() => {
|
|
||||||
if (!data) return [];
|
|
||||||
|
|
||||||
let transactions = [...data.transactions];
|
|
||||||
|
|
||||||
// Filter by period
|
|
||||||
transactions = transactions.filter((t) => {
|
|
||||||
const transactionDate = new Date(t.date);
|
|
||||||
if (endDate) {
|
|
||||||
// Custom date range
|
|
||||||
const endOfDay = new Date(endDate);
|
|
||||||
endOfDay.setHours(23, 59, 59, 999);
|
|
||||||
return transactionDate >= startDate && transactionDate <= endOfDay;
|
|
||||||
} else if (period !== "all") {
|
|
||||||
// Standard period
|
|
||||||
return transactionDate >= startDate;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (searchQuery) {
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
transactions = transactions.filter(
|
|
||||||
(t) =>
|
|
||||||
t.description.toLowerCase().includes(query) ||
|
|
||||||
t.memo?.toLowerCase().includes(query),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedAccounts.includes("all")) {
|
|
||||||
transactions = transactions.filter((t) =>
|
|
||||||
selectedAccounts.includes(t.accountId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedCategories.includes("all")) {
|
|
||||||
if (selectedCategories.includes("uncategorized")) {
|
|
||||||
transactions = transactions.filter((t) => !t.categoryId);
|
|
||||||
} else {
|
|
||||||
transactions = transactions.filter(
|
|
||||||
(t) => t.categoryId && selectedCategories.includes(t.categoryId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showReconciled !== "all") {
|
|
||||||
const isReconciled = showReconciled === "reconciled";
|
|
||||||
transactions = transactions.filter(
|
|
||||||
(t) => t.isReconciled === isReconciled,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
transactions.sort((a, b) => {
|
|
||||||
let comparison = 0;
|
|
||||||
switch (sortField) {
|
|
||||||
case "date":
|
|
||||||
comparison = new Date(a.date).getTime() - new Date(b.date).getTime();
|
|
||||||
break;
|
|
||||||
case "amount":
|
|
||||||
comparison = a.amount - b.amount;
|
|
||||||
break;
|
|
||||||
case "description":
|
|
||||||
comparison = a.description.localeCompare(b.description);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return sortOrder === "asc" ? comparison : -comparison;
|
|
||||||
});
|
|
||||||
|
|
||||||
return transactions;
|
|
||||||
}, [
|
|
||||||
data,
|
|
||||||
searchQuery,
|
|
||||||
selectedAccounts,
|
selectedAccounts,
|
||||||
selectedCategories,
|
selectedCategories,
|
||||||
|
debouncedSearchQuery,
|
||||||
showReconciled,
|
showReconciled,
|
||||||
|
sortField,
|
||||||
|
sortOrder,
|
||||||
period,
|
period,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fetch transactions with pagination
|
||||||
|
const {
|
||||||
|
data: transactionsData,
|
||||||
|
isLoading: isLoadingTransactions,
|
||||||
|
invalidate: invalidateTransactions,
|
||||||
|
} = useTransactions(transactionParams, !!metadata);
|
||||||
|
|
||||||
|
// Reset page when filters change
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(0);
|
||||||
|
}, [
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
|
selectedAccounts,
|
||||||
|
selectedCategories,
|
||||||
|
debouncedSearchQuery,
|
||||||
|
showReconciled,
|
||||||
sortField,
|
sortField,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// For filter comboboxes, we'll use empty arrays for now
|
||||||
|
// They can be enhanced later with separate queries if needed
|
||||||
|
const transactionsForAccountFilter: Transaction[] = [];
|
||||||
|
const transactionsForCategoryFilter: Transaction[] = [];
|
||||||
|
|
||||||
const handleCreateRule = useCallback((transaction: Transaction) => {
|
const handleCreateRule = useCallback((transaction: Transaction) => {
|
||||||
setRuleTransaction(transaction);
|
setRuleTransaction(transaction);
|
||||||
setRuleDialogOpen(true);
|
setRuleDialogOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Create a virtual group for the rule dialog based on selected transaction
|
// Create a virtual group for the rule dialog based on selected transaction
|
||||||
|
// Note: This requires fetching similar transactions - simplified for now
|
||||||
const ruleGroup = useMemo(() => {
|
const ruleGroup = useMemo(() => {
|
||||||
if (!ruleTransaction || !data) return null;
|
if (!ruleTransaction || !transactionsData) return null;
|
||||||
|
|
||||||
// Find similar transactions (same normalized description)
|
// Use transactions from current page to find similar ones
|
||||||
const normalizedDesc = normalizeDescription(ruleTransaction.description);
|
const normalizedDesc = normalizeDescription(ruleTransaction.description);
|
||||||
const similarTransactions = data.transactions.filter(
|
const similarTransactions = transactionsData.transactions.filter(
|
||||||
(t) => normalizeDescription(t.description) === normalizedDesc,
|
(t) => normalizeDescription(t.description) === normalizedDesc
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (similarTransactions.length === 0) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
key: normalizedDesc,
|
key: normalizedDesc,
|
||||||
displayName: ruleTransaction.description,
|
displayName: ruleTransaction.description,
|
||||||
transactions: similarTransactions,
|
transactions: similarTransactions,
|
||||||
totalAmount: similarTransactions.reduce((sum, t) => sum + t.amount, 0),
|
totalAmount: similarTransactions.reduce((sum, t) => sum + t.amount, 0),
|
||||||
suggestedKeyword: suggestKeyword(
|
suggestedKeyword: suggestKeyword(
|
||||||
similarTransactions.map((t) => t.description),
|
similarTransactions.map((t) => t.description)
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}, [ruleTransaction, data]);
|
}, [ruleTransaction, transactionsData]);
|
||||||
|
|
||||||
const handleSaveRule = useCallback(
|
const handleSaveRule = useCallback(
|
||||||
async (ruleData: {
|
async (ruleData: {
|
||||||
@@ -313,11 +211,11 @@ export default function TransactionsPage() {
|
|||||||
applyToExisting: boolean;
|
applyToExisting: boolean;
|
||||||
transactionIds: string[];
|
transactionIds: string[];
|
||||||
}) => {
|
}) => {
|
||||||
if (!data) return;
|
if (!metadata) return;
|
||||||
|
|
||||||
// 1. Add keyword to category
|
// 1. Add keyword to category
|
||||||
const category = data.categories.find(
|
const category = metadata.categories.find(
|
||||||
(c) => 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");
|
||||||
@@ -325,7 +223,7 @@ export default function TransactionsPage() {
|
|||||||
|
|
||||||
// Check if keyword already exists
|
// Check if keyword already exists
|
||||||
const keywordExists = category.keywords.some(
|
const keywordExists = category.keywords.some(
|
||||||
(k) => k.toLowerCase() === ruleData.keyword.toLowerCase(),
|
(k: string) => k.toLowerCase() === ruleData.keyword.toLowerCase()
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!keywordExists) {
|
if (!keywordExists) {
|
||||||
@@ -337,24 +235,31 @@ export default function TransactionsPage() {
|
|||||||
|
|
||||||
// 2. Apply to existing transactions if requested
|
// 2. Apply to existing transactions if requested
|
||||||
if (ruleData.applyToExisting) {
|
if (ruleData.applyToExisting) {
|
||||||
const transactions = data.transactions.filter((t) =>
|
|
||||||
ruleData.transactionIds.includes(t.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
transactions.map((t) =>
|
ruleData.transactionIds.map((id) =>
|
||||||
updateTransaction({ ...t, categoryId: ruleData.categoryId }),
|
fetch("/api/banking/transactions", {
|
||||||
),
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id, categoryId: ruleData.categoryId }),
|
||||||
|
})
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh();
|
// Invalidate queries
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
||||||
setRuleDialogOpen(false);
|
setRuleDialogOpen(false);
|
||||||
},
|
},
|
||||||
[data, refresh],
|
[metadata, queryClient]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading || !data) {
|
const invalidateAll = useCallback(() => {
|
||||||
|
invalidateTransactions();
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
|
||||||
|
}, [invalidateTransactions, queryClient]);
|
||||||
|
|
||||||
|
if (isLoadingMetadata || !metadata) {
|
||||||
return <LoadingState />;
|
return <LoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +279,11 @@ export default function TransactionsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleReconciled = async (transactionId: string) => {
|
const toggleReconciled = async (transactionId: string) => {
|
||||||
const transaction = data.transactions.find((t) => t.id === transactionId);
|
if (!transactionsData) return;
|
||||||
|
|
||||||
|
const transaction = transactionsData.transactions.find(
|
||||||
|
(t) => t.id === transactionId
|
||||||
|
);
|
||||||
if (!transaction) return;
|
if (!transaction) return;
|
||||||
|
|
||||||
const updatedTransaction = {
|
const updatedTransaction = {
|
||||||
@@ -382,84 +291,75 @@ export default function TransactionsPage() {
|
|||||||
isReconciled: !transaction.isReconciled,
|
isReconciled: !transaction.isReconciled,
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedTransactions = data.transactions.map((t) =>
|
|
||||||
t.id === transactionId ? updatedTransaction : t,
|
|
||||||
);
|
|
||||||
update({ ...data, transactions: updatedTransactions });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch("/api/banking/transactions", {
|
await fetch("/api/banking/transactions", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(updatedTransaction),
|
body: JSON.stringify(updatedTransaction),
|
||||||
});
|
});
|
||||||
|
invalidateTransactions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update transaction:", error);
|
console.error("Failed to update transaction:", error);
|
||||||
refresh();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const markReconciled = async (transactionId: string) => {
|
const markReconciled = async (transactionId: string) => {
|
||||||
const transaction = data.transactions.find((t) => t.id === transactionId);
|
if (!transactionsData) return;
|
||||||
if (!transaction || transaction.isReconciled) return; // Skip if already reconciled
|
|
||||||
|
const transaction = transactionsData.transactions.find(
|
||||||
|
(t) => t.id === transactionId
|
||||||
|
);
|
||||||
|
if (!transaction || transaction.isReconciled) return;
|
||||||
|
|
||||||
const updatedTransaction = {
|
const updatedTransaction = {
|
||||||
...transaction,
|
...transaction,
|
||||||
isReconciled: true,
|
isReconciled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedTransactions = data.transactions.map((t) =>
|
|
||||||
t.id === transactionId ? updatedTransaction : t,
|
|
||||||
);
|
|
||||||
update({ ...data, transactions: updatedTransactions });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch("/api/banking/transactions", {
|
await fetch("/api/banking/transactions", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(updatedTransaction),
|
body: JSON.stringify(updatedTransaction),
|
||||||
});
|
});
|
||||||
|
invalidateTransactions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update transaction:", error);
|
console.error("Failed to update transaction:", error);
|
||||||
refresh();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setCategory = async (
|
const setCategory = async (
|
||||||
transactionId: string,
|
transactionId: string,
|
||||||
categoryId: string | null,
|
categoryId: string | null
|
||||||
) => {
|
) => {
|
||||||
const transaction = data.transactions.find((t) => t.id === transactionId);
|
if (!transactionsData) return;
|
||||||
|
|
||||||
|
const transaction = transactionsData.transactions.find(
|
||||||
|
(t) => t.id === transactionId
|
||||||
|
);
|
||||||
if (!transaction) return;
|
if (!transaction) return;
|
||||||
|
|
||||||
const updatedTransaction = { ...transaction, categoryId };
|
const updatedTransaction = { ...transaction, categoryId };
|
||||||
|
|
||||||
const updatedTransactions = data.transactions.map((t) =>
|
|
||||||
t.id === transactionId ? updatedTransaction : t,
|
|
||||||
);
|
|
||||||
update({ ...data, transactions: updatedTransactions });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch("/api/banking/transactions", {
|
await fetch("/api/banking/transactions", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(updatedTransaction),
|
body: JSON.stringify(updatedTransaction),
|
||||||
});
|
});
|
||||||
|
invalidateTransactions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update transaction:", error);
|
console.error("Failed to update transaction:", error);
|
||||||
refresh();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const bulkReconcile = async (reconciled: boolean) => {
|
const bulkReconcile = async (reconciled: boolean) => {
|
||||||
const transactionsToUpdate = data.transactions.filter((t) =>
|
if (!transactionsData) return;
|
||||||
selectedTransactions.has(t.id),
|
|
||||||
|
const transactionsToUpdate = transactionsData.transactions.filter((t) =>
|
||||||
|
selectedTransactions.has(t.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedTransactions = data.transactions.map((t) =>
|
|
||||||
selectedTransactions.has(t.id) ? { ...t, isReconciled: reconciled } : t,
|
|
||||||
);
|
|
||||||
update({ ...data, transactions: updatedTransactions });
|
|
||||||
setSelectedTransactions(new Set());
|
setSelectedTransactions(new Set());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -469,24 +369,22 @@ export default function TransactionsPage() {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ...t, isReconciled: reconciled }),
|
body: JSON.stringify({ ...t, isReconciled: reconciled }),
|
||||||
}),
|
})
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
|
invalidateTransactions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update transactions:", error);
|
console.error("Failed to update transactions:", error);
|
||||||
refresh();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const bulkSetCategory = async (categoryId: string | null) => {
|
const bulkSetCategory = async (categoryId: string | null) => {
|
||||||
const transactionsToUpdate = data.transactions.filter((t) =>
|
if (!transactionsData) return;
|
||||||
selectedTransactions.has(t.id),
|
|
||||||
|
const transactionsToUpdate = transactionsData.transactions.filter((t) =>
|
||||||
|
selectedTransactions.has(t.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedTransactions = data.transactions.map((t) =>
|
|
||||||
selectedTransactions.has(t.id) ? { ...t, categoryId } : t,
|
|
||||||
);
|
|
||||||
update({ ...data, transactions: updatedTransactions });
|
|
||||||
setSelectedTransactions(new Set());
|
setSelectedTransactions(new Set());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -496,20 +394,23 @@ export default function TransactionsPage() {
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ ...t, categoryId }),
|
body: JSON.stringify({ ...t, categoryId }),
|
||||||
}),
|
})
|
||||||
),
|
)
|
||||||
);
|
);
|
||||||
|
invalidateTransactions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update transactions:", error);
|
console.error("Failed to update transactions:", error);
|
||||||
refresh();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSelectAll = () => {
|
const toggleSelectAll = () => {
|
||||||
if (selectedTransactions.size === filteredTransactions.length) {
|
if (!transactionsData) return;
|
||||||
|
if (selectedTransactions.size === transactionsData.transactions.length) {
|
||||||
setSelectedTransactions(new Set());
|
setSelectedTransactions(new Set());
|
||||||
} else {
|
} else {
|
||||||
setSelectedTransactions(new Set(filteredTransactions.map((t) => t.id)));
|
setSelectedTransactions(
|
||||||
|
new Set(transactionsData.transactions.map((t) => t.id))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -530,15 +431,10 @@ export default function TransactionsPage() {
|
|||||||
setSortField(field);
|
setSortField(field);
|
||||||
setSortOrder(field === "date" ? "desc" : "asc");
|
setSortOrder(field === "date" ? "desc" : "asc");
|
||||||
}
|
}
|
||||||
|
setPage(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteTransaction = async (transactionId: string) => {
|
const deleteTransaction = async (transactionId: string) => {
|
||||||
// Optimistic update
|
|
||||||
const updatedTransactions = data.transactions.filter(
|
|
||||||
(t) => t.id !== transactionId,
|
|
||||||
);
|
|
||||||
update({ ...data, transactions: updatedTransactions });
|
|
||||||
|
|
||||||
// Remove from selected if selected
|
// Remove from selected if selected
|
||||||
const newSelected = new Set(selectedTransactions);
|
const newSelected = new Set(selectedTransactions);
|
||||||
newSelected.delete(transactionId);
|
newSelected.delete(transactionId);
|
||||||
@@ -549,22 +445,26 @@ export default function TransactionsPage() {
|
|||||||
`/api/banking/transactions?id=${transactionId}`,
|
`/api/banking/transactions?id=${transactionId}`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
if (!response.ok) throw new Error("Failed to delete transaction");
|
if (!response.ok) throw new Error("Failed to delete transaction");
|
||||||
|
invalidateTransactions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete transaction:", error);
|
console.error("Failed to delete transaction:", error);
|
||||||
refresh(); // Revert on error
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filteredTransactions = transactionsData?.transactions || [];
|
||||||
|
const totalTransactions = transactionsData?.total || 0;
|
||||||
|
const hasMore = transactionsData?.hasMore || false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Transactions"
|
title="Transactions"
|
||||||
description={`${filteredTransactions.length} transaction${filteredTransactions.length > 1 ? "s" : ""}`}
|
description={`${totalTransactions} transaction${totalTransactions > 1 ? "s" : ""}`}
|
||||||
actions={
|
actions={
|
||||||
<OFXImportDialog onImportComplete={refresh}>
|
<OFXImportDialog onImportComplete={invalidateAll}>
|
||||||
<Button>
|
<Button>
|
||||||
<Upload className="w-4 h-4 mr-2" />
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
Importer OFX
|
Importer OFX
|
||||||
@@ -577,14 +477,24 @@ export default function TransactionsPage() {
|
|||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
onSearchChange={setSearchQuery}
|
onSearchChange={setSearchQuery}
|
||||||
selectedAccounts={selectedAccounts}
|
selectedAccounts={selectedAccounts}
|
||||||
onAccountsChange={setSelectedAccounts}
|
onAccountsChange={(accounts) => {
|
||||||
|
setSelectedAccounts(accounts);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
selectedCategories={selectedCategories}
|
selectedCategories={selectedCategories}
|
||||||
onCategoriesChange={setSelectedCategories}
|
onCategoriesChange={(categories) => {
|
||||||
|
setSelectedCategories(categories);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
showReconciled={showReconciled}
|
showReconciled={showReconciled}
|
||||||
onReconciledChange={setShowReconciled}
|
onReconciledChange={(value) => {
|
||||||
|
setShowReconciled(value);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
period={period}
|
period={period}
|
||||||
onPeriodChange={(p) => {
|
onPeriodChange={(p) => {
|
||||||
setPeriod(p);
|
setPeriod(p);
|
||||||
|
setPage(0);
|
||||||
if (p !== "custom") {
|
if (p !== "custom") {
|
||||||
setIsCustomDatePickerOpen(false);
|
setIsCustomDatePickerOpen(false);
|
||||||
} else {
|
} else {
|
||||||
@@ -593,48 +503,89 @@ export default function TransactionsPage() {
|
|||||||
}}
|
}}
|
||||||
customStartDate={customStartDate}
|
customStartDate={customStartDate}
|
||||||
customEndDate={customEndDate}
|
customEndDate={customEndDate}
|
||||||
onCustomStartDateChange={setCustomStartDate}
|
onCustomStartDateChange={(date) => {
|
||||||
onCustomEndDateChange={setCustomEndDate}
|
setCustomStartDate(date);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
onCustomEndDateChange={(date) => {
|
||||||
|
setCustomEndDate(date);
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
isCustomDatePickerOpen={isCustomDatePickerOpen}
|
isCustomDatePickerOpen={isCustomDatePickerOpen}
|
||||||
onCustomDatePickerOpenChange={setIsCustomDatePickerOpen}
|
onCustomDatePickerOpenChange={setIsCustomDatePickerOpen}
|
||||||
accounts={data.accounts}
|
accounts={metadata.accounts}
|
||||||
folders={data.folders}
|
folders={metadata.folders}
|
||||||
categories={data.categories}
|
categories={metadata.categories}
|
||||||
transactionsForAccountFilter={transactionsForAccountFilter}
|
transactionsForAccountFilter={transactionsForAccountFilter}
|
||||||
transactionsForCategoryFilter={transactionsForCategoryFilter}
|
transactionsForCategoryFilter={transactionsForCategoryFilter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TransactionBulkActions
|
<TransactionBulkActions
|
||||||
selectedCount={selectedTransactions.size}
|
selectedCount={selectedTransactions.size}
|
||||||
categories={data.categories}
|
categories={metadata.categories}
|
||||||
onReconcile={bulkReconcile}
|
onReconcile={bulkReconcile}
|
||||||
onSetCategory={bulkSetCategory}
|
onSetCategory={bulkSetCategory}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TransactionTable
|
{isLoadingTransactions ? (
|
||||||
transactions={filteredTransactions}
|
<LoadingState />
|
||||||
accounts={data.accounts}
|
) : (
|
||||||
categories={data.categories}
|
<>
|
||||||
selectedTransactions={selectedTransactions}
|
<TransactionTable
|
||||||
sortField={sortField}
|
transactions={filteredTransactions}
|
||||||
sortOrder={sortOrder}
|
accounts={metadata.accounts}
|
||||||
onSortChange={handleSortChange}
|
categories={metadata.categories}
|
||||||
onToggleSelectAll={toggleSelectAll}
|
selectedTransactions={selectedTransactions}
|
||||||
onToggleSelectTransaction={toggleSelectTransaction}
|
sortField={sortField}
|
||||||
onToggleReconciled={toggleReconciled}
|
sortOrder={sortOrder}
|
||||||
onMarkReconciled={markReconciled}
|
onSortChange={handleSortChange}
|
||||||
onSetCategory={setCategory}
|
onToggleSelectAll={toggleSelectAll}
|
||||||
onCreateRule={handleCreateRule}
|
onToggleSelectTransaction={toggleSelectTransaction}
|
||||||
onDelete={deleteTransaction}
|
onToggleReconciled={toggleReconciled}
|
||||||
formatCurrency={formatCurrency}
|
onMarkReconciled={markReconciled}
|
||||||
formatDate={formatDate}
|
onSetCategory={setCategory}
|
||||||
/>
|
onCreateRule={handleCreateRule}
|
||||||
|
onDelete={deleteTransaction}
|
||||||
|
formatCurrency={formatCurrency}
|
||||||
|
formatDate={formatDate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pagination controls */}
|
||||||
|
{totalTransactions > PAGE_SIZE && (
|
||||||
|
<div className="flex items-center justify-between mt-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Affichage de {page * PAGE_SIZE + 1} à{" "}
|
||||||
|
{Math.min((page + 1) * PAGE_SIZE, totalTransactions)} sur{" "}
|
||||||
|
{totalTransactions}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
>
|
||||||
|
Précédent
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
disabled={!hasMore}
|
||||||
|
>
|
||||||
|
Suivant
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<RuleCreateDialog
|
<RuleCreateDialog
|
||||||
open={ruleDialogOpen}
|
open={ruleDialogOpen}
|
||||||
onOpenChange={setRuleDialogOpen}
|
onOpenChange={setRuleDialogOpen}
|
||||||
group={ruleGroup}
|
group={ruleGroup}
|
||||||
categories={data.categories}
|
categories={metadata.categories}
|
||||||
onSave={handleSaveRule}
|
onSave={handleSaveRule}
|
||||||
/>
|
/>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
25
components/providers/query-provider.tsx
Normal file
25
components/providers/query-provider.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60 * 1000, // 1 minute
|
||||||
|
gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime)
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
104
lib/hooks.ts
104
lib/hooks.ts
@@ -1,8 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import type { BankingData } from "./types";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { BankingData, Account } from "./types";
|
||||||
import { loadData } from "./store-db";
|
import { loadData } from "./store-db";
|
||||||
|
import type {
|
||||||
|
TransactionsPaginatedParams,
|
||||||
|
TransactionsPaginatedResult,
|
||||||
|
} from "@/services/banking.service";
|
||||||
|
|
||||||
export function useBankingData() {
|
export function useBankingData() {
|
||||||
const [data, setData] = useState<BankingData | null>(null);
|
const [data, setData] = useState<BankingData | null>(null);
|
||||||
@@ -75,3 +80,100 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
|
|||||||
|
|
||||||
return [storedValue, setValue] as const;
|
return [storedValue, setValue] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useTransactions(
|
||||||
|
params: TransactionsPaginatedParams = {},
|
||||||
|
enabled = true,
|
||||||
|
) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["transactions", params],
|
||||||
|
queryFn: async (): Promise<TransactionsPaginatedResult> => {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params.limit) searchParams.set("limit", params.limit.toString());
|
||||||
|
if (params.offset) searchParams.set("offset", params.offset.toString());
|
||||||
|
if (params.startDate) searchParams.set("startDate", params.startDate);
|
||||||
|
if (params.endDate) searchParams.set("endDate", params.endDate);
|
||||||
|
if (params.accountIds && params.accountIds.length > 0) {
|
||||||
|
searchParams.set("accountIds", params.accountIds.join(","));
|
||||||
|
}
|
||||||
|
if (params.categoryIds && params.categoryIds.length > 0) {
|
||||||
|
searchParams.set("categoryIds", params.categoryIds.join(","));
|
||||||
|
}
|
||||||
|
if (params.includeUncategorized) {
|
||||||
|
searchParams.set("includeUncategorized", "true");
|
||||||
|
}
|
||||||
|
if (params.search) searchParams.set("search", params.search);
|
||||||
|
if (params.isReconciled !== undefined && params.isReconciled !== "all") {
|
||||||
|
searchParams.set(
|
||||||
|
"isReconciled",
|
||||||
|
params.isReconciled === true ? "true" : "false",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (params.sortField) searchParams.set("sortField", params.sortField);
|
||||||
|
if (params.sortOrder) searchParams.set("sortOrder", params.sortOrder);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/banking/transactions?${searchParams}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch transactions");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
|
staleTime: 30 * 1000, // 30 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = useCallback(() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["transactions"] });
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...query,
|
||||||
|
invalidate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCategoryStats() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["category-stats"],
|
||||||
|
queryFn: async (): Promise<Record<string, { count: number; total: number }>> => {
|
||||||
|
const response = await fetch("/api/banking/categories?statsOnly=true");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch category stats");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
staleTime: 60 * 1000, // 1 minute
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBankingMetadata() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["banking-metadata"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch("/api/banking?metadataOnly=true");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch banking metadata");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAccountsWithStats() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["accounts-with-stats"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch("/api/banking/accounts?withStats=true");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch accounts with stats");
|
||||||
|
}
|
||||||
|
return response.json() as Promise<
|
||||||
|
Array<Account & { transactionCount: number }>
|
||||||
|
>;
|
||||||
|
},
|
||||||
|
staleTime: 60 * 1000, // 1 minute
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
"@radix-ui/react-toggle": "1.1.1",
|
"@radix-ui/react-toggle": "1.1.1",
|
||||||
"@radix-ui/react-toggle-group": "1.1.1",
|
"@radix-ui/react-toggle-group": "1.1.1",
|
||||||
"@radix-ui/react-tooltip": "1.1.6",
|
"@radix-ui/react-tooltip": "1.1.6",
|
||||||
|
"@tanstack/react-query": "^5.90.12",
|
||||||
"@tanstack/react-virtual": "^3.13.12",
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"@vercel/analytics": "1.3.1",
|
"@vercel/analytics": "1.3.1",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
|||||||
6731
pnpm-lock.yaml
generated
6731
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,8 @@ model Transaction {
|
|||||||
@@index([accountId])
|
@@index([accountId])
|
||||||
@@index([categoryId])
|
@@index([categoryId])
|
||||||
@@index([date])
|
@@index([date])
|
||||||
|
@@index([accountId, date])
|
||||||
|
@@index([isReconciled])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Folder {
|
model Folder {
|
||||||
|
|||||||
@@ -6,6 +6,27 @@ import type {
|
|||||||
Folder,
|
Folder,
|
||||||
Category,
|
Category,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
export interface TransactionsPaginatedParams {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
accountIds?: string[];
|
||||||
|
categoryIds?: string[];
|
||||||
|
includeUncategorized?: boolean;
|
||||||
|
search?: string;
|
||||||
|
isReconciled?: boolean | "all";
|
||||||
|
sortField?: "date" | "amount" | "description";
|
||||||
|
sortOrder?: "asc" | "desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransactionsPaginatedResult {
|
||||||
|
transactions: Transaction[];
|
||||||
|
total: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export const bankingService = {
|
export const bankingService = {
|
||||||
async getAllData(): Promise<BankingData> {
|
async getAllData(): Promise<BankingData> {
|
||||||
@@ -16,9 +37,19 @@ export const bankingService = {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.transaction.findMany({
|
prisma.transaction.findMany({
|
||||||
include: {
|
// Removed includes - not needed for transformation, only use direct fields
|
||||||
account: true,
|
select: {
|
||||||
category: true,
|
id: true,
|
||||||
|
accountId: true,
|
||||||
|
date: true,
|
||||||
|
amount: true,
|
||||||
|
description: true,
|
||||||
|
type: true,
|
||||||
|
categoryId: true,
|
||||||
|
isReconciled: true,
|
||||||
|
fitId: true,
|
||||||
|
memo: true,
|
||||||
|
checkNum: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.folder.findMany(),
|
prisma.folder.findMany(),
|
||||||
@@ -78,4 +109,292 @@ export const bankingService = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getTransactionsPaginated(
|
||||||
|
params: TransactionsPaginatedParams = {},
|
||||||
|
): Promise<TransactionsPaginatedResult> {
|
||||||
|
const {
|
||||||
|
limit = 50,
|
||||||
|
offset = 0,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
accountIds,
|
||||||
|
categoryIds,
|
||||||
|
includeUncategorized = false,
|
||||||
|
search,
|
||||||
|
isReconciled = "all",
|
||||||
|
sortField = "date",
|
||||||
|
sortOrder = "desc",
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
// Build where clause
|
||||||
|
const where: Prisma.TransactionWhereInput = {};
|
||||||
|
|
||||||
|
// Date filter
|
||||||
|
if (startDate || endDate) {
|
||||||
|
where.date = {};
|
||||||
|
if (startDate) {
|
||||||
|
where.date.gte = startDate;
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
// Add time to end of day
|
||||||
|
const endOfDay = new Date(endDate);
|
||||||
|
endOfDay.setHours(23, 59, 59, 999);
|
||||||
|
where.date.lte = endOfDay.toISOString().split("T")[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account filter
|
||||||
|
if (accountIds && accountIds.length > 0) {
|
||||||
|
where.accountId = { in: accountIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category filter
|
||||||
|
const categoryFilter: Prisma.TransactionWhereInput[] = [];
|
||||||
|
if (categoryIds && categoryIds.length > 0) {
|
||||||
|
if (includeUncategorized) {
|
||||||
|
categoryFilter.push({
|
||||||
|
OR: [
|
||||||
|
{ categoryId: { in: categoryIds } },
|
||||||
|
{ categoryId: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
categoryFilter.push({ categoryId: { in: categoryIds } });
|
||||||
|
}
|
||||||
|
} else if (includeUncategorized) {
|
||||||
|
categoryFilter.push({ categoryId: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search filter (description or memo)
|
||||||
|
// SQLite is case-insensitive by default for ASCII strings
|
||||||
|
if (search && search.trim()) {
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
categoryFilter.push({
|
||||||
|
OR: [
|
||||||
|
{ description: { contains: searchLower } },
|
||||||
|
{ memo: { contains: searchLower } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine all filters with AND if we have multiple conditions
|
||||||
|
if (categoryFilter.length > 0) {
|
||||||
|
if (categoryFilter.length === 1) {
|
||||||
|
Object.assign(where, categoryFilter[0]);
|
||||||
|
} else {
|
||||||
|
where.AND = categoryFilter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconciled filter
|
||||||
|
if (isReconciled !== "all") {
|
||||||
|
where.isReconciled = isReconciled === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build orderBy
|
||||||
|
const orderBy: Prisma.TransactionOrderByWithRelationInput[] = [];
|
||||||
|
switch (sortField) {
|
||||||
|
case "date":
|
||||||
|
orderBy.push({ date: sortOrder });
|
||||||
|
break;
|
||||||
|
case "amount":
|
||||||
|
orderBy.push({ amount: sortOrder });
|
||||||
|
break;
|
||||||
|
case "description":
|
||||||
|
orderBy.push({ description: sortOrder });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Add secondary sort by date for consistency
|
||||||
|
if (sortField !== "date") {
|
||||||
|
orderBy.push({ date: "desc" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const total = await prisma.transaction.count({ where });
|
||||||
|
|
||||||
|
// Get paginated transactions
|
||||||
|
const transactions = await prisma.transaction.findMany({
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
take: limit,
|
||||||
|
skip: offset,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
accountId: true,
|
||||||
|
date: true,
|
||||||
|
amount: true,
|
||||||
|
description: true,
|
||||||
|
type: true,
|
||||||
|
categoryId: true,
|
||||||
|
isReconciled: true,
|
||||||
|
fitId: true,
|
||||||
|
memo: true,
|
||||||
|
checkNum: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform to Transaction type
|
||||||
|
const transformedTransactions: Transaction[] = transactions.map(
|
||||||
|
(t): Transaction => ({
|
||||||
|
id: t.id,
|
||||||
|
accountId: t.accountId,
|
||||||
|
date: t.date,
|
||||||
|
amount: t.amount,
|
||||||
|
description: t.description,
|
||||||
|
type: t.type as Transaction["type"],
|
||||||
|
categoryId: t.categoryId,
|
||||||
|
isReconciled: t.isReconciled,
|
||||||
|
fitId: t.fitId,
|
||||||
|
memo: t.memo ?? undefined,
|
||||||
|
checkNum: t.checkNum ?? undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
transactions: transformedTransactions,
|
||||||
|
total,
|
||||||
|
hasMore: offset + limit < total,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getMetadata(): Promise<{
|
||||||
|
accounts: Account[];
|
||||||
|
folders: Folder[];
|
||||||
|
categories: Category[];
|
||||||
|
}> {
|
||||||
|
const [accounts, folders, categories] = await Promise.all([
|
||||||
|
prisma.account.findMany({
|
||||||
|
include: {
|
||||||
|
folder: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.folder.findMany(),
|
||||||
|
prisma.category.findMany(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accounts: accounts.map(
|
||||||
|
(a): Account => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
bankId: a.bankId,
|
||||||
|
accountNumber: a.accountNumber,
|
||||||
|
type: a.type as Account["type"],
|
||||||
|
folderId: a.folderId,
|
||||||
|
balance: a.balance,
|
||||||
|
initialBalance: a.initialBalance,
|
||||||
|
currency: a.currency,
|
||||||
|
lastImport: a.lastImport,
|
||||||
|
externalUrl: a.externalUrl,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
folders: folders.map(
|
||||||
|
(f): Folder => ({
|
||||||
|
id: f.id,
|
||||||
|
name: f.name,
|
||||||
|
parentId: f.parentId,
|
||||||
|
color: f.color,
|
||||||
|
icon: f.icon,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
categories: categories.map(
|
||||||
|
(c): Category => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
color: c.color,
|
||||||
|
icon: c.icon,
|
||||||
|
keywords: JSON.parse(c.keywords) as string[],
|
||||||
|
parentId: c.parentId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCategoryStats(): Promise<Record<string, { count: number; total: number }>> {
|
||||||
|
// Get stats for all categories in one query using aggregation
|
||||||
|
// We need to sum absolute values, so we'll do it in two steps
|
||||||
|
const stats = await prisma.transaction.groupBy({
|
||||||
|
by: ["categoryId"],
|
||||||
|
where: {
|
||||||
|
categoryId: { not: null },
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const statsMap: Record<string, { count: number; total: number }> = {};
|
||||||
|
|
||||||
|
// Get uncategorized count
|
||||||
|
const uncategorizedCount = await prisma.transaction.count({
|
||||||
|
where: { categoryId: null },
|
||||||
|
});
|
||||||
|
statsMap["uncategorized"] = {
|
||||||
|
count: uncategorizedCount,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// For each category, calculate total with absolute values
|
||||||
|
for (const stat of stats) {
|
||||||
|
if (stat.categoryId) {
|
||||||
|
const categoryTransactions = await prisma.transaction.findMany({
|
||||||
|
where: { categoryId: stat.categoryId },
|
||||||
|
select: { amount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = categoryTransactions.reduce(
|
||||||
|
(sum, t) => sum + Math.abs(t.amount),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
statsMap[stat.categoryId] = {
|
||||||
|
count: stat._count.id,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return statsMap;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAccountsWithStats(): Promise<
|
||||||
|
Array<Account & { transactionCount: number }>
|
||||||
|
> {
|
||||||
|
const accounts = await prisma.account.findMany({
|
||||||
|
include: {
|
||||||
|
folder: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get transaction counts for all accounts in one query
|
||||||
|
const transactionCounts = await prisma.transaction.groupBy({
|
||||||
|
by: ["accountId"],
|
||||||
|
_count: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const countMap = new Map<string, number>();
|
||||||
|
transactionCounts.forEach((tc) => {
|
||||||
|
countMap.set(tc.accountId, tc._count.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
return accounts.map(
|
||||||
|
(a): Account & { transactionCount: number } => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
bankId: a.bankId,
|
||||||
|
accountNumber: a.accountNumber,
|
||||||
|
type: a.type as Account["type"],
|
||||||
|
folderId: a.folderId,
|
||||||
|
balance: a.balance,
|
||||||
|
initialBalance: a.initialBalance,
|
||||||
|
currency: a.currency,
|
||||||
|
lastImport: a.lastImport,
|
||||||
|
externalUrl: a.externalUrl,
|
||||||
|
transactionCount: countMap.get(a.id) || 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user