feat: implement clickable category links in charts and lists; handle uncategorized transactions in URL parameters
This commit is contained in:
@@ -281,6 +281,7 @@ export default function StatisticsPage() {
|
|||||||
value: Math.round(total),
|
value: Math.round(total),
|
||||||
color: category?.color || "#94a3b8",
|
color: category?.color || "#94a3b8",
|
||||||
icon: category?.icon || "HelpCircle",
|
icon: category?.icon || "HelpCircle",
|
||||||
|
categoryId: categoryId === "uncategorized" ? null : categoryId,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.value - a.value);
|
.sort((a, b) => b.value - a.value);
|
||||||
@@ -315,6 +316,7 @@ export default function StatisticsPage() {
|
|||||||
value: Math.round(total),
|
value: Math.round(total),
|
||||||
color: category?.color || "#94a3b8",
|
color: category?.color || "#94a3b8",
|
||||||
icon: category?.icon || "HelpCircle",
|
icon: category?.icon || "HelpCircle",
|
||||||
|
categoryId: groupId === "uncategorized" ? null : groupId,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.value - a.value);
|
.sort((a, b) => b.value - a.value);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +30,48 @@ export function CategoryBarChart({
|
|||||||
}: CategoryBarChartProps) {
|
}: CategoryBarChartProps) {
|
||||||
const displayData = data.slice(0, maxItems).reverse(); // Reverse pour avoir le plus grand en haut
|
const displayData = data.slice(0, maxItems).reverse(); // Reverse pour avoir le plus grand en haut
|
||||||
|
|
||||||
|
// Custom tick component for clickable labels
|
||||||
|
const CustomYAxisTick = ({ x, y, payload }: any) => {
|
||||||
|
const categoryName = payload.value;
|
||||||
|
const item = displayData.find((d) => d.name === categoryName);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
return (
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
fill="var(--muted-foreground)"
|
||||||
|
fontSize={12}
|
||||||
|
textAnchor="end"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
>
|
||||||
|
{categoryName}
|
||||||
|
</text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const href =
|
||||||
|
item.categoryId === null || item.categoryId === undefined
|
||||||
|
? "/transactions?includeUncategorized=true"
|
||||||
|
: `/transactions?categoryIds=${item.categoryId}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={href}>
|
||||||
|
<text
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
fill="var(--muted-foreground)"
|
||||||
|
fontSize={12}
|
||||||
|
className="hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
|
textAnchor="end"
|
||||||
|
dominantBaseline="middle"
|
||||||
|
>
|
||||||
|
{categoryName}
|
||||||
|
</text>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -60,11 +103,7 @@ export function CategoryBarChart({
|
|||||||
dataKey="name"
|
dataKey="name"
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
width={90}
|
width={90}
|
||||||
tick={{ fill: "var(--muted-foreground)" }}
|
tick={CustomYAxisTick}
|
||||||
tickFormatter={(value) => {
|
|
||||||
const item = displayData.find((d) => d.name === value);
|
|
||||||
return item ? value : "";
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={({ active, payload }) => {
|
content={({ active, payload }) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
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 { Button } from "@/components/ui/button";
|
||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||||
@@ -13,7 +14,8 @@ export interface CategoryChartData {
|
|||||||
value: number;
|
value: number;
|
||||||
color: string;
|
color: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
[key: string]: string | number;
|
categoryId?: string | null; // null for "Non catégorisé"
|
||||||
|
[key: string]: string | number | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CategoryPieChartProps {
|
interface CategoryPieChartProps {
|
||||||
@@ -162,24 +164,32 @@ export function CategoryPieChart({
|
|||||||
Légende
|
Légende
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[300px] overflow-y-auto overflow-x-hidden pr-2 space-y-2">
|
<div className="max-h-[300px] overflow-y-auto overflow-x-hidden pr-2 space-y-2">
|
||||||
{currentData.map((item, index) => (
|
{currentData.map((item, index) => {
|
||||||
<div
|
const href =
|
||||||
key={`legend-${index}`}
|
item.categoryId === null || item.categoryId === undefined
|
||||||
className="flex items-center gap-2 text-sm"
|
? "/transactions?includeUncategorized=true"
|
||||||
>
|
: `/transactions?categoryIds=${item.categoryId}`;
|
||||||
<CategoryIcon
|
|
||||||
icon={item.icon}
|
return (
|
||||||
color={item.color}
|
<Link
|
||||||
size={16}
|
key={`legend-${index}`}
|
||||||
/>
|
href={href}
|
||||||
<span className="text-foreground flex-1 min-w-0 truncate">
|
className="flex items-center gap-2 text-sm hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
{item.name}
|
>
|
||||||
</span>
|
<CategoryIcon
|
||||||
<span className="text-foreground font-semibold tabular-nums whitespace-nowrap">
|
icon={item.icon}
|
||||||
{formatCurrency(item.value)}
|
color={item.color}
|
||||||
</span>
|
size={16}
|
||||||
</div>
|
/>
|
||||||
))}
|
<span className="text-foreground flex-1 min-w-0 truncate">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground font-semibold tabular-nums whitespace-nowrap">
|
||||||
|
{formatCurrency(item.value)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { CategoryIcon } from "@/components/ui/category-icon";
|
import { CategoryIcon } from "@/components/ui/category-icon";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -53,24 +54,29 @@ export function TopExpensesList({
|
|||||||
{new Date(expense.date).toLocaleDateString("fr-FR")}
|
{new Date(expense.date).toLocaleDateString("fr-FR")}
|
||||||
</span>
|
</span>
|
||||||
{category && (
|
{category && (
|
||||||
<Badge
|
<Link
|
||||||
variant="secondary"
|
href={`/transactions?categoryIds=${category.id}`}
|
||||||
className="text-[10px] md:text-xs px-1.5 md:px-2 py-0.5 inline-flex items-center gap-1 shrink-0"
|
className="inline-block"
|
||||||
style={{
|
|
||||||
backgroundColor: `${category.color}20`,
|
|
||||||
color: category.color,
|
|
||||||
borderColor: `${category.color}30`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<CategoryIcon
|
<Badge
|
||||||
icon={category.icon}
|
variant="secondary"
|
||||||
color={category.color}
|
className="text-[10px] md:text-xs px-1.5 md:px-2 py-0.5 inline-flex items-center gap-1 shrink-0 hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
size={isMobile ? 8 : 10}
|
style={{
|
||||||
/>
|
backgroundColor: `${category.color}20`,
|
||||||
<span className="truncate max-w-[120px] md:max-w-none">
|
color: category.color,
|
||||||
{category.name}
|
borderColor: `${category.color}30`,
|
||||||
</span>
|
}}
|
||||||
</Badge>
|
>
|
||||||
|
<CategoryIcon
|
||||||
|
icon={category.icon}
|
||||||
|
color={category.color}
|
||||||
|
size={isMobile ? 8 : 10}
|
||||||
|
/>
|
||||||
|
<span className="truncate max-w-[120px] md:max-w-none">
|
||||||
|
{category.name}
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,6 +69,21 @@ export function useTransactionsPage() {
|
|||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
|
// Handle categoryIds and includeUncategorized from URL params
|
||||||
|
useEffect(() => {
|
||||||
|
const categoryIdsParam = searchParams.get("categoryIds");
|
||||||
|
const includeUncategorizedParam = searchParams.get("includeUncategorized");
|
||||||
|
|
||||||
|
if (categoryIdsParam) {
|
||||||
|
const categoryIds = categoryIdsParam.split(",");
|
||||||
|
setSelectedCategories(categoryIds);
|
||||||
|
setPage(0);
|
||||||
|
} else if (includeUncategorizedParam === "true") {
|
||||||
|
setSelectedCategories(["uncategorized"]);
|
||||||
|
setPage(0);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
// Calculate start date based on period
|
// Calculate start date based on period
|
||||||
const startDate = useMemo(() => {
|
const startDate = useMemo(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|||||||
Reference in New Issue
Block a user