Compare commits
4 Commits
53798176a0
...
dbcf8e7abd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbcf8e7abd | ||
|
|
55f0e5c625 | ||
|
|
b4dace0673 | ||
|
|
aa2c656c00 |
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,54 +30,124 @@ 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({
|
||||||
|
data,
|
||||||
|
formatCurrency,
|
||||||
|
collapsible = false,
|
||||||
|
defaultExpanded = true,
|
||||||
|
showDots = true,
|
||||||
|
}: MonthlyChartProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||||
|
|
||||||
|
const chartContent = (
|
||||||
|
<>
|
||||||
|
{data.length > 0 ? (
|
||||||
|
<div className="h-[400px] sm:h-[300px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={data} margin={{ left: 0, right: 10, top: 10, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
className="text-xs"
|
||||||
|
angle={-45}
|
||||||
|
textAnchor="end"
|
||||||
|
height={60}
|
||||||
|
interval={0}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
className="text-xs"
|
||||||
|
width={60}
|
||||||
|
tickFormatter={(v) => {
|
||||||
|
// 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)" }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => formatCurrency(value)}
|
||||||
|
labelFormatter={(label) => label}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "var(--card)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-[400px] sm:h-[300px] flex items-center justify-center text-muted-foreground">
|
||||||
|
Pas de données pour cette période
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!collapsible) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Revenus vs Dépenses par mois</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>{chartContent}</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
|
||||||
<CardTitle>Revenus vs Dépenses par mois</CardTitle>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 py-3 px-6">
|
||||||
</CardHeader>
|
<CardTitle className="text-base font-semibold">
|
||||||
<CardContent>
|
Revenus vs Dépenses par mois
|
||||||
{data.length > 0 ? (
|
</CardTitle>
|
||||||
<div className="h-[300px]">
|
<CollapsibleTrigger asChild>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<Button variant="ghost" size="sm" className="h-8">
|
||||||
<BarChart data={data}>
|
{isExpanded ? (
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
<>
|
||||||
<XAxis dataKey="month" className="text-xs" />
|
<ChevronUp className="w-4 h-4 mr-1" />
|
||||||
<YAxis
|
Réduire
|
||||||
className="text-xs"
|
</>
|
||||||
width={80}
|
) : (
|
||||||
tickFormatter={(v) => {
|
<>
|
||||||
// Format compact pour les grandes valeurs
|
<ChevronDown className="w-4 h-4 mr-1" />
|
||||||
if (Math.abs(v) >= 1000) {
|
Afficher
|
||||||
return `${(v / 1000).toFixed(1)}k€`;
|
</>
|
||||||
}
|
)}
|
||||||
return `${Math.round(v)}€`;
|
</Button>
|
||||||
}}
|
</CollapsibleTrigger>
|
||||||
tick={{ fill: "var(--muted-foreground)" }}
|
</CardHeader>
|
||||||
/>
|
<CollapsibleContent>
|
||||||
<Tooltip
|
<CardContent className="pt-0">{chartContent}</CardContent>
|
||||||
formatter={(value: number) => formatCurrency(value)}
|
</CollapsibleContent>
|
||||||
labelFormatter={(label) => label}
|
</Collapsible>
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: "var(--card)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "8px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Legend />
|
|
||||||
<Bar dataKey="revenus" fill="#22c55e" radius={[4, 4, 0, 0]} />
|
|
||||||
<Bar dataKey="depenses" fill="#ef4444" radius={[4, 4, 0, 0]} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
|
|
||||||
Pas de données pour cette période
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
newSelection = [newValue];
|
if (isParentCategory && childCategories.length > 0) {
|
||||||
|
newSelection = [
|
||||||
|
newValue,
|
||||||
|
...childCategories.map((child) => child.id),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
newSelection = [newValue];
|
||||||
|
}
|
||||||
} else if (value.includes(newValue)) {
|
} else if (value.includes(newValue)) {
|
||||||
// Remove category
|
// Remove category and its children (if parent)
|
||||||
newSelection = value.filter((v) => v !== newValue);
|
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);
|
||||||
|
}
|
||||||
if (newSelection.length === 0) {
|
if (newSelection.length === 0) {
|
||||||
newSelection = ["all"];
|
newSelection = ["all"];
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Add category
|
// Add category and its children (if parent)
|
||||||
newSelection = [...value, newValue];
|
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];
|
||||||
|
} else {
|
||||||
|
// Regular add
|
||||||
|
newSelection = [...value, newValue];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange(newSelection);
|
onChange(newSelection);
|
||||||
|
|||||||
328
hooks/use-transactions-balance-chart.ts
Normal file
328
hooks/use-transactions-balance-chart.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
215
hooks/use-transactions-chart-data.ts
Normal file
215
hooks/use-transactions-chart-data.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user