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>
72 lines
2.3 KiB
TypeScript
72 lines
2.3 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { getAllCategoriesWithCounts } from "../../services/categoryService";
|
|
|
|
interface CategoryOption {
|
|
id: number;
|
|
name: string;
|
|
color: string | null;
|
|
parent_id: number | null;
|
|
}
|
|
|
|
export interface CategoryZoomHeaderProps {
|
|
categoryId: number | null;
|
|
includeSubcategories: boolean;
|
|
onCategoryChange: (id: number | null) => void;
|
|
onIncludeSubcategoriesChange: (flag: boolean) => void;
|
|
}
|
|
|
|
export default function CategoryZoomHeader({
|
|
categoryId,
|
|
includeSubcategories,
|
|
onCategoryChange,
|
|
onIncludeSubcategoriesChange,
|
|
}: CategoryZoomHeaderProps) {
|
|
const { t } = useTranslation();
|
|
const [categories, setCategories] = useState<CategoryOption[]>([]);
|
|
|
|
useEffect(() => {
|
|
getAllCategoriesWithCounts()
|
|
.then((rows) =>
|
|
setCategories(
|
|
rows.map((r) => ({ id: r.id, name: r.name, color: r.color, parent_id: r.parent_id })),
|
|
),
|
|
)
|
|
.catch(() => setCategories([]));
|
|
}, []);
|
|
|
|
return (
|
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3 bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
|
|
<label className="flex flex-col gap-1 flex-1 min-w-0">
|
|
<span className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
|
|
{t("reports.category.selectCategory")}
|
|
</span>
|
|
<select
|
|
value={categoryId ?? ""}
|
|
onChange={(e) => onCategoryChange(e.target.value ? Number(e.target.value) : null)}
|
|
className="bg-[var(--card)] border border-[var(--border)] rounded-lg px-3 py-2 text-sm"
|
|
>
|
|
<option value="">—</option>
|
|
{categories.map((cat) => (
|
|
<option key={cat.id} value={cat.id}>
|
|
{cat.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="inline-flex items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={includeSubcategories}
|
|
onChange={(e) => onIncludeSubcategoriesChange(e.target.checked)}
|
|
className="accent-[var(--primary)]"
|
|
/>
|
|
<span>
|
|
{includeSubcategories
|
|
? t("reports.category.includeSubcategories")
|
|
: t("reports.category.directOnly")}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
);
|
|
}
|