Simpl-Resultat/src/components/dashboard/CategoryPieChart.tsx
Le-King-Fu 29a1a15120
Some checks failed
Release / build (windows-latest) (push) Has been cancelled
feat: add chart patterns, context menu, and import preview popup (v0.2.3)
- Add SVG fill patterns to differentiate chart categories beyond color
- Add right-click context menu on charts to hide categories or view transactions
- Add transaction detail modal showing all transactions for a category
- Change import preview from wizard step to popup modal
- Add direct "Check Duplicates" button skipping preview step
- Bump version to 0.2.3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 23:55:19 +00:00

151 lines
5.6 KiB
TypeScript

import { useState, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
import { Eye } from "lucide-react";
import type { CategoryBreakdownItem } from "../../shared/types";
import { ChartPatternDefs, getPatternFill, PatternSwatch } from "../../utils/chartPatterns";
import ChartContextMenu from "../shared/ChartContextMenu";
interface CategoryPieChartProps {
data: CategoryBreakdownItem[];
hiddenCategories: Set<string>;
onToggleHidden: (categoryName: string) => void;
onShowAll: () => void;
onViewDetails: (item: CategoryBreakdownItem) => void;
}
export default function CategoryPieChart({
data,
hiddenCategories,
onToggleHidden,
onShowAll,
onViewDetails,
}: CategoryPieChartProps) {
const { t } = useTranslation();
const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null);
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
const total = visibleData.reduce((sum, d) => sum + d.total, 0);
const handleContextMenu = useCallback((e: React.MouseEvent) => {
if (!hoveredRef.current) return;
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, item: hoveredRef.current });
}, []);
if (data.length === 0) {
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<h2 className="text-lg font-semibold mb-4">{t("dashboard.expensesByCategory")}</h2>
<p className="text-center text-[var(--muted-foreground)] py-8">{t("dashboard.noData")}</p>
</div>
);
}
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<h2 className="text-lg font-semibold mb-4">{t("dashboard.expensesByCategory")}</h2>
{hiddenCategories.size > 0 && (
<div className="flex flex-wrap items-center gap-2 mb-3">
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
{Array.from(hiddenCategories).map((name) => (
<button
key={name}
onClick={() => onToggleHidden(name)}
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors"
>
<Eye size={12} />
{name}
</button>
))}
<button
onClick={onShowAll}
className="text-xs text-[var(--primary)] hover:underline"
>
{t("charts.showAll")}
</button>
</div>
)}
<div onContextMenu={handleContextMenu}>
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<ChartPatternDefs
prefix="cat-pie"
categories={visibleData.map((item, index) => ({ color: item.category_color, index }))}
/>
<Pie
data={visibleData}
dataKey="total"
nameKey="category_name"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={100}
paddingAngle={2}
>
{visibleData.map((item, index) => (
<Cell
key={index}
fill={getPatternFill("cat-pie", index, item.category_color)}
onMouseEnter={() => { hoveredRef.current = item; }}
onMouseLeave={() => { hoveredRef.current = null; }}
cursor="context-menu"
/>
))}
</Pie>
<Tooltip
formatter={(value) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value))
}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
}}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2">
{data.map((item, index) => {
const isHidden = hiddenCategories.has(item.category_name);
return (
<button
key={index}
className={`flex items-center gap-1.5 text-sm ${isHidden ? "opacity-40" : ""}`}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, item });
}}
onClick={() => isHidden ? onToggleHidden(item.category_name) : undefined}
title={isHidden ? t("charts.clickToShow") : undefined}
>
<PatternSwatch index={index} color={item.category_color} prefix="cat-pie" />
<span className="text-[var(--muted-foreground)]">
{item.category_name} {total > 0 && !isHidden ? `${Math.round((item.total / total) * 100)}%` : ""}
</span>
</button>
);
})}
</div>
{contextMenu && (
<ChartContextMenu
x={contextMenu.x}
y={contextMenu.y}
categoryName={contextMenu.item.category_name}
onHide={() => onToggleHidden(contextMenu.item.category_name)}
onViewDetails={() => onViewDetails(contextMenu.item)}
onClose={() => setContextMenu(null)}
/>
)}
</div>
);
}