Compare commits

...

4 Commits

7 changed files with 944 additions and 66 deletions

View File

@@ -1,8 +1,15 @@
"use client"; "use client";
import { useCallback } from "react"; import { useCallback, useState } from "react";
import { PageLayout, PageHeader } from "@/components/layout"; import { PageLayout, PageHeader } from "@/components/layout";
import { RefreshCw } from "lucide-react"; import {
RefreshCw,
Maximize2,
Minimize2,
Receipt,
Euro,
ChevronDown,
} from "lucide-react";
import { import {
TransactionFilters, TransactionFilters,
TransactionBulkActions, TransactionBulkActions,
@@ -13,15 +20,30 @@ 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 { MonthlyChart } from "@/components/statistics";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Upload } from "lucide-react"; import { Upload } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useTransactionsPage } from "@/hooks/use-transactions-page"; import { useTransactionsPage } from "@/hooks/use-transactions-page";
import { useTransactionMutations } from "@/hooks/use-transaction-mutations"; import { useTransactionMutations } from "@/hooks/use-transaction-mutations";
import { useTransactionRules } from "@/hooks/use-transaction-rules"; import { useTransactionRules } from "@/hooks/use-transaction-rules";
import { useTransactionsChartData } from "@/hooks/use-transactions-chart-data";
export default function TransactionsPage() { export default function TransactionsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isFullscreen, setIsFullscreen] = useState(false);
// Main page state and logic // Main page state and logic
const { const {
@@ -88,6 +110,22 @@ export default function TransactionsPage() {
metadata, metadata,
}); });
// Chart data
const {
monthlyData,
isLoading: isLoadingChart,
totalAmount: chartTotalAmount,
totalCount: chartTotalCount,
} = useTransactionsChartData({
selectedAccounts,
selectedCategories,
period,
customStartDate,
customEndDate,
showReconciled,
searchQuery,
});
const invalidateAll = useCallback(() => { const invalidateAll = useCallback(() => {
invalidateTransactions(); invalidateTransactions();
queryClient.invalidateQueries({ queryKey: ["banking-metadata"] }); queryClient.invalidateQueries({ queryKey: ["banking-metadata"] });
@@ -98,7 +136,7 @@ export default function TransactionsPage() {
handleBulkReconcile(reconciled, selectedTransactions); handleBulkReconcile(reconciled, selectedTransactions);
clearSelection(); clearSelection();
}, },
[handleBulkReconcile, selectedTransactions, clearSelection], [handleBulkReconcile, selectedTransactions, clearSelection]
); );
const handleBulkSetCategoryWithClear = useCallback( const handleBulkSetCategoryWithClear = useCallback(
@@ -106,7 +144,7 @@ export default function TransactionsPage() {
handleBulkSetCategory(categoryId, selectedTransactions); handleBulkSetCategory(categoryId, selectedTransactions);
clearSelection(); clearSelection();
}, },
[handleBulkSetCategory, selectedTransactions, clearSelection], [handleBulkSetCategory, selectedTransactions, clearSelection]
); );
const filteredTransactions = transactionsData?.transactions || []; const filteredTransactions = transactionsData?.transactions || [];
@@ -118,6 +156,10 @@ export default function TransactionsPage() {
? Math.round((uncategorizedCount / totalTransactions) * 100) ? Math.round((uncategorizedCount / totalTransactions) * 100)
: 0; : 0;
// Use total from chart data (all filtered transactions) or fallback to paginated data
const totalAmount = chartTotalAmount ?? 0;
const displayTotalCount = chartTotalCount ?? totalTransactions;
// For filter comboboxes, we'll use empty arrays for now // For filter comboboxes, we'll use empty arrays for now
// They can be enhanced later with separate queries if needed // They can be enhanced later with separate queries if needed
const transactionsForAccountFilter: never[] = []; const transactionsForAccountFilter: never[] = [];
@@ -179,6 +221,82 @@ export default function TransactionsPage() {
transactionsForCategoryFilter={transactionsForCategoryFilter} transactionsForCategoryFilter={transactionsForCategoryFilter}
/> />
{(!isLoadingChart || !isLoadingTransactions) && (
<Card className="mb-6">
<Collapsible defaultOpen={true}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 py-3 px-6">
<CardTitle className="text-base font-semibold">
Statistiques
</CardTitle>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8">
<ChevronDown className="w-4 h-4 mr-1" />
Réduire
</Button>
</CollapsibleTrigger>
</CardHeader>
<CollapsibleContent>
<CardContent className="pt-0">
{/* Summary cards */}
{!isLoadingTransactions && (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 mb-6">
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
Nombre de transactions
</p>
<p className="text-2xl font-bold mt-1">
{displayTotalCount}
</p>
</div>
<Receipt className="w-8 h-8 text-muted-foreground" />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
Total
</p>
<p
className={`text-2xl font-bold mt-1 ${
totalAmount >= 0
? "text-emerald-600"
: "text-red-600"
}`}
>
{formatCurrency(totalAmount)}
</p>
</div>
<Euro className="w-8 h-8 text-muted-foreground" />
</div>
</CardContent>
</Card>
</div>
)}
{/* Chart */}
{!isLoadingChart && monthlyData.length > 0 && (
<MonthlyChart
data={monthlyData}
formatCurrency={formatCurrency}
collapsible={false}
showDots={
period !== "all" &&
(period === "12months" || monthlyData.length <= 12)
}
/>
)}
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
)}
<TransactionBulkActions <TransactionBulkActions
selectedCount={selectedTransactions.size} selectedCount={selectedTransactions.size}
categories={metadata.categories} categories={metadata.categories}
@@ -192,6 +310,19 @@ export default function TransactionsPage() {
</div> </div>
) : ( ) : (
<> <>
<div className="flex items-center justify-between mb-4">
<div className="flex-1" />
<Button
variant="outline"
size="sm"
onClick={() => setIsFullscreen(true)}
className="gap-2"
>
<Maximize2 className="w-4 h-4" />
Plein écran
</Button>
</div>
<TransactionTable <TransactionTable
transactions={filteredTransactions} transactions={filteredTransactions}
accounts={metadata.accounts} accounts={metadata.accounts}
@@ -224,6 +355,74 @@ export default function TransactionsPage() {
</> </>
)} )}
<Dialog open={isFullscreen} onOpenChange={setIsFullscreen}>
<DialogContent
className="!max-w-none !max-h-none !w-full !h-full !p-0 flex flex-col !rounded-none !translate-x-0 !translate-y-0 !top-0 !left-0 !right-0 !bottom-0 !m-0"
showCloseButton={false}
style={{
width: "100vw",
height: "100vh",
maxWidth: "100vw",
maxHeight: "100vh",
}}
>
<DialogHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<div className="flex items-center justify-between">
<DialogTitle>Transactions</DialogTitle>
<Button
variant="ghost"
size="sm"
onClick={() => setIsFullscreen(false)}
className="gap-2"
>
<Minimize2 className="w-4 h-4" />
Réduire
</Button>
</div>
</DialogHeader>
<div className="flex-1 overflow-auto px-6 pb-6 min-h-0">
<div className="mb-4">
<TransactionBulkActions
selectedCount={selectedTransactions.size}
categories={metadata.categories}
onReconcile={handleBulkReconcileWithClear}
onSetCategory={handleBulkSetCategoryWithClear}
/>
</div>
<TransactionTable
transactions={filteredTransactions}
accounts={metadata.accounts}
categories={metadata.categories}
selectedTransactions={selectedTransactions}
sortField={sortField}
sortOrder={sortOrder}
onSortChange={onSortChange}
onToggleSelectAll={onToggleSelectAll}
onToggleSelectTransaction={onToggleSelectTransaction}
onToggleReconciled={toggleReconciled}
onMarkReconciled={markReconciled}
onSetCategory={setCategory}
onCreateRule={handleCreateRule}
onDelete={deleteTransaction}
formatCurrency={formatCurrency}
formatDate={formatDate}
updatingTransactionIds={updatingTransactionIds}
duplicateIds={duplicateIds}
highlightDuplicates={showDuplicates}
/>
<div className="mt-4">
<TransactionPagination
page={page}
pageSize={pageSize}
total={totalTransactions}
hasMore={hasMore}
onPageChange={onPageChange}
/>
</div>
</div>
</DialogContent>
</Dialog>
<RuleCreateDialog <RuleCreateDialog
open={ruleDialogOpen} open={ruleDialogOpen}
onOpenChange={setRuleDialogOpen} onOpenChange={setRuleDialogOpen}

