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:
parent
f7fb6910b6
commit
5648f79424
6 changed files with 175 additions and 4 deletions
121
src/components/categories/AllKeywordsPanel.tsx
Normal file
121
src/components/categories/AllKeywordsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
: [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<PageHelp helpKey="categories" />
|
||||
</div>
|
||||
<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
|
||||
onClick={handleReinitialize}
|
||||
disabled={state.isSaving}
|
||||
|
|
@ -66,6 +80,13 @@ export default function CategoriesPage() {
|
|||
|
||||
{state.isLoading ? (
|
||||
<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="w-1/3 bg-[var(--card)] rounded-xl border border-[var(--border)] p-3 overflow-y-auto">
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
categoryId: number
|
||||
): Promise<Keyword[]> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue