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>
85 lines
2.8 KiB
TypeScript
85 lines
2.8 KiB
TypeScript
import { useReducer, useEffect, useRef, useCallback } from "react";
|
|
import type { CategoryZoomData } from "../shared/types";
|
|
import { getCategoryZoom } from "../services/reportService";
|
|
import { useReportsPeriod } from "./useReportsPeriod";
|
|
|
|
interface State {
|
|
zoomedCategoryId: number | null;
|
|
rollupChildren: boolean;
|
|
data: CategoryZoomData | null;
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
type Action =
|
|
| { type: "SET_CATEGORY"; payload: number | null }
|
|
| { type: "TOGGLE_ROLLUP"; payload: boolean }
|
|
| { type: "SET_LOADING"; payload: boolean }
|
|
| { type: "SET_DATA"; payload: CategoryZoomData }
|
|
| { type: "SET_ERROR"; payload: string };
|
|
|
|
const initialState: State = {
|
|
zoomedCategoryId: null,
|
|
rollupChildren: true,
|
|
data: null,
|
|
isLoading: false,
|
|
error: null,
|
|
};
|
|
|
|
function reducer(state: State, action: Action): State {
|
|
switch (action.type) {
|
|
case "SET_CATEGORY":
|
|
return { ...state, zoomedCategoryId: action.payload, data: null };
|
|
case "TOGGLE_ROLLUP":
|
|
return { ...state, rollupChildren: action.payload };
|
|
case "SET_LOADING":
|
|
return { ...state, isLoading: action.payload };
|
|
case "SET_DATA":
|
|
return { ...state, data: action.payload, isLoading: false, error: null };
|
|
case "SET_ERROR":
|
|
return { ...state, error: action.payload, isLoading: false };
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
export function useCategoryZoom() {
|
|
const { from, to } = useReportsPeriod();
|
|
const [state, dispatch] = useReducer(reducer, initialState);
|
|
const fetchIdRef = useRef(0);
|
|
|
|
const fetch = useCallback(
|
|
async (categoryId: number | null, includeChildren: boolean, dateFrom: string, dateTo: string) => {
|
|
if (categoryId === null) return;
|
|
const id = ++fetchIdRef.current;
|
|
dispatch({ type: "SET_LOADING", payload: true });
|
|
try {
|
|
const data = await getCategoryZoom(categoryId, dateFrom, dateTo, includeChildren);
|
|
if (id !== fetchIdRef.current) return;
|
|
dispatch({ type: "SET_DATA", payload: data });
|
|
} catch (e) {
|
|
if (id !== fetchIdRef.current) return;
|
|
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
useEffect(() => {
|
|
fetch(state.zoomedCategoryId, state.rollupChildren, from, to);
|
|
}, [fetch, state.zoomedCategoryId, state.rollupChildren, from, to]);
|
|
|
|
const setCategory = useCallback((id: number | null) => {
|
|
dispatch({ type: "SET_CATEGORY", payload: id });
|
|
}, []);
|
|
|
|
const setRollupChildren = useCallback((flag: boolean) => {
|
|
dispatch({ type: "TOGGLE_ROLLUP", payload: flag });
|
|
}, []);
|
|
|
|
const refetch = useCallback(() => {
|
|
fetch(state.zoomedCategoryId, state.rollupChildren, from, to);
|
|
}, [fetch, state.zoomedCategoryId, state.rollupChildren, from, to]);
|
|
|
|
return { ...state, setCategory, setRollupChildren, refetch, from, to };
|
|
}
|