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 && ( +
+ +
+ )} + ) : ( <> +
+
+ +
+ )} + + + +
+ Transactions + +
+
+
+
+ +
+ +
+ +
+
+
+
+ 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, );