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>
114 lines
3.9 KiB
TypeScript
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>
|
|
);
|
|
}
|