diff --git a/app/transactions/page.tsx b/app/transactions/page.tsx
index 71d00ac..4704e00 100644
--- a/app/transactions/page.tsx
+++ b/app/transactions/page.tsx
@@ -1,8 +1,8 @@
"use client";
-import { useCallback } from "react";
+import { useCallback, useState } from "react";
import { PageLayout, PageHeader } from "@/components/layout";
-import { RefreshCw } from "lucide-react";
+import { RefreshCw, Maximize2, Minimize2 } from "lucide-react";
import {
TransactionFilters,
TransactionBulkActions,
@@ -13,15 +13,24 @@ import {
} from "@/components/transactions";
import { RuleCreateDialog } from "@/components/rules";
import { OFXImportDialog } from "@/components/import/ofx-import-dialog";
+import { MonthlyChart } from "@/components/statistics";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Upload } from "lucide-react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
import { useTransactionsPage } from "@/hooks/use-transactions-page";
import { useTransactionMutations } from "@/hooks/use-transaction-mutations";
import { useTransactionRules } from "@/hooks/use-transaction-rules";
+import { useTransactionsChartData } from "@/hooks/use-transactions-chart-data";
export default function TransactionsPage() {
const queryClient = useQueryClient();
+ const [isFullscreen, setIsFullscreen] = useState(false);
// Main page state and logic
const {
@@ -88,6 +97,17 @@ export default function TransactionsPage() {
metadata,
});
+ // Chart data
+ const { monthlyData, isLoading: isLoadingChart } = useTransactionsChartData({
+ selectedAccounts,
+ selectedCategories,
+ period,
+ customStartDate,
+ customEndDate,
+ showReconciled,
+ searchQuery,
+ });
+
const invalidateAll = useCallback(() => {
invalidateTransactions();
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
@@ -98,7 +118,7 @@ export default function TransactionsPage() {
handleBulkReconcile(reconciled, selectedTransactions);
clearSelection();
},
- [handleBulkReconcile, selectedTransactions, clearSelection],
+ [handleBulkReconcile, selectedTransactions, clearSelection]
);
const handleBulkSetCategoryWithClear = useCallback(
@@ -106,7 +126,7 @@ export default function TransactionsPage() {
handleBulkSetCategory(categoryId, selectedTransactions);
clearSelection();
},
- [handleBulkSetCategory, selectedTransactions, clearSelection],
+ [handleBulkSetCategory, selectedTransactions, clearSelection]
);
const filteredTransactions = transactionsData?.transactions || [];
@@ -179,6 +199,21 @@ export default function TransactionsPage() {
transactionsForCategoryFilter={transactionsForCategoryFilter}
/>
+ {!isLoadingChart && monthlyData.length > 0 && (
+
+
+
+ )}
+
) : (
<>
+
+
+
+
+
)}
+
+
string;
+ collapsible?: boolean;
+ defaultExpanded?: boolean;
+ showDots?: boolean;
}
-export function MonthlyChart({ data, formatCurrency }: MonthlyChartProps) {
+export function MonthlyChart({
+ data,
+ formatCurrency,
+ collapsible = false,
+ defaultExpanded = true,
+ showDots = true,
+}: MonthlyChartProps) {
+ const [isExpanded, setIsExpanded] = useState(defaultExpanded);
+
+ const chartContent = (
+ <>
+ {data.length > 0 ? (
+
+
+
+
+
+ {
+ // Format compact pour les grandes valeurs
+ if (Math.abs(v) >= 1000) {
+ return `${(v / 1000).toFixed(1)}k€`;
+ }
+ return `${v.toFixed(0)}€`;
+ }}
+ tick={{ fill: "var(--muted-foreground)" }}
+ />
+ formatCurrency(value)}
+ labelFormatter={(label) => label}
+ contentStyle={{
+ backgroundColor: "var(--card)",
+ border: "1px solid var(--border)",
+ borderRadius: "8px",
+ }}
+ />
+
+
+
+
+
+
+ ) : (
+
+ Pas de données pour cette période
+
+ )}
+ >
+ );
+
+ if (!collapsible) {
+ return (
+
+
+ Revenus vs Dépenses par mois
+
+ {chartContent}
+
+ );
+ }
+
return (
-
- Revenus vs Dépenses par mois
-
-
- {data.length > 0 ? (
-
-
-
-
-
- {
- // Format compact pour les grandes valeurs
- if (Math.abs(v) >= 1000) {
- return `${(v / 1000).toFixed(1)}k€`;
- }
- return `${Math.round(v)}€`;
- }}
- tick={{ fill: "var(--muted-foreground)" }}
- />
- formatCurrency(value)}
- labelFormatter={(label) => label}
- contentStyle={{
- backgroundColor: "var(--card)",
- border: "1px solid var(--border)",
- borderRadius: "8px",
- }}
- />
-
-
-
-
-
-
- ) : (
-
- Pas de données pour cette période
-
- )}
-
+
+
+
+ Revenus vs Dépenses par mois
+
+
+
+
+
+
+ {chartContent}
+
+
);
}
diff --git a/hooks/use-transactions-balance-chart.ts b/hooks/use-transactions-balance-chart.ts
new file mode 100644
index 0000000..dfc7c30
--- /dev/null
+++ b/hooks/use-transactions-balance-chart.ts
@@ -0,0 +1,328 @@
+"use client";
+
+import { useMemo } from "react";
+import { useTransactions } from "@/lib/hooks";
+import { useBankingMetadata } from "@/lib/hooks";
+import type { TransactionsPaginatedParams } from "@/services/banking.service";
+import type { Account } from "@/lib/types";
+
+interface BalanceChartDataPoint {
+ date: string;
+ [key: string]: string | number;
+}
+
+interface UseTransactionsBalanceChartParams {
+ selectedAccounts: string[];
+ selectedCategories: string[];
+ period: "1month" | "3months" | "6months" | "12months" | "custom" | "all";
+ customStartDate?: Date;
+ customEndDate?: Date;
+ showReconciled: string;
+ searchQuery: string;
+}
+
+export function useTransactionsBalanceChart({
+ selectedAccounts,
+ selectedCategories,
+ period,
+ customStartDate,
+ customEndDate,
+ showReconciled,
+ searchQuery,
+}: UseTransactionsBalanceChartParams) {
+ const { data: metadata } = useBankingMetadata();
+
+ // Calculate start date based on period
+ const startDate = useMemo(() => {
+ const now = new Date();
+ switch (period) {
+ case "1month":
+ return new Date(now.getFullYear(), now.getMonth() - 1, 1);
+ case "3months":
+ return new Date(now.getFullYear(), now.getMonth() - 3, 1);
+ case "6months":
+ return new Date(now.getFullYear(), now.getMonth() - 6, 1);
+ case "12months":
+ return new Date(now.getFullYear(), now.getMonth() - 12, 1);
+ case "custom":
+ return customStartDate || new Date(0);
+ default:
+ return new Date(0);
+ }
+ }, [period, customStartDate]);
+
+ // Calculate end date (only for custom period)
+ const endDate = useMemo(() => {
+ if (period === "custom" && customEndDate) {
+ return customEndDate;
+ }
+ return undefined;
+ }, [period, customEndDate]);
+
+ // Build params for fetching all transactions (no pagination)
+ const chartParams = useMemo(() => {
+ const params: TransactionsPaginatedParams = {
+ limit: 10000, // Large limit to get all transactions
+ offset: 0,
+ sortField: "date",
+ sortOrder: "asc", // Ascending for balance calculation
+ };
+
+ if (startDate && period !== "all") {
+ params.startDate = startDate.toISOString().split("T")[0];
+ }
+ if (endDate) {
+ params.endDate = endDate.toISOString().split("T")[0];
+ }
+ if (!selectedAccounts.includes("all")) {
+ params.accountIds = selectedAccounts;
+ }
+ if (!selectedCategories.includes("all")) {
+ if (selectedCategories.includes("uncategorized")) {
+ params.includeUncategorized = true;
+ } else {
+ params.categoryIds = selectedCategories;
+ }
+ }
+ if (searchQuery) {
+ params.search = searchQuery;
+ }
+ if (showReconciled !== "all") {
+ params.isReconciled = showReconciled === "reconciled";
+ }
+
+ return params;
+ }, [
+ startDate,
+ endDate,
+ selectedAccounts,
+ selectedCategories,
+ searchQuery,
+ showReconciled,
+ period,
+ ]);
+
+ // Build params for fetching transactions before startDate (for initial balance)
+ const beforeStartDateParams = useMemo(() => {
+ if (period === "all" || !startDate) {
+ return { limit: 0, offset: 0 }; // Don't fetch if no start date
+ }
+
+ const params: TransactionsPaginatedParams = {
+ limit: 10000,
+ offset: 0,
+ sortField: "date",
+ sortOrder: "asc",
+ endDate: new Date(startDate.getTime() - 1).toISOString().split("T")[0], // Day before startDate
+ };
+
+ if (!selectedAccounts.includes("all")) {
+ params.accountIds = selectedAccounts;
+ }
+ if (!selectedCategories.includes("all")) {
+ if (selectedCategories.includes("uncategorized")) {
+ params.includeUncategorized = true;
+ } else {
+ params.categoryIds = selectedCategories;
+ }
+ }
+ if (searchQuery) {
+ params.search = searchQuery;
+ }
+ if (showReconciled !== "all") {
+ params.isReconciled = showReconciled === "reconciled";
+ }
+
+ return params;
+ }, [
+ startDate,
+ selectedAccounts,
+ selectedCategories,
+ searchQuery,
+ showReconciled,
+ period,
+ ]);
+
+ // Fetch transactions before startDate for initial balance calculation
+ const { data: beforeStartDateData } = useTransactions(
+ beforeStartDateParams,
+ !!metadata && period !== "all" && !!startDate
+ );
+
+ // Fetch all filtered transactions for chart
+ const { data: transactionsData, isLoading } = useTransactions(
+ chartParams,
+ !!metadata
+ );
+
+ // Calculate balance chart data
+ const chartData = useMemo(() => {
+ if (!transactionsData || !metadata) {
+ return {
+ aggregatedData: [],
+ perAccountData: [],
+ };
+ }
+
+ const transactions = transactionsData.transactions;
+ const accounts = metadata.accounts;
+
+ // Sort transactions by date
+ const sortedTransactions = [...transactions].sort(
+ (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
+ );
+
+ // Calculate starting balance: initialBalance + transactions before startDate
+ let runningBalance = 0;
+ const accountsToUse = selectedAccounts.includes("all")
+ ? accounts
+ : accounts.filter((acc: Account) => selectedAccounts.includes(acc.id));
+
+ // Start with initial balances
+ runningBalance = accountsToUse.reduce(
+ (sum: number, acc: Account) => sum + (acc.initialBalance || 0),
+ 0
+ );
+
+ // Add transactions before startDate if we have them
+ if (beforeStartDateData?.transactions) {
+ const beforeStartTransactions = beforeStartDateData.transactions.filter(
+ (t) => {
+ const transactionDate = new Date(t.date);
+ return transactionDate < startDate;
+ }
+ );
+ beforeStartTransactions.forEach((t) => {
+ runningBalance += t.amount;
+ });
+ }
+
+ const aggregatedBalanceByDate = new Map();
+
+ // Calculate balance evolution
+ sortedTransactions.forEach((t) => {
+ runningBalance += t.amount;
+ aggregatedBalanceByDate.set(t.date, runningBalance);
+ });
+
+ const aggregatedBalanceData: BalanceChartDataPoint[] = Array.from(
+ aggregatedBalanceByDate.entries()
+ ).map(([date, balance]) => ({
+ date: new Date(date).toLocaleDateString("fr-FR", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ }),
+ solde: Math.round(balance),
+ }));
+
+ // Per account balance calculation
+ const accountBalances = new Map>();
+ accounts.forEach((account: Account) => {
+ accountBalances.set(account.id, new Map());
+ });
+
+ // Calculate running balance per account
+ const accountRunningBalances = new Map();
+ accounts.forEach((account: Account) => {
+ accountRunningBalances.set(account.id, account.initialBalance || 0);
+ });
+
+ // Add transactions before startDate per account
+ if (beforeStartDateData?.transactions) {
+ const beforeStartTransactions = beforeStartDateData.transactions.filter(
+ (t) => {
+ const transactionDate = new Date(t.date);
+ return transactionDate < startDate;
+ }
+ );
+ beforeStartTransactions.forEach((t) => {
+ const currentBalance = accountRunningBalances.get(t.accountId) || 0;
+ accountRunningBalances.set(t.accountId, currentBalance + t.amount);
+ });
+ }
+
+ // Filter transactions by account if needed
+ const transactionsForAccounts = selectedAccounts.includes("all")
+ ? sortedTransactions
+ : sortedTransactions.filter((t) =>
+ selectedAccounts.includes(t.accountId)
+ );
+
+ transactionsForAccounts.forEach((t) => {
+ const currentBalance = accountRunningBalances.get(t.accountId) || 0;
+ const newBalance = currentBalance + t.amount;
+ accountRunningBalances.set(t.accountId, newBalance);
+
+ const accountDates = accountBalances.get(t.accountId);
+ if (accountDates) {
+ accountDates.set(t.date, newBalance);
+ }
+ });
+
+ // Merge all dates and create data points
+ const allDates = new Set();
+ accountBalances.forEach((dates) => {
+ dates.forEach((_, date) => allDates.add(date));
+ });
+
+ const sortedDates = Array.from(allDates).sort();
+ const lastBalances = new Map();
+ accounts.forEach((account: Account) => {
+ // Start with initial balance + transactions before startDate
+ let accountStartingBalance = account.initialBalance || 0;
+ if (beforeStartDateData?.transactions) {
+ const beforeStartTransactions = beforeStartDateData.transactions.filter(
+ (t) => {
+ const transactionDate = new Date(t.date);
+ return transactionDate < startDate && t.accountId === account.id;
+ }
+ );
+ beforeStartTransactions.forEach((t) => {
+ accountStartingBalance += t.amount;
+ });
+ }
+ lastBalances.set(account.id, accountStartingBalance);
+ });
+
+ const perAccountBalanceData: BalanceChartDataPoint[] = sortedDates.map(
+ (date) => {
+ const point: BalanceChartDataPoint = {
+ date: new Date(date).toLocaleDateString("fr-FR", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ }),
+ };
+
+ accounts.forEach((account: Account) => {
+ const accountDates = accountBalances.get(account.id);
+ if (accountDates?.has(date)) {
+ lastBalances.set(account.id, accountDates.get(date)!);
+ }
+ point[account.id] = Math.round(lastBalances.get(account.id) || 0);
+ });
+
+ return point;
+ }
+ );
+
+ return {
+ aggregatedData: aggregatedBalanceData,
+ perAccountData: perAccountBalanceData,
+ };
+ }, [
+ transactionsData,
+ beforeStartDateData,
+ metadata,
+ selectedAccounts,
+ startDate,
+ ]);
+
+ return {
+ aggregatedData: chartData.aggregatedData,
+ perAccountData: chartData.perAccountData,
+ accounts: metadata?.accounts || [],
+ isLoading,
+ };
+}
diff --git a/hooks/use-transactions-chart-data.ts b/hooks/use-transactions-chart-data.ts
new file mode 100644
index 0000000..2358daf
--- /dev/null
+++ b/hooks/use-transactions-chart-data.ts
@@ -0,0 +1,200 @@
+"use client";
+
+import { useMemo } from "react";
+import { useTransactions } from "@/lib/hooks";
+import { useBankingMetadata } from "@/lib/hooks";
+import type { TransactionsPaginatedParams } from "@/services/banking.service";
+import type { Category } from "@/lib/types";
+
+interface MonthlyChartData {
+ month: string;
+ revenus: number;
+ depenses: number;
+ solde: number;
+}
+
+interface CategoryChartData {
+ name: string;
+ value: number;
+ color: string;
+ icon: string;
+ categoryId: string | null;
+}
+
+interface UseTransactionsChartDataParams {
+ selectedAccounts: string[];
+ selectedCategories: string[];
+ period: "1month" | "3months" | "6months" | "12months" | "custom" | "all";
+ customStartDate?: Date;
+ customEndDate?: Date;
+ showReconciled: string;
+ searchQuery: string;
+}
+
+export function useTransactionsChartData({
+ selectedAccounts,
+ selectedCategories,
+ period,
+ customStartDate,
+ customEndDate,
+ showReconciled,
+ searchQuery,
+}: UseTransactionsChartDataParams) {
+ const { data: metadata } = useBankingMetadata();
+
+ // Calculate start date based on period
+ const startDate = useMemo(() => {
+ const now = new Date();
+ switch (period) {
+ case "1month":
+ return new Date(now.getFullYear(), now.getMonth() - 1, 1);
+ case "3months":
+ return new Date(now.getFullYear(), now.getMonth() - 3, 1);
+ case "6months":
+ return new Date(now.getFullYear(), now.getMonth() - 6, 1);
+ case "12months":
+ return new Date(now.getFullYear(), now.getMonth() - 12, 1);
+ case "custom":
+ return customStartDate || new Date(0);
+ default:
+ return new Date(0);
+ }
+ }, [period, customStartDate]);
+
+ // Calculate end date (only for custom period)
+ const endDate = useMemo(() => {
+ if (period === "custom" && customEndDate) {
+ return customEndDate;
+ }
+ return undefined;
+ }, [period, customEndDate]);
+
+ // Build params for fetching all transactions (no pagination)
+ const chartParams = useMemo(() => {
+ const params: TransactionsPaginatedParams = {
+ limit: 10000, // Large limit to get all transactions
+ offset: 0,
+ sortField: "date",
+ sortOrder: "asc",
+ };
+
+ if (startDate && period !== "all") {
+ params.startDate = startDate.toISOString().split("T")[0];
+ }
+ if (endDate) {
+ params.endDate = endDate.toISOString().split("T")[0];
+ }
+ if (!selectedAccounts.includes("all")) {
+ params.accountIds = selectedAccounts;
+ }
+ if (!selectedCategories.includes("all")) {
+ if (selectedCategories.includes("uncategorized")) {
+ params.includeUncategorized = true;
+ } else {
+ params.categoryIds = selectedCategories;
+ }
+ }
+ if (searchQuery) {
+ params.search = searchQuery;
+ }
+ if (showReconciled !== "all") {
+ params.isReconciled = showReconciled === "reconciled";
+ }
+
+ return params;
+ }, [
+ startDate,
+ endDate,
+ selectedAccounts,
+ selectedCategories,
+ searchQuery,
+ showReconciled,
+ period,
+ ]);
+
+ // Fetch all filtered transactions for chart
+ const { data: transactionsData, isLoading } = useTransactions(
+ chartParams,
+ !!metadata
+ );
+
+ // Calculate monthly chart data
+ const monthlyData = useMemo(() => {
+ if (!transactionsData || !metadata) {
+ return [];
+ }
+
+ const transactions = transactionsData.transactions;
+
+ // Monthly breakdown
+ const monthlyMap = new Map();
+ transactions.forEach((t) => {
+ const monthKey = t.date.substring(0, 7);
+ const current = monthlyMap.get(monthKey) || { income: 0, expenses: 0 };
+ if (t.amount >= 0) {
+ current.income += t.amount;
+ } else {
+ current.expenses += Math.abs(t.amount);
+ }
+ monthlyMap.set(monthKey, current);
+ });
+
+ const monthlyChartData: MonthlyChartData[] = Array.from(
+ monthlyMap.entries()
+ )
+ .sort((a, b) => a[0].localeCompare(b[0]))
+ .map(([month, values]) => ({
+ month: new Date(month + "-01").toLocaleDateString("fr-FR", {
+ month: "short",
+ year: "numeric",
+ }),
+ revenus: values.income,
+ depenses: values.expenses,
+ solde: values.income - values.expenses,
+ }));
+
+ return monthlyChartData;
+ }, [transactionsData]);
+
+ // Calculate category chart data (expenses only)
+ const categoryData = useMemo(() => {
+ if (!transactionsData || !metadata) {
+ return [];
+ }
+
+ const transactions = transactionsData.transactions;
+ const categoryTotals = new Map();
+
+ transactions
+ .filter((t) => t.amount < 0)
+ .forEach((t) => {
+ const catId = t.categoryId || "uncategorized";
+ const current = categoryTotals.get(catId) || 0;
+ categoryTotals.set(catId, current + Math.abs(t.amount));
+ });
+
+ const categoryChartData: CategoryChartData[] = Array.from(
+ categoryTotals.entries()
+ )
+ .map(([categoryId, total]) => {
+ const category = metadata.categories.find((c: Category) => c.id === categoryId);
+ return {
+ name: category?.name || "Non catégorisé",
+ value: Math.round(total),
+ color: category?.color || "#94a3b8",
+ icon: category?.icon || "HelpCircle",
+ categoryId: categoryId === "uncategorized" ? null : categoryId,
+ };
+ })
+ .sort((a, b) => b.value - a.value);
+
+ return categoryChartData;
+ }, [transactionsData, metadata]);
+
+ return {
+ monthlyData,
+ categoryData,
+ isLoading,
+ };
+}
+
diff --git a/hooks/use-transactions-page.ts b/hooks/use-transactions-page.ts
index e28551c..8341d4c 100644
--- a/hooks/use-transactions-page.ts
+++ b/hooks/use-transactions-page.ts
@@ -29,7 +29,7 @@ export function useTransactionsPage() {
"all",
]);
const [showReconciled, setShowReconciled] = useState("all");
- const [period, setPeriod] = useState("all");
+ const [period, setPeriod] = useState("3months");
const [customStartDate, setCustomStartDate] = useState(
undefined,
);