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>
135 lines
4.5 KiB
TypeScript
135 lines
4.5 KiB
TypeScript
import { useState, useMemo } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Tag } from "lucide-react";
|
|
import type { RecentTransaction } from "../../shared/types";
|
|
import ContextMenu from "../shared/ContextMenu";
|
|
|
|
export interface CategoryTransactionsTableProps {
|
|
transactions: RecentTransaction[];
|
|
onAddKeyword?: (transaction: RecentTransaction) => void;
|
|
}
|
|
|
|
type SortKey = "date" | "description" | "amount";
|
|
|
|
function formatAmount(amount: number, language: string): string {
|
|
return new Intl.NumberFormat(language === "fr" ? "fr-CA" : "en-CA", {
|
|
style: "currency",
|
|
currency: "CAD",
|
|
}).format(amount);
|
|
}
|
|
|
|
export default function CategoryTransactionsTable({
|
|
transactions,
|
|
onAddKeyword,
|
|
}: CategoryTransactionsTableProps) {
|
|
const { t, i18n } = useTranslation();
|
|
const [sortKey, setSortKey] = useState<SortKey>("amount");
|
|
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
|
const [menu, setMenu] = useState<{ x: number; y: number; tx: RecentTransaction } | null>(null);
|
|
|
|
const sorted = useMemo(() => {
|
|
const copy = [...transactions];
|
|
copy.sort((a, b) => {
|
|
let cmp = 0;
|
|
switch (sortKey) {
|
|
case "date":
|
|
cmp = a.date.localeCompare(b.date);
|
|
break;
|
|
case "description":
|
|
cmp = a.description.localeCompare(b.description);
|
|
break;
|
|
case "amount":
|
|
cmp = Math.abs(a.amount) - Math.abs(b.amount);
|
|
break;
|
|
}
|
|
return sortDir === "asc" ? cmp : -cmp;
|
|
});
|
|
return copy;
|
|
}, [transactions, sortKey, sortDir]);
|
|
|
|
function toggleSort(key: SortKey) {
|
|
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
|
else {
|
|
setSortKey(key);
|
|
setSortDir("desc");
|
|
}
|
|
}
|
|
|
|
const handleContextMenu = (e: React.MouseEvent, tx: RecentTransaction) => {
|
|
if (!onAddKeyword) return;
|
|
e.preventDefault();
|
|
setMenu({ x: e.clientX, y: e.clientY, tx });
|
|
};
|
|
|
|
const header = (key: SortKey, label: string, align: "left" | "right") => (
|
|
<th
|
|
onClick={() => toggleSort(key)}
|
|
className={`${align === "right" ? "text-right" : "text-left"} px-3 py-2 font-medium text-[var(--muted-foreground)] cursor-pointer hover:text-[var(--foreground)] select-none`}
|
|
>
|
|
{label}
|
|
{sortKey === key && <span className="ml-1">{sortDir === "asc" ? "▲" : "▼"}</span>}
|
|
</th>
|
|
);
|
|
|
|
return (
|
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
|
<div className="overflow-x-auto max-h-[500px] overflow-y-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="sticky top-0 z-10 bg-[var(--card)]">
|
|
<tr className="border-b border-[var(--border)]">
|
|
{header("date", t("transactions.date"), "left")}
|
|
{header("description", t("transactions.description"), "left")}
|
|
{header("amount", t("transactions.amount"), "right")}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sorted.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={3} className="px-3 py-4 text-center text-[var(--muted-foreground)] italic">
|
|
{t("reports.empty.noData")}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
sorted.map((tx) => (
|
|
<tr
|
|
key={tx.id}
|
|
onContextMenu={(e) => handleContextMenu(e, tx)}
|
|
className="border-b border-[var(--border)] last:border-0 hover:bg-[var(--muted)]/40"
|
|
>
|
|
<td className="px-3 py-2 tabular-nums text-[var(--muted-foreground)]">{tx.date}</td>
|
|
<td className="px-3 py-2 truncate max-w-[280px]">{tx.description}</td>
|
|
<td
|
|
className="px-3 py-2 text-right tabular-nums font-medium"
|
|
style={{
|
|
color: tx.amount >= 0 ? "var(--positive, #10b981)" : "var(--foreground)",
|
|
}}
|
|
>
|
|
{formatAmount(tx.amount, i18n.language)}
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{menu && onAddKeyword && (
|
|
<ContextMenu
|
|
x={menu.x}
|
|
y={menu.y}
|
|
header={menu.tx.description}
|
|
onClose={() => setMenu(null)}
|
|
items={[
|
|
{
|
|
icon: <Tag size={14} />,
|
|
label: t("reports.keyword.addFromTransaction"),
|
|
onClick: () => {
|
|
onAddKeyword(menu.tx);
|
|
},
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|