From 5648f79424a414d986fbb040f5998dd2a5b1ad45 Mon Sep 17 00:00:00 2001 From: Le-King-Fu Date: Fri, 13 Feb 2026 12:37:46 +0000 Subject: [PATCH] feat: add "All Keywords" view on Categories page Adds a toggle button to view all keywords across categories in a searchable table with accent-insensitive filtering. Clicking a category name navigates back to the normal view with that category selected. Co-Authored-By: Claude Opus 4.6 --- .../categories/AllKeywordsPanel.tsx | 121 ++++++++++++++++++ src/components/shared/CategoryCombobox.tsx | 10 +- src/i18n/locales/en.json | 2 + src/i18n/locales/fr.json | 2 + src/pages/CategoriesPage.tsx | 23 +++- src/services/categoryService.ts | 21 +++ 6 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 src/components/categories/AllKeywordsPanel.tsx diff --git a/src/components/categories/AllKeywordsPanel.tsx b/src/components/categories/AllKeywordsPanel.tsx new file mode 100644 index 0000000..b08442d --- /dev/null +++ b/src/components/categories/AllKeywordsPanel.tsx @@ -0,0 +1,121 @@ +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Search } from "lucide-react"; +import { + getAllKeywordsWithCategory, + type KeywordWithCategory, +} from "../../services/categoryService"; + +function normalize(str: string): string { + return str + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase(); +} + +interface AllKeywordsPanelProps { + onSelectCategory: (id: number) => void; +} + +export default function AllKeywordsPanel({ + onSelectCategory, +}: AllKeywordsPanelProps) { + const { t } = useTranslation(); + const [keywords, setKeywords] = useState([]); + const [search, setSearch] = useState(""); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + (async () => { + setIsLoading(true); + const data = await getAllKeywordsWithCategory(); + if (!cancelled) { + setKeywords(data); + setIsLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const normalizedSearch = normalize(search); + const filtered = search + ? keywords.filter( + (k) => + normalize(k.keyword).includes(normalizedSearch) || + normalize(k.category_name).includes(normalizedSearch) + ) + : keywords; + + if (isLoading) { + return ( +

{t("common.loading")}

+ ); + } + + return ( +
+
+ + setSearch(e.target.value)} + placeholder={t("common.search")} + className="w-full pl-9 pr-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm" + /> +
+ + {filtered.length === 0 ? ( +

+ {keywords.length === 0 + ? t("categories.allKeywordsEmpty") + : t("common.noResults")} +

+ ) : ( + + + + + + + + + + {filtered.map((k) => ( + + + + + + ))} + +
+ {t("categories.keywords")} + {t("categories.priority")}{t("transactions.category")}
{k.keyword}{k.priority} + +
+ )} +
+ ); +} diff --git a/src/components/shared/CategoryCombobox.tsx b/src/components/shared/CategoryCombobox.tsx index e982363..1294be5 100644 --- a/src/components/shared/CategoryCombobox.tsx +++ b/src/components/shared/CategoryCombobox.tsx @@ -39,15 +39,19 @@ export default function CategoryCombobox({ ? extraOptions?.find((o) => o.value === activeExtra)?.label ?? "" : selectedCategory?.name ?? ""; + // Strip accents + lowercase for accent-insensitive matching + const normalize = (s: string) => + s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase(); + // Filter categories - const lowerQuery = query.toLowerCase(); + const normalizedQuery = normalize(query); const filtered = query - ? categories.filter((c) => c.name.toLowerCase().includes(lowerQuery)) + ? categories.filter((c) => normalize(c.name).includes(normalizedQuery)) : categories; const filteredExtras = extraOptions ? query - ? extraOptions.filter((o) => o.label.toLowerCase().includes(lowerQuery)) + ? extraOptions.filter((o) => normalize(o.label).includes(normalizedQuery)) : extraOptions : []; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2fb07c3..59f7bc6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -241,6 +241,8 @@ "keywordText": "Keyword...", "priority": "Priority", "customColor": "Custom color", + "allKeywords": "All Keywords", + "allKeywordsEmpty": "No keywords yet", "help": { "title": "How to manage Categories", "tips": [ diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index ac3045d..b17da45 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -241,6 +241,8 @@ "keywordText": "Mot-clé...", "priority": "Priorité", "customColor": "Couleur personnalisée", + "allKeywords": "Tous les mots-clés", + "allKeywordsEmpty": "Aucun mot-clé", "help": { "title": "Comment gérer les Catégories", "tips": [ diff --git a/src/pages/CategoriesPage.tsx b/src/pages/CategoriesPage.tsx index 2f671df..bc0f512 100644 --- a/src/pages/CategoriesPage.tsx +++ b/src/pages/CategoriesPage.tsx @@ -1,12 +1,15 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Plus, RotateCcw } from "lucide-react"; +import { Plus, RotateCcw, List } from "lucide-react"; import { PageHelp } from "../components/shared/PageHelp"; import { useCategories } from "../hooks/useCategories"; import CategoryTree from "../components/categories/CategoryTree"; import CategoryDetailPanel from "../components/categories/CategoryDetailPanel"; +import AllKeywordsPanel from "../components/categories/AllKeywordsPanel"; export default function CategoriesPage() { const { t } = useTranslation(); + const [showAllKeywords, setShowAllKeywords] = useState(false); const { state, selectCategory, @@ -40,6 +43,17 @@ export default function CategoriesPage() {
+