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 <noreply@anthropic.com>
This commit is contained in:
Le-King-Fu 2026-02-13 12:37:46 +00:00
parent f7fb6910b6
commit 5648f79424
6 changed files with 175 additions and 4 deletions

View file

@ -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<KeywordWithCategory[]>([]);
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 (
<p className="text-[var(--muted-foreground)]">{t("common.loading")}</p>
);
}
return (
<div
className="flex-1 bg-[var(--card)] rounded-xl border border-[var(--border)] p-4 overflow-y-auto"
style={{ minHeight: "calc(100vh - 180px)" }}
>
<div className="relative mb-4">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)]"
/>
<input
type="text"
value={search}
onChange={(e) => 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"
/>
</div>
{filtered.length === 0 ? (
<p className="text-[var(--muted-foreground)] text-sm text-center py-8">
{keywords.length === 0
? t("categories.allKeywordsEmpty")
: t("common.noResults")}
</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[var(--muted-foreground)] border-b border-[var(--border)]">
<th className="pb-2 font-medium">
{t("categories.keywords")}
</th>
<th className="pb-2 font-medium">{t("categories.priority")}</th>
<th className="pb-2 font-medium">{t("transactions.category")}</th>
</tr>
</thead>
<tbody>
{filtered.map((k) => (
<tr
key={k.id}
className="border-b border-[var(--border)] last:border-0"
>
<td className="py-2 font-mono">{k.keyword}</td>
<td className="py-2">{k.priority}</td>
<td className="py-2">
<button
onClick={() => onSelectCategory(k.category_id)}
className="flex items-center gap-2 hover:underline"
>
<span
className="inline-block w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: k.category_color || "#6b7280" }}
/>
{k.category_name}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}

View file

@ -39,15 +39,19 @@ export default function CategoryCombobox({
? extraOptions?.find((o) => o.value === activeExtra)?.label ?? "" ? extraOptions?.find((o) => o.value === activeExtra)?.label ?? ""
: selectedCategory?.name ?? ""; : selectedCategory?.name ?? "";
// Strip accents + lowercase for accent-insensitive matching
const normalize = (s: string) =>
s.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
// Filter categories // Filter categories
const lowerQuery = query.toLowerCase(); const normalizedQuery = normalize(query);
const filtered = query const filtered = query
? categories.filter((c) => c.name.toLowerCase().includes(lowerQuery)) ? categories.filter((c) => normalize(c.name).includes(normalizedQuery))
: categories; : categories;
const filteredExtras = extraOptions const filteredExtras = extraOptions
? query ? query
? extraOptions.filter((o) => o.label.toLowerCase().includes(lowerQuery)) ? extraOptions.filter((o) => normalize(o.label).includes(normalizedQuery))
: extraOptions : extraOptions
: []; : [];

View file

@ -241,6 +241,8 @@
"keywordText": "Keyword...", "keywordText": "Keyword...",
"priority": "Priority", "priority": "Priority",
"customColor": "Custom color", "customColor": "Custom color",
"allKeywords": "All Keywords",
"allKeywordsEmpty": "No keywords yet",
"help": { "help": {
"title": "How to manage Categories", "title": "How to manage Categories",
"tips": [ "tips": [

View file

@ -241,6 +241,8 @@
"keywordText": "Mot-clé...", "keywordText": "Mot-clé...",
"priority": "Priorité", "priority": "Priorité",
"customColor": "Couleur personnalisée", "customColor": "Couleur personnalisée",
"allKeywords": "Tous les mots-clés",
"allKeywordsEmpty": "Aucun mot-clé",
"help": { "help": {
"title": "Comment gérer les Catégories", "title": "Comment gérer les Catégories",
"tips": [ "tips": [

View file

@ -1,12 +1,15 @@
import { useState } from "react";
import { useTranslation } from "react-i18next"; 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 { PageHelp } from "../components/shared/PageHelp";
import { useCategories } from "../hooks/useCategories"; import { useCategories } from "../hooks/useCategories";
import CategoryTree from "../components/categories/CategoryTree"; import CategoryTree from "../components/categories/CategoryTree";
import CategoryDetailPanel from "../components/categories/CategoryDetailPanel"; import CategoryDetailPanel from "../components/categories/CategoryDetailPanel";
import AllKeywordsPanel from "../components/categories/AllKeywordsPanel";
export default function CategoriesPage() { export default function CategoriesPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [showAllKeywords, setShowAllKeywords] = useState(false);
const { const {
state, state,
selectCategory, selectCategory,
@ -40,6 +43,17 @@ export default function CategoriesPage() {
<PageHelp helpKey="categories" /> <PageHelp helpKey="categories" />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button
onClick={() => setShowAllKeywords((v) => !v)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
showAllKeywords
? "border-[var(--primary)] bg-[var(--primary)]/10 text-[var(--primary)]"
: "border-[var(--border)] hover:bg-[var(--muted)]"
}`}
>
<List size={16} />
{t("categories.allKeywords")}
</button>
<button <button
onClick={handleReinitialize} onClick={handleReinitialize}
disabled={state.isSaving} disabled={state.isSaving}
@ -66,6 +80,13 @@ export default function CategoriesPage() {
{state.isLoading ? ( {state.isLoading ? (
<p className="text-[var(--muted-foreground)]">{t("common.loading")}</p> <p className="text-[var(--muted-foreground)]">{t("common.loading")}</p>
) : showAllKeywords ? (
<AllKeywordsPanel
onSelectCategory={(id) => {
setShowAllKeywords(false);
selectCategory(id);
}}
/>
) : ( ) : (
<div className="flex gap-6" style={{ minHeight: "calc(100vh - 180px)" }}> <div className="flex gap-6" style={{ minHeight: "calc(100vh - 180px)" }}>
<div className="w-1/3 bg-[var(--card)] rounded-xl border border-[var(--border)] p-3 overflow-y-auto"> <div className="w-1/3 bg-[var(--card)] rounded-xl border border-[var(--border)] p-3 overflow-y-auto">

View file

@ -210,6 +210,27 @@ export async function reinitializeCategories(): Promise<void> {
} }
} }
export interface KeywordWithCategory {
id: number;
keyword: string;
priority: number;
category_id: number;
category_name: string;
category_color: string;
}
export async function getAllKeywordsWithCategory(): Promise<KeywordWithCategory[]> {
const db = await getDb();
return db.select<KeywordWithCategory[]>(
`SELECT k.id, k.keyword, k.priority, k.category_id,
c.name AS category_name, c.color AS category_color
FROM keywords k
JOIN categories c ON k.category_id = c.id AND c.is_active = 1
WHERE k.is_active = 1
ORDER BY k.keyword COLLATE NOCASE`
);
}
export async function getKeywordsByCategoryId( export async function getKeywordsByCategoryId(
categoryId: number categoryId: number
): Promise<Keyword[]> { ): Promise<Keyword[]> {