feat(reports/trends): add stacked-area chart option for category view (#105) #110
8 changed files with 252 additions and 69 deletions
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
- **Rapport Cartes** : info-bulle d'aide sur le KPI taux d'épargne expliquant la formule — `(revenus − dépenses) ÷ revenus × 100`, calculée sur le mois de référence (#101)
|
- **Rapport Cartes** : info-bulle d'aide sur le KPI taux d'épargne expliquant la formule — `(revenus − dépenses) ÷ revenus × 100`, calculée sur le mois de référence (#101)
|
||||||
|
- **Rapport Tendances — Par catégorie** (`/reports/trends`) : nouveau toggle segmenté pour basculer le graphique d'évolution par catégorie entre les barres empilées (par défaut, inchangé) et une vue surface empilée Recharts (`<AreaChart stackId="1">`) qui montre la composition totale dans le temps. Les deux modes partagent la même palette de catégories et les mêmes patterns SVG en niveaux de gris. Le type choisi est mémorisé dans `localStorage` (`reports-trends-category-charttype`) (#105)
|
||||||
|
|
||||||
### Modifié
|
### Modifié
|
||||||
- **Rapport Zoom catégorie** (`/reports/category`) : le sélecteur de catégorie est désormais un combobox saisissable et filtrable avec recherche insensible aux accents, navigation clavier (↑/↓/Entrée/Échap) et indentation hiérarchique, en remplacement du `<select>` natif (#103)
|
- **Rapport Zoom catégorie** (`/reports/category`) : le sélecteur de catégorie est désormais un combobox saisissable et filtrable avec recherche insensible aux accents, navigation clavier (↑/↓/Entrée/Échap) et indentation hiérarchique, en remplacement du `<select>` natif (#103)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Cartes report**: help tooltip on the savings-rate KPI explaining the formula — `(income − expenses) ÷ income × 100`, computed on the reference month (#101)
|
- **Cartes report**: help tooltip on the savings-rate KPI explaining the formula — `(income − expenses) ÷ income × 100`, computed on the reference month (#101)
|
||||||
|
- **Trends report — by category** (`/reports/trends`): new segmented toggle to switch the category-evolution chart between stacked bars (default, unchanged) and a Recharts stacked-area view (`<AreaChart stackId="1">`) that shows total composition over time. Both modes share the same category palette and SVG grayscale patterns. The chosen type is persisted in `localStorage` (`reports-trends-category-charttype`) (#105)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **Category zoom report** (`/reports/category`): the category picker is now a typeable, searchable combobox with accent-insensitive matching, keyboard navigation (↑/↓/Enter/Esc) and hierarchy indentation, replacing the native `<select>` (#103)
|
- **Category zoom report** (`/reports/category`): the category picker is now a typeable, searchable combobox with accent-insensitive matching, keyboard navigation (↑/↓/Enter/Esc) and hierarchy indentation, replacing the native `<select>` (#103)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
BarChart,
|
BarChart,
|
||||||
Bar,
|
Bar,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
|
@ -25,6 +27,8 @@ function formatMonth(month: string): string {
|
||||||
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
|
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CategoryOverTimeChartType = "bar" | "area";
|
||||||
|
|
||||||
interface CategoryOverTimeChartProps {
|
interface CategoryOverTimeChartProps {
|
||||||
data: CategoryOverTimeData;
|
data: CategoryOverTimeData;
|
||||||
hiddenCategories: Set<string>;
|
hiddenCategories: Set<string>;
|
||||||
|
|
@ -32,6 +36,13 @@ interface CategoryOverTimeChartProps {
|
||||||
onShowAll: () => void;
|
onShowAll: () => void;
|
||||||
onViewDetails: (item: CategoryBreakdownItem) => void;
|
onViewDetails: (item: CategoryBreakdownItem) => void;
|
||||||
showAmounts?: boolean;
|
showAmounts?: boolean;
|
||||||
|
/**
|
||||||
|
* Visual rendering mode. `bar` (default) keeps the legacy stacked-bar
|
||||||
|
* composition. `area` stacks Recharts <Area> layers (stackId="1") for a
|
||||||
|
* smoother flow view. Both modes share the same palette and SVG grayscale
|
||||||
|
* patterns (existing signature visual).
|
||||||
|
*/
|
||||||
|
chartType?: CategoryOverTimeChartType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CategoryOverTimeChart({
|
export default function CategoryOverTimeChart({
|
||||||
|
|
@ -41,6 +52,7 @@ export default function CategoryOverTimeChart({
|
||||||
onShowAll,
|
onShowAll,
|
||||||
onViewDetails,
|
onViewDetails,
|
||||||
showAmounts,
|
showAmounts,
|
||||||
|
chartType = "bar",
|
||||||
}: CategoryOverTimeChartProps) {
|
}: CategoryOverTimeChartProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const hoveredRef = useRef<string | null>(null);
|
const hoveredRef = useRef<string | null>(null);
|
||||||
|
|
@ -68,6 +80,58 @@ export default function CategoryOverTimeChart({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shared chart configuration used by both Bar and Area variants.
|
||||||
|
const patternPrefix = "cat-time";
|
||||||
|
const patternDefs = (
|
||||||
|
<ChartPatternDefs
|
||||||
|
prefix={patternPrefix}
|
||||||
|
categories={categoryEntries.map((c) => ({ color: c.color, index: c.index }))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const commonAxes = (
|
||||||
|
<>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="month"
|
||||||
|
tickFormatter={formatMonth}
|
||||||
|
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||||
|
stroke="var(--border)"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={(v) => cadFormatter(v)}
|
||||||
|
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
||||||
|
stroke="var(--border)"
|
||||||
|
width={80}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: unknown, name: unknown) => {
|
||||||
|
if (hoveredCategory && name !== hoveredCategory) return [null, null];
|
||||||
|
return [cadFormatter(Number(value) || 0), String(name)];
|
||||||
|
}}
|
||||||
|
labelFormatter={(label) => formatMonth(String(label))}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: "var(--card)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||||
|
}}
|
||||||
|
wrapperStyle={{ zIndex: 50 }}
|
||||||
|
labelStyle={{ color: "var(--foreground)" }}
|
||||||
|
itemStyle={{ color: "var(--foreground)" }}
|
||||||
|
filterNull
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (e && e.dataKey) setHoveredCategory(String(e.dataKey));
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setHoveredCategory(null)}
|
||||||
|
wrapperStyle={{ cursor: "pointer" }}
|
||||||
|
formatter={(value) => <span style={{ color: "var(--foreground)" }}>{value}</span>}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||||
{hiddenCategories.size > 0 && (
|
{hiddenCategories.size > 0 && (
|
||||||
|
|
@ -94,73 +158,53 @@ export default function CategoryOverTimeChart({
|
||||||
|
|
||||||
<div onContextMenu={handleContextMenu}>
|
<div onContextMenu={handleContextMenu}>
|
||||||
<ResponsiveContainer width="100%" height={400}>
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
<BarChart data={data.data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
{chartType === "area" ? (
|
||||||
<ChartPatternDefs
|
<AreaChart data={data.data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||||
prefix="cat-time"
|
{patternDefs}
|
||||||
categories={categoryEntries.map((c) => ({ color: c.color, index: c.index }))}
|
{commonAxes}
|
||||||
/>
|
{categoryEntries.map((c) => (
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
<Area
|
||||||
<XAxis
|
key={c.name}
|
||||||
dataKey="month"
|
type="monotone"
|
||||||
tickFormatter={formatMonth}
|
dataKey={c.name}
|
||||||
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
stackId="1"
|
||||||
stroke="var(--border)"
|
stroke={c.color}
|
||||||
/>
|
fill={getPatternFill(patternPrefix, c.index, c.color)}
|
||||||
<YAxis
|
fillOpacity={hoveredCategory === null || hoveredCategory === c.name ? 1 : 0.2}
|
||||||
tickFormatter={(v) => cadFormatter(v)}
|
onMouseEnter={() => { hoveredRef.current = c.name; setHoveredCategory(c.name); }}
|
||||||
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }}
|
||||||
stroke="var(--border)"
|
style={{ transition: "fill-opacity 150ms", cursor: "context-menu" }}
|
||||||
width={80}
|
/>
|
||||||
/>
|
))}
|
||||||
<Tooltip
|
</AreaChart>
|
||||||
formatter={(value: unknown, name: unknown) => {
|
) : (
|
||||||
if (hoveredCategory && name !== hoveredCategory) return [null, null];
|
<BarChart data={data.data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
||||||
return [cadFormatter(Number(value) || 0), String(name)];
|
{patternDefs}
|
||||||
}}
|
{commonAxes}
|
||||||
labelFormatter={(label) => formatMonth(String(label))}
|
{categoryEntries.map((c) => (
|
||||||
contentStyle={{
|
<Bar
|
||||||
backgroundColor: "var(--card)",
|
key={c.name}
|
||||||
border: "1px solid var(--border)",
|
dataKey={c.name}
|
||||||
borderRadius: "8px",
|
stackId="stack"
|
||||||
color: "var(--foreground)",
|
fill={getPatternFill(patternPrefix, c.index, c.color)}
|
||||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
fillOpacity={hoveredCategory === null || hoveredCategory === c.name ? 1 : 0.2}
|
||||||
}}
|
onMouseEnter={() => { hoveredRef.current = c.name; setHoveredCategory(c.name); }}
|
||||||
wrapperStyle={{ zIndex: 50 }}
|
onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }}
|
||||||
labelStyle={{ color: "var(--foreground)" }}
|
cursor="context-menu"
|
||||||
itemStyle={{ color: "var(--foreground)" }}
|
style={{ transition: "fill-opacity 150ms" }}
|
||||||
filterNull
|
>
|
||||||
/>
|
{showAmounts && (
|
||||||
<Legend
|
<LabelList
|
||||||
onMouseEnter={(e) => {
|
dataKey={c.name}
|
||||||
if (e && e.dataKey) setHoveredCategory(String(e.dataKey));
|
position="center"
|
||||||
}}
|
formatter={(v: unknown) => Number(v) ? cadFormatter(Number(v)) : ""}
|
||||||
onMouseLeave={() => setHoveredCategory(null)}
|
style={{ fill: "#000", fontSize: 10, fontWeight: 600, paintOrder: "stroke", stroke: "rgba(255,255,255,0.7)", strokeWidth: 3, strokeLinejoin: "round" }}
|
||||||
wrapperStyle={{ cursor: "pointer" }}
|
/>
|
||||||
formatter={(value) => <span style={{ color: "var(--foreground)" }}>{value}</span>}
|
)}
|
||||||
/>
|
</Bar>
|
||||||
{categoryEntries.map((c) => (
|
))}
|
||||||
<Bar
|
</BarChart>
|
||||||
key={c.name}
|
)}
|
||||||
dataKey={c.name}
|
|
||||||
stackId="stack"
|
|
||||||
fill={getPatternFill("cat-time", c.index, c.color)}
|
|
||||||
fillOpacity={hoveredCategory === null || hoveredCategory === c.name ? 1 : 0.2}
|
|
||||||
onMouseEnter={() => { hoveredRef.current = c.name; setHoveredCategory(c.name); }}
|
|
||||||
onMouseLeave={() => { hoveredRef.current = null; setHoveredCategory(null); }}
|
|
||||||
cursor="context-menu"
|
|
||||||
style={{ transition: "fill-opacity 150ms" }}
|
|
||||||
>
|
|
||||||
{showAmounts && (
|
|
||||||
<LabelList
|
|
||||||
dataKey={c.name}
|
|
||||||
position="center"
|
|
||||||
formatter={(v: unknown) => Number(v) ? cadFormatter(Number(v)) : ""}
|
|
||||||
style={{ fill: "#000", fontSize: 10, fontWeight: 600, paintOrder: "stroke", stroke: "rgba(255,255,255,0.7)", strokeWidth: 3, strokeLinejoin: "round" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Bar>
|
|
||||||
))}
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
55
src/components/reports/TrendsChartTypeToggle.test.ts
Normal file
55
src/components/reports/TrendsChartTypeToggle.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { readTrendsChartType, TRENDS_CHART_TYPE_STORAGE_KEY } from "./TrendsChartTypeToggle";
|
||||||
|
|
||||||
|
describe("readTrendsChartType", () => {
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
const mockLocalStorage = {
|
||||||
|
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||||
|
setItem: vi.fn((key: string, value: string) => {
|
||||||
|
store.set(key, value);
|
||||||
|
}),
|
||||||
|
removeItem: vi.fn((key: string) => {
|
||||||
|
store.delete(key);
|
||||||
|
}),
|
||||||
|
clear: vi.fn(() => store.clear()),
|
||||||
|
key: vi.fn(),
|
||||||
|
length: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store.clear();
|
||||||
|
vi.stubGlobal("localStorage", mockLocalStorage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'bar' fallback when key is missing", () => {
|
||||||
|
expect(readTrendsChartType()).toBe("bar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'bar' when stored value is 'bar'", () => {
|
||||||
|
store.set(TRENDS_CHART_TYPE_STORAGE_KEY, "bar");
|
||||||
|
expect(readTrendsChartType()).toBe("bar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("migrates legacy 'line' stored value to 'bar'", () => {
|
||||||
|
store.set(TRENDS_CHART_TYPE_STORAGE_KEY, "line");
|
||||||
|
expect(readTrendsChartType()).toBe("bar");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 'area' when stored value is 'area'", () => {
|
||||||
|
store.set(TRENDS_CHART_TYPE_STORAGE_KEY, "area");
|
||||||
|
expect(readTrendsChartType()).toBe("area");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores invalid stored values and returns fallback", () => {
|
||||||
|
store.set(TRENDS_CHART_TYPE_STORAGE_KEY, "bogus");
|
||||||
|
expect(readTrendsChartType(TRENDS_CHART_TYPE_STORAGE_KEY, "area")).toBe("area");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects custom fallback when provided", () => {
|
||||||
|
expect(readTrendsChartType(TRENDS_CHART_TYPE_STORAGE_KEY, "area")).toBe("area");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the expected storage key", () => {
|
||||||
|
expect(TRENDS_CHART_TYPE_STORAGE_KEY).toBe("reports-trends-category-charttype");
|
||||||
|
});
|
||||||
|
});
|
||||||
62
src/components/reports/TrendsChartTypeToggle.tsx
Normal file
62
src/components/reports/TrendsChartTypeToggle.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { BarChart3 as BarIcon, AreaChart as AreaIcon } from "lucide-react";
|
||||||
|
import type { CategoryOverTimeChartType } from "./CategoryOverTimeChart";
|
||||||
|
|
||||||
|
export const TRENDS_CHART_TYPE_STORAGE_KEY = "reports-trends-category-charttype";
|
||||||
|
|
||||||
|
export interface TrendsChartTypeToggleProps {
|
||||||
|
value: CategoryOverTimeChartType;
|
||||||
|
onChange: (value: CategoryOverTimeChartType) => void;
|
||||||
|
/** localStorage key used to persist the preference. */
|
||||||
|
storageKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readTrendsChartType(
|
||||||
|
storageKey: string = TRENDS_CHART_TYPE_STORAGE_KEY,
|
||||||
|
fallback: CategoryOverTimeChartType = "bar",
|
||||||
|
): CategoryOverTimeChartType {
|
||||||
|
if (typeof localStorage === "undefined") return fallback;
|
||||||
|
const saved = localStorage.getItem(storageKey);
|
||||||
|
// Back-compat: "line" was the historical key for the bar chart.
|
||||||
|
if (saved === "line") return "bar";
|
||||||
|
return saved === "bar" || saved === "area" ? saved : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrendsChartTypeToggle({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
storageKey = TRENDS_CHART_TYPE_STORAGE_KEY,
|
||||||
|
}: TrendsChartTypeToggleProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (storageKey) localStorage.setItem(storageKey, value);
|
||||||
|
}, [value, storageKey]);
|
||||||
|
|
||||||
|
const options: { type: CategoryOverTimeChartType; icon: React.ReactNode; label: string }[] = [
|
||||||
|
{ type: "bar", icon: <BarIcon size={14} />, label: t("reports.trends.chartBar") },
|
||||||
|
{ type: "area", icon: <AreaIcon size={14} />, label: t("reports.trends.chartArea") },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex gap-1" role="group" aria-label={t("reports.trends.chartTypeAria")}>
|
||||||
|
{options.map(({ type, icon, label }) => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(type)}
|
||||||
|
aria-pressed={value === type}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
value === type
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--card)] border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -360,7 +360,10 @@
|
||||||
"overTime": "Category Over Time",
|
"overTime": "Category Over Time",
|
||||||
"trends": {
|
"trends": {
|
||||||
"subviewGlobal": "Global flow",
|
"subviewGlobal": "Global flow",
|
||||||
"subviewByCategory": "By category"
|
"subviewByCategory": "By category",
|
||||||
|
"chartBar": "Bars",
|
||||||
|
"chartArea": "Stacked area",
|
||||||
|
"chartTypeAria": "Chart type"
|
||||||
},
|
},
|
||||||
"budgetVsActual": "Budget vs Actual",
|
"budgetVsActual": "Budget vs Actual",
|
||||||
"subtotalsOnTop": "Subtotals on top",
|
"subtotalsOnTop": "Subtotals on top",
|
||||||
|
|
|
||||||
|
|
@ -360,7 +360,10 @@
|
||||||
"overTime": "Catégories dans le temps",
|
"overTime": "Catégories dans le temps",
|
||||||
"trends": {
|
"trends": {
|
||||||
"subviewGlobal": "Flux global",
|
"subviewGlobal": "Flux global",
|
||||||
"subviewByCategory": "Par catégorie"
|
"subviewByCategory": "Par catégorie",
|
||||||
|
"chartBar": "Barres",
|
||||||
|
"chartArea": "Surface empilée",
|
||||||
|
"chartTypeAria": "Type de graphique"
|
||||||
},
|
},
|
||||||
"budgetVsActual": "Budget vs Réel",
|
"budgetVsActual": "Budget vs Réel",
|
||||||
"subtotalsOnTop": "Sous-totaux en haut",
|
"subtotalsOnTop": "Sous-totaux en haut",
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,13 @@ import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
||||||
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
|
import MonthlyTrendsTable from "../components/reports/MonthlyTrendsTable";
|
||||||
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
||||||
|
import type { CategoryOverTimeChartType } from "../components/reports/CategoryOverTimeChart";
|
||||||
import CategoryOverTimeTable from "../components/reports/CategoryOverTimeTable";
|
import CategoryOverTimeTable from "../components/reports/CategoryOverTimeTable";
|
||||||
import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle";
|
import ViewModeToggle, { readViewMode, type ViewMode } from "../components/reports/ViewModeToggle";
|
||||||
|
import TrendsChartTypeToggle, {
|
||||||
|
readTrendsChartType,
|
||||||
|
TRENDS_CHART_TYPE_STORAGE_KEY,
|
||||||
|
} from "../components/reports/TrendsChartTypeToggle";
|
||||||
import { useTrends } from "../hooks/useTrends";
|
import { useTrends } from "../hooks/useTrends";
|
||||||
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
import { useReportsPeriod } from "../hooks/useReportsPeriod";
|
||||||
import type { CategoryBreakdownItem } from "../shared/types";
|
import type { CategoryBreakdownItem } from "../shared/types";
|
||||||
|
|
@ -19,6 +24,7 @@ export default function ReportsTrendsPage() {
|
||||||
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
|
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
|
||||||
const { subView, setSubView, monthlyTrends, categoryOverTime, isLoading, error } = useTrends();
|
const { subView, setSubView, monthlyTrends, categoryOverTime, isLoading, error } = useTrends();
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
|
const [viewMode, setViewMode] = useState<ViewMode>(() => readViewMode(STORAGE_KEY));
|
||||||
|
const [chartType, setChartType] = useState<CategoryOverTimeChartType>(() => readTrendsChartType());
|
||||||
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
|
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
|
||||||
|
|
@ -84,6 +90,13 @@ export default function ReportsTrendsPage() {
|
||||||
{t("reports.trends.subviewByCategory")}
|
{t("reports.trends.subviewByCategory")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{subView === "byCategory" && viewMode === "chart" && (
|
||||||
|
<TrendsChartTypeToggle
|
||||||
|
value={chartType}
|
||||||
|
onChange={setChartType}
|
||||||
|
storageKey={TRENDS_CHART_TYPE_STORAGE_KEY}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
|
<ViewModeToggle value={viewMode} onChange={setViewMode} storageKey={STORAGE_KEY} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -107,6 +120,7 @@ export default function ReportsTrendsPage() {
|
||||||
onToggleHidden={toggleHidden}
|
onToggleHidden={toggleHidden}
|
||||||
onShowAll={showAll}
|
onShowAll={showAll}
|
||||||
onViewDetails={noOpDetails}
|
onViewDetails={noOpDetails}
|
||||||
|
chartType={chartType}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CategoryOverTimeTable data={categoryOverTime} hiddenCategories={hiddenCategories} />
|
<CategoryOverTimeTable data={categoryOverTime} hiddenCategories={hiddenCategories} />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue