Service layer - New reportService.getCategoryZoom(categoryId, from, to, includeChildren) — bounded recursive CTE (WHERE ct.depth < 5) protects against parent_id cycles; direct-only path skips the CTE; every binding is parameterised - Export categorizationService helpers normalizeDescription / buildKeywordRegex / compileKeywords so the dialog can reuse them - New validateKeyword() enforces 2–64 char length (anti-ReDoS), whitespace-only rejection, returns discriminated result - New previewKeywordMatches(keyword, limit=50) uses parameterised LIKE + regex filter in memory; caps candidate scan at 1000 rows to protect against catastrophic backtracking - New applyKeywordWithReassignment wraps INSERT (or UPDATE-reassign) + per-transaction UPDATEs in an explicit BEGIN/COMMIT/ROLLBACK; rejects existing keyword reassignment unless allowReplaceExisting is set; never recategorises historical transactions beyond the ids the caller supplied Hook - Flesh out useCategoryZoom with reducer + fetch + refetch hook Components (flat under src/components/reports/) - CategoryZoomHeader — category combobox + include/direct toggle - CategoryDonutChart — template'd from dashboard/CategoryPieChart with innerRadius=55 and ChartPatternDefs for SVG patterns - CategoryEvolutionChart — AreaChart with Intl-formatted axes - CategoryTransactionsTable — sortable table with per-row onContextMenu → ContextMenu → "Add as keyword" action AddKeywordDialog — src/components/categories/AddKeywordDialog.tsx - Lives in categories/ (not reports/) because it is a keyword-editing widget consumed from multiple sections - Renders transaction descriptions as React children only (no dangerouslySetInnerHTML); CSS truncation (CWE-79 safe) - Per-row checkboxes for applying recategorisation; cap visible rows at 50; explicit opt-in checkbox to extend to N-50 non-displayed matches - Surfaces apply errors + "keyword already exists" replace prompt - Re-runs category zoom fetch on success so the zoomed view updates Page - ReportsCategoryPage composes header + donut + evolution + transactions + AddKeywordDialog, fetches from useCategoryZoom, preserves query string for back navigation i18n - New keys reports.category.* and reports.keyword.* in FR + EN - Plural forms use i18next v25 _one / _other suffixes (nMatches) Tests - 3 reportService tests cover bounded CTE, cycle-guard depth check, direct-only fallthrough - New categorizationService.test.ts: 13 tests covering validation boundaries, parameterised LIKE preview, regex word-boundary filter, explicit BEGIN/COMMIT wrapping, rollback on failure, existing keyword reassignment policy - 62 total tests passing Fixes #74 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
82 lines
2.6 KiB
TypeScript
82 lines
2.6 KiB
TypeScript
import { useTranslation } from "react-i18next";
|
|
import {
|
|
AreaChart,
|
|
Area,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
} from "recharts";
|
|
import type { CategoryZoomEvolutionPoint } from "../../shared/types";
|
|
|
|
export interface CategoryEvolutionChartProps {
|
|
data: CategoryZoomEvolutionPoint[];
|
|
color?: string;
|
|
}
|
|
|
|
function formatMonth(month: string): string {
|
|
const [year, m] = month.split("-");
|
|
const date = new Date(Number(year), Number(m) - 1);
|
|
return date.toLocaleDateString("default", { month: "short", year: "2-digit" });
|
|
}
|
|
|
|
export default function CategoryEvolutionChart({
|
|
data,
|
|
color = "var(--primary)",
|
|
}: CategoryEvolutionChartProps) {
|
|
const { t, i18n } = useTranslation();
|
|
|
|
if (data.length === 0) {
|
|
return (
|
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 text-center text-[var(--muted-foreground)] italic">
|
|
{t("reports.empty.noData")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
|
|
<h3 className="text-sm font-semibold mb-2">{t("reports.category.evolution")}</h3>
|
|
<ResponsiveContainer width="100%" height={200}>
|
|
<AreaChart data={data} margin={{ top: 10, right: 20, bottom: 10, left: 10 }}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
|
<XAxis
|
|
dataKey="month"
|
|
tickFormatter={formatMonth}
|
|
stroke="var(--muted-foreground)"
|
|
fontSize={11}
|
|
/>
|
|
<YAxis
|
|
stroke="var(--muted-foreground)"
|
|
fontSize={11}
|
|
tickFormatter={(v: number) =>
|
|
new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
|
|
style: "currency",
|
|
currency: "CAD",
|
|
maximumFractionDigits: 0,
|
|
}).format(v)
|
|
}
|
|
/>
|
|
<Tooltip
|
|
formatter={(value) =>
|
|
typeof value === "number"
|
|
? new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
|
|
style: "currency",
|
|
currency: "CAD",
|
|
}).format(value)
|
|
: String(value)
|
|
}
|
|
labelFormatter={(label) => (typeof label === "string" ? formatMonth(label) : String(label ?? ""))}
|
|
contentStyle={{
|
|
backgroundColor: "var(--card)",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "0.5rem",
|
|
}}
|
|
/>
|
|
<Area type="monotone" dataKey="total" stroke={color} fill={color} fillOpacity={0.2} />
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
}
|