Simpl-Resultat/src/pages/ReportsCategoryPage.tsx
le king fu 62430c63dc
Some checks failed
PR Check / rust (push) Has been cancelled
PR Check / frontend (push) Has been cancelled
PR Check / rust (pull_request) Has been cancelled
PR Check / frontend (pull_request) Has been cancelled
feat: category zoom + secure AddKeywordDialog with context menu (#74)
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>
2026-04-14 15:09:17 -04:00

114 lines
3.9 KiB
TypeScript

import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import CategoryZoomHeader from "../components/reports/CategoryZoomHeader";
import CategoryDonutChart from "../components/reports/CategoryDonutChart";
import CategoryEvolutionChart from "../components/reports/CategoryEvolutionChart";
import CategoryTransactionsTable from "../components/reports/CategoryTransactionsTable";
import AddKeywordDialog from "../components/categories/AddKeywordDialog";
import { useCategoryZoom } from "../hooks/useCategoryZoom";
import { useReportsPeriod } from "../hooks/useReportsPeriod";
import type { RecentTransaction } from "../shared/types";
export default function ReportsCategoryPage() {
const { t, i18n } = useTranslation();
const { period, setPeriod, from, to, setCustomDates } = useReportsPeriod();
const {
zoomedCategoryId,
rollupChildren,
data,
isLoading,
error,
setCategory,
setRollupChildren,
refetch,
} = useCategoryZoom();
const [pending, setPending] = useState<RecentTransaction | null>(null);
const preserveSearch = typeof window !== "undefined" ? window.location.search : "";
const centerLabel = t("reports.category.breakdown");
const centerValue = data
? new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
style: "currency",
currency: "CAD",
maximumFractionDigits: 0,
}).format(data.rollupTotal)
: "—";
return (
<div className={isLoading ? "opacity-60" : ""}>
<div className="flex items-center gap-3 mb-4">
<Link
to={`/reports${preserveSearch}`}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] p-1 rounded-md hover:bg-[var(--muted)]"
aria-label={t("reports.hub.title")}
>
<ArrowLeft size={18} />
</Link>
<h1 className="text-2xl font-bold">{t("reports.hub.categoryZoom")}</h1>
</div>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<PeriodSelector
value={period}
onChange={setPeriod}
customDateFrom={from}
customDateTo={to}
onCustomDateChange={setCustomDates}
/>
</div>
<div className="mb-4">
<CategoryZoomHeader
categoryId={zoomedCategoryId}
includeSubcategories={rollupChildren}
onCategoryChange={setCategory}
onIncludeSubcategoriesChange={setRollupChildren}
/>
</div>
{error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{error}
</div>
)}
{zoomedCategoryId === null ? (
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)]">
{t("reports.category.selectCategory")}
</div>
) : data ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<CategoryDonutChart
byChild={data.byChild}
centerLabel={centerLabel}
centerValue={centerValue}
/>
<CategoryEvolutionChart data={data.monthlyEvolution} />
<div className="lg:col-span-2">
<h3 className="text-sm font-semibold mb-2">{t("reports.category.transactions")}</h3>
<CategoryTransactionsTable
transactions={data.transactions}
onAddKeyword={(tx) => setPending(tx)}
/>
</div>
</div>
) : null}
{pending && (
<AddKeywordDialog
initialKeyword={pending.description.split(/\s+/)[0] ?? ""}
initialCategoryId={zoomedCategoryId}
onClose={() => setPending(null)}
onApplied={() => {
setPending(null);
refetch();
}}
/>
)}
</div>
);
}