feat: integrate monthly chart with collapsible feature in transactions page; update transaction period to default to last 3 months

This commit is contained in:
Julien Froidefond
2025-12-21 07:31:29 +01:00
parent 53798176a0
commit aa2c656c00
5 changed files with 766 additions and 51 deletions

View File

@@ -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<TransactionsPaginatedParams>(() => {
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<TransactionsPaginatedParams>(() => {
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<string, number>();
// 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<string, Map<string, number>>();
accounts.forEach((account: Account) => {
accountBalances.set(account.id, new Map());
});
// Calculate running balance per account
const accountRunningBalances = new Map<string, number>();
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<string>();
accountBalances.forEach((dates) => {
dates.forEach((_, date) => allDates.add(date));
});
const sortedDates = Array.from(allDates).sort();
const lastBalances = new Map<string, number>();
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,
};
}

View File

@@ -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<TransactionsPaginatedParams>(() => {
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<string, { income: number; expenses: number }>();
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<string, number>();
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,
};
}

View File

@@ -29,7 +29,7 @@ export function useTransactionsPage() {
"all",
]);
const [showReconciled, setShowReconciled] = useState<string>("all");
const [period, setPeriod] = useState<Period>("all");
const [period, setPeriod] = useState<Period>("3months");
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
undefined,
);