View File

@@ -57,10 +57,10 @@ export function OverviewCards({ data }: OverviewCardsProps) {
}; };
return ( return (
<div className="grid gap-4 sm:gap-6 grid-cols-2 lg:grid-cols-5"> <div className="grid gap-4 sm:gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-5">
<Card className="stat-card-gradient-1 card-hover group relative overflow-hidden"> <Card className="stat-card-gradient-1 card-hover group relative overflow-hidden">
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4 px-6 pt-6 sm:px-7 sm:pt-7 lg:px-5 lg:pt-5 relative z-10"> <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4 px-6 pt-6 sm:px-7 sm:pt-7 lg:px-5 lg:pt-5 relative z-10">
<CardTitle className="text-xs font-bold text-muted-foreground/70 leading-tight uppercase tracking-widest"> <CardTitle className="text-xs font-bold text-muted-foreground/70 leading-tight uppercase tracking-widest flex-1 min-w-0 pr-2">
Solde Total Solde Total
</CardTitle> </CardTitle>
<div className="rounded-2xl bg-gradient-to-br from-primary/30 via-primary/20 to-primary/10 p-3 shrink-0 shadow-lg shadow-primary/20"> <div className="rounded-2xl bg-gradient-to-br from-primary/30 via-primary/20 to-primary/10 p-3 shrink-0 shadow-lg shadow-primary/20">
@@ -86,7 +86,7 @@ export function OverviewCards({ data }: OverviewCardsProps) {
<Card className="stat-card-gradient-2 card-hover group relative overflow-hidden"> <Card className="stat-card-gradient-2 card-hover group relative overflow-hidden">
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4 px-6 pt-6 sm:px-7 sm:pt-7 lg:px-5 lg:pt-5 relative z-10"> <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4 px-6 pt-6 sm:px-7 sm:pt-7 lg:px-5 lg:pt-5 relative z-10">
<CardTitle className="text-xs font-bold text-muted-foreground/70 leading-tight uppercase tracking-widest"> <CardTitle className="text-xs font-bold text-muted-foreground/70 leading-tight uppercase tracking-widest flex-1 min-w-0 pr-2">
Revenus du mois Revenus du mois
</CardTitle> </CardTitle>
<div className="rounded-2xl bg-gradient-to-br from-success/30 via-success/20 to-success/10 p-3 shrink-0 shadow-lg shadow-success/20"> <div className="rounded-2xl bg-gradient-to-br from-success/30 via-success/20 to-success/10 p-3 shrink-0 shadow-lg shadow-success/20">
@@ -108,7 +108,7 @@ export function OverviewCards({ data }: OverviewCardsProps) {
<Card className="stat-card-gradient-3 card-hover group relative overflow-hidden"> <Card className="stat-card-gradient-3 card-hover group relative overflow-hidden">
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4 px-6 pt-6 sm:px-7 sm:pt-7 lg:px-5 lg:pt-5 relative z-10"> <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4 px-6 pt-6 sm:px-7 sm:pt-7 lg:px-5 lg:pt-5 relative z-10">
<CardTitle className="text-xs font-bold text-muted-foreground/70 leading-tight uppercase tracking-widest"> <CardTitle className="text-xs font-bold text-muted-foreground/70 leading-tight uppercase tracking-widest flex-1 min-w-0 pr-2">
Dépenses du mois Dépenses du mois
</CardTitle> </CardTitle>
<div className="rounded-2xl bg-gradient-to-br from-destructive/30 via-destructive/20 to-destructive/10 p-3 shrink-0 shadow-lg shadow-destructive/20"> <div className="rounded-2xl bg-gradient-to-br from-destructive/30 via-destructive/20 to-destructive/10 p-3 shrink-0 shadow-lg shadow-destructive/20">
@@ -130,7 +130,7 @@ export function OverviewCards({ data }: OverviewCardsProps) {
<Card className="stat-card-gradient-4 card-hover group relative overflow-hidden"> <Card className="stat-card-gradient-4 card-hover group relative overflow-hidden">
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4 px-6 pt-6 sm:px-7 sm:pt-7 lg:px-5 lg:pt-5 relative z-10"> <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4 px-6 pt-6 sm:px-7 sm:pt-7 lg:px-5 lg:pt-5 relative z-10">
<CardTitle className="text-xs font-bold text-muted-foreground/70 leading-tight uppercase tracking-widest"> <CardTitle className="text-xs font-bold text-muted-foreground/70 leading-tight uppercase tracking-widest flex-1 min-w-0 pr-2">
Pointage Pointage
</CardTitle> </CardTitle>
<div className="rounded-2xl bg-gradient-to-br from-chart-4/30 via-chart-4/20 to-chart-4/10 p-3 shrink-0 shadow-lg shadow-chart-4/20"> <div className="rounded-2xl bg-gradient-to-br from-chart-4/30 via-chart-4/20 to-chart-4/10 p-3 shrink-0 shadow-lg shadow-chart-4/20">
@@ -149,7 +149,7 @@ export function OverviewCards({ data }: OverviewCardsProps) {
<Card className="stat-card-gradient-5 card-hover group relative overflow-hidden"> <Card className="stat-card-gradient-5 card-hover group relative overflow-hidden">
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4 px-6 pt-6 sm:px-7 sm:pt-7 lg:px-5 lg:pt-5 relative z-10"> <CardHeader className="flex flex-row items-start justify-between space-y-0 pb-4 px-6 pt-6 sm:px-7 sm:pt-7 lg:px-5 lg:pt-5 relative z-10">
<CardTitle className="text-xs font-bold text-muted-foreground/70 leading-tight uppercase tracking-widest"> <CardTitle className="text-xs font-bold text-muted-foreground/70 leading-tight uppercase tracking-widest flex-1 min-w-0 pr-2">
Catégorisation Catégorisation
</CardTitle> </CardTitle>
<div className="rounded-2xl bg-gradient-to-br from-chart-5/30 via-chart-5/20 to-chart-5/10 p-3 shrink-0 shadow-lg shadow-chart-5/20"> <div className="rounded-2xl bg-gradient-to-br from-chart-5/30 via-chart-5/20 to-chart-5/10 p-3 shrink-0 shadow-lg shadow-chart-5/20">

View File

@@ -1,9 +1,12 @@
"use client"; "use client";
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ChevronDown, ChevronUp } from "lucide-react";
import { import {
BarChart, LineChart,
Bar, Line,
XAxis, XAxis,
YAxis, YAxis,
CartesianGrid, CartesianGrid,
@@ -11,6 +14,11 @@ import {
ResponsiveContainer, ResponsiveContainer,
Legend, Legend,
} from "recharts"; } from "recharts";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
interface MonthlyChartData { interface MonthlyChartData {
month: string; month: string;
@@ -22,30 +30,44 @@ interface MonthlyChartData {
interface MonthlyChartProps { interface MonthlyChartProps {
data: MonthlyChartData[]; data: MonthlyChartData[];
formatCurrency: (amount: number) => string; formatCurrency: (amount: number) => string;
collapsible?: boolean;
defaultExpanded?: boolean;
showDots?: boolean;
} }
export function MonthlyChart({ data, formatCurrency }: MonthlyChartProps) { export function MonthlyChart({
return ( data,
<Card> formatCurrency,
<CardHeader> collapsible = false,
<CardTitle>Revenus vs Dépenses par mois</CardTitle> defaultExpanded = true,
</CardHeader> showDots = true,
<CardContent> }: MonthlyChartProps) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const chartContent = (
<>
{data.length > 0 ? ( {data.length > 0 ? (
<div className="h-[300px]"> <div className="h-[400px] sm:h-[300px]">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={data}> <LineChart data={data} margin={{ left: 0, right: 10, top: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" /> <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="month" className="text-xs" /> <XAxis
dataKey="month"
className="text-xs"
angle={-45}
textAnchor="end"
height={60}
interval={0}
/>
<YAxis <YAxis
className="text-xs" className="text-xs"
width={80} width={60}
tickFormatter={(v) => { tickFormatter={(v) => {
// Format compact pour les grandes valeurs // Format compact pour les grandes valeurs
if (Math.abs(v) >= 1000) { if (Math.abs(v) >= 1000) {
return `${(v / 1000).toFixed(1)}k€`; return `${(v / 1000).toFixed(1)}k€`;
} }
return `${Math.round(v)}`; return `${v.toFixed(0)}`;
}} }}
tick={{ fill: "var(--muted-foreground)" }} tick={{ fill: "var(--muted-foreground)" }}
/> />
@@ -59,17 +81,73 @@ export function MonthlyChart({ data, formatCurrency }: MonthlyChartProps) {
}} }}
/> />
<Legend /> <Legend />
<Bar dataKey="revenus" fill="#22c55e" radius={[4, 4, 0, 0]} /> <Line
<Bar dataKey="depenses" fill="#ef4444" radius={[4, 4, 0, 0]} /> type="monotone"
</BarChart> dataKey="revenus"
name="Revenus"
stroke="#22c55e"
strokeWidth={2}
dot={showDots ? { fill: "#22c55e", r: 4 } : false}
activeDot={showDots ? { r: 6 } : false}
/>
<Line
type="monotone"
dataKey="depenses"
name="Dépenses"
stroke="#ef4444"
strokeWidth={2}
dot={showDots ? { fill: "#ef4444", r: 4 } : false}
activeDot={showDots ? { r: 6 } : false}
/>
</LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
) : ( ) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground"> <div className="h-[400px] sm:h-[300px] flex items-center justify-center text-muted-foreground">
Pas de données pour cette période Pas de données pour cette période
</div> </div>
)} )}
</CardContent> </>
);
if (!collapsible) {
return (
<Card>
<CardHeader>
<CardTitle>Revenus vs Dépenses par mois</CardTitle>
</CardHeader>
<CardContent>{chartContent}</CardContent>
</Card>
);
}
return (
<Card>
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 py-3 px-6">
<CardTitle className="text-base font-semibold">
Revenus vs Dépenses par mois
</CardTitle>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8">
{isExpanded ? (
<>
<ChevronUp className="w-4 h-4 mr-1" />
Réduire
</>
) : (
<>
<ChevronDown className="w-4 h-4 mr-1" />
Afficher
</>
)}
</Button>
</CollapsibleTrigger>
</CardHeader>
<CollapsibleContent>
<CardContent className="pt-0">{chartContent}</CardContent>
</CollapsibleContent>
</Collapsible>
</Card> </Card>
); );
} }

View File

@@ -87,21 +87,58 @@ export function CategoryFilterCombobox({
return; return;
} }
// Check if this is a parent category
const isParentCategory = parentCategories.some((p) => p.id === newValue);
const childCategories = isParentCategory
? childrenByParent[newValue] || []
: [];
// Category selection - toggle // Category selection - toggle
let newSelection: string[]; let newSelection: string[];
if (isAll || isUncategorized) { if (isAll || isUncategorized) {
// Start fresh with just this category // Start fresh with this category and its children (if parent)
if (isParentCategory && childCategories.length > 0) {
newSelection = [
newValue,
...childCategories.map((child) => child.id),
];
} else {
newSelection = [newValue]; newSelection = [newValue];
}
} else if (value.includes(newValue)) { } else if (value.includes(newValue)) {
// Remove category // Remove category and its children (if parent)
if (isParentCategory && childCategories.length > 0) {
const childIds = childCategories.map((child) => child.id);
newSelection = value.filter(
(v) => v !== newValue && !childIds.includes(v)
);
} else {
newSelection = value.filter((v) => v !== newValue); newSelection = value.filter((v) => v !== newValue);
}
if (newSelection.length === 0) { if (newSelection.length === 0) {
newSelection = ["all"]; newSelection = ["all"];
} }
} else { } else {
// Add category // Add category and its children (if parent)
if (isParentCategory && childCategories.length > 0) {
const childIds = childCategories.map((child) => child.id);
newSelection = [
...value.filter((v) => !childIds.includes(v)), // Remove any existing children
newValue,
...childIds,
];
} else {
// Check if this child's parent is already selected
const category = categories.find((c) => c.id === newValue);
if (category?.parentId && value.includes(category.parentId)) {
// Parent is selected, so we're adding a child - keep parent
newSelection = [...value, newValue]; newSelection = [...value, newValue];
} else {
// Regular add
newSelection = [...value, newValue];
}
}
} }
onChange(newSelection); onChange(newSelection);

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,215 @@
"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]);
// Calculate total amount and count from all filtered transactions
const totalAmount = useMemo(() => {
if (!transactionsData) return 0;
return transactionsData.transactions.reduce(
(sum, t) => sum + t.amount,
0
);
}, [transactionsData]);
const totalCount = useMemo(() => {
return transactionsData?.total || 0;
}, [transactionsData?.total]);
return {
monthlyData,
categoryData,
isLoading,
totalAmount,
totalCount,
};
}

View File

@@ -8,6 +8,7 @@ import {
useDuplicateIds, useDuplicateIds,
} from "@/lib/hooks"; } from "@/lib/hooks";
import type { TransactionsPaginatedParams } from "@/services/banking.service"; import type { TransactionsPaginatedParams } from "@/services/banking.service";
import type { Category } from "@/lib/types";
type SortField = "date" | "amount" | "description"; type SortField = "date" | "amount" | "description";
type SortOrder = "asc" | "desc"; type SortOrder = "asc" | "desc";
@@ -29,7 +30,7 @@ export function useTransactionsPage() {
"all", "all",
]); ]);
const [showReconciled, setShowReconciled] = useState<string>("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>( const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
undefined, undefined,
); );
@@ -74,15 +75,35 @@ export function useTransactionsPage() {
const categoryIdsParam = searchParams.get("categoryIds"); const categoryIdsParam = searchParams.get("categoryIds");
const includeUncategorizedParam = searchParams.get("includeUncategorized"); const includeUncategorizedParam = searchParams.get("includeUncategorized");
if (categoryIdsParam) { if (categoryIdsParam && metadata) {
const categoryIds = categoryIdsParam.split(","); const categoryIds = categoryIdsParam.split(",");
setSelectedCategories(categoryIds);
// Expand parent categories to include their children
const expandedCategoryIds = new Set<string>(categoryIds);
categoryIds.forEach((categoryId) => {
// Check if this is a parent category
const category = metadata.categories.find(
(c: Category) => c.id === categoryId
);
if (category && category.parentId === null) {
// Find all children of this parent
const children = metadata.categories.filter(
(c: Category) => c.parentId === categoryId
);
children.forEach((child: Category) => {
expandedCategoryIds.add(child.id);
});
}
});
setSelectedCategories(Array.from(expandedCategoryIds));
setPage(0); setPage(0);
} else if (includeUncategorizedParam === "true") { } else if (includeUncategorizedParam === "true") {
setSelectedCategories(["uncategorized"]); setSelectedCategories(["uncategorized"]);
setPage(0); setPage(0);
} }
}, [searchParams]); }, [searchParams, metadata]);
// Calculate start date based on period // Calculate start date based on period
const startDate = useMemo(() => { const startDate = useMemo(() => {