feat(categories): categories standard guide page (#117) #129
10 changed files with 539 additions and 0 deletions
|
|
@ -3,6 +3,7 @@
|
|||
## [Non publié]
|
||||
|
||||
### Ajouté
|
||||
- **Page Guide des catégories standard** (Paramètres → *Structure standard des catégories*, route `/settings/categories/standard`) : nouvelle page en lecture seule qui expose la taxonomie v1 IPC complète sous forme d'arbre navigable avec repli/expansion par racine, un compteur global en direct (racines · sous-catégories · feuilles · total), une recherche plein texte insensible aux accents sur les noms traduits, des info-bulles au survol affichant la clé `i18n_key`, le type et l'identifiant de chaque nœud, et un bouton *Exporter en PDF* qui ouvre la boîte d'impression du navigateur. Une règle `@media print` dédiée force l'affichage complet de toutes les branches à l'impression, peu importe l'état de repli à l'écran. Tous les libellés passent par `categoriesSeed.*` avec `name` en repli pour les futures lignes personnalisées. Aucune écriture en base, aucune action destructive (#117)
|
||||
- **Seed de catégories IPC pour les nouveaux profils** : les nouveaux profils sont désormais créés avec la taxonomie v1 IPC (Indice des prix à la consommation) — une hiérarchie alignée sur les catégories de Statistique Canada. Les noms des catégories du seed sont traduits dynamiquement depuis la clé i18n `categoriesSeed.*` (FR/EN), donc affichés dans la langue de l'utilisateur. Les profils existants gardent l'ancien seed v2, marqués via une nouvelle préférence `categories_schema_version` (une page de migration ultérieure offrira le passage v2→v1). Côté interne : colonne `categories.i18n_key` (nullable) ajoutée par la migration v8 (strictement additive), `src/data/categoryTaxonomyV1.json` livré comme source de vérité côté TS, les renderers `CategoryTree` et `CategoryCombobox` utilisent `name` en repli quand aucune clé de traduction n'est présente (catégories créées par l'utilisateur) (#115)
|
||||
|
||||
## [0.8.3] - 2026-04-19
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Standard categories guide page** (Settings → *Standard category structure*, route `/settings/categories/standard`): new read-only page that exposes the full v1 IPC taxonomy as a navigable tree with expand/collapse per root, a live category counter (roots · subcategories · leaves · total), accent-insensitive full-text search over translated names, hover tooltips showing the `i18n_key` / type / ID of each node, and a *Export as PDF* button that triggers the browser print dialog. A dedicated `@media print` rule forces every branch to render fully expanded regardless of the on-screen collapse state. All labels resolve via `categoriesSeed.*` with `name` as fallback for future custom rows. No database writes, no destructive actions (#117)
|
||||
- **IPC-aligned categories seed for new profiles**: brand-new profiles are now seeded with the v1 IPC (Indice des prix à la consommation) taxonomy — a structured hierarchy aligned with Statistics Canada consumer price index categories. Category labels are now translated dynamically from the `categoriesSeed.*` i18n namespace (FR/EN), so seed categories display in the user's current language. Existing profiles remain on the legacy v2 seed, marked via a new `categories_schema_version` user preference (a later migration wizard will offer the v2→v1 transition). Internally: nullable `categories.i18n_key` column added in migration v8 (additive only), `src/data/categoryTaxonomyV1.json` bundled as the TS-side source of truth, `CategoryTree` and `CategoryCombobox` renderers fall back to the raw `name` when no translation key is present (user-created rows) (#115)
|
||||
|
||||
## [0.8.3] - 2026-04-19
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import ReportsComparePage from "./pages/ReportsComparePage";
|
|||
import ReportsCategoryPage from "./pages/ReportsCategoryPage";
|
||||
import ReportsCartesPage from "./pages/ReportsCartesPage";
|
||||
import SettingsPage from "./pages/SettingsPage";
|
||||
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
|
||||
import DocsPage from "./pages/DocsPage";
|
||||
import ChangelogPage from "./pages/ChangelogPage";
|
||||
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
|
||||
|
|
@ -112,6 +113,10 @@ export default function App() {
|
|||
<Route path="/reports/category" element={<ReportsCategoryPage />} />
|
||||
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route
|
||||
path="/settings/categories/standard"
|
||||
element={<CategoriesStandardGuidePage />}
|
||||
/>
|
||||
<Route path="/docs" element={<DocsPage />} />
|
||||
<Route path="/changelog" element={<ChangelogPage />} />
|
||||
</Route>
|
||||
|
|
|
|||
214
src/components/categories/CategoryTaxonomyTree.tsx
Normal file
214
src/components/categories/CategoryTaxonomyTree.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronRight, ChevronDown } from "lucide-react";
|
||||
import type { TaxonomyNode } from "../../services/categoryTaxonomyService";
|
||||
|
||||
interface CategoryTaxonomyTreeProps {
|
||||
nodes: TaxonomyNode[];
|
||||
expanded: Set<number>;
|
||||
onToggle: (id: number) => void;
|
||||
searchQuery: string;
|
||||
}
|
||||
|
||||
interface NodeRowProps {
|
||||
node: TaxonomyNode;
|
||||
depth: number;
|
||||
expanded: Set<number>;
|
||||
onToggle: (id: number) => void;
|
||||
visibleIds: Set<number> | null;
|
||||
}
|
||||
|
||||
function NodeRow({
|
||||
node,
|
||||
depth,
|
||||
expanded,
|
||||
onToggle,
|
||||
visibleIds,
|
||||
}: NodeRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const label = t(node.i18n_key, { defaultValue: node.name });
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isExpanded = expanded.has(node.id);
|
||||
|
||||
// Filter children by visibility set (search mode) if provided.
|
||||
const visibleChildren = useMemo(() => {
|
||||
if (visibleIds === null) return node.children;
|
||||
return node.children.filter((child) => visibleIds.has(child.id));
|
||||
}, [node.children, visibleIds]);
|
||||
|
||||
const typeLabel =
|
||||
node.type === "income"
|
||||
? t("categoriesSeed.guidePage.type.income")
|
||||
: node.type === "transfer"
|
||||
? t("categoriesSeed.guidePage.type.transfer")
|
||||
: t("categoriesSeed.guidePage.type.expense");
|
||||
|
||||
const tooltipText = [
|
||||
`${t("categoriesSeed.guidePage.tooltip.key")}: ${node.i18n_key}`,
|
||||
`${t("categoriesSeed.guidePage.tooltip.type")}: ${typeLabel}`,
|
||||
`${t("categoriesSeed.guidePage.tooltip.id")}: ${node.id}`,
|
||||
].join("\n");
|
||||
|
||||
// On screen: show children only if expanded.
|
||||
// In print: @media print in styles.css overrides display:none to show everything.
|
||||
const childrenHidden = !isExpanded;
|
||||
|
||||
return (
|
||||
<li className="taxonomy-node">
|
||||
<div
|
||||
className="flex items-center gap-2 py-1.5 px-2 rounded-md hover:bg-[var(--muted)] transition-colors"
|
||||
style={{ paddingLeft: `${depth * 1.25 + 0.5}rem` }}
|
||||
title={tooltipText}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(node.id)}
|
||||
aria-label={
|
||||
isExpanded
|
||||
? t("categoriesSeed.guidePage.collapseAll")
|
||||
: t("categoriesSeed.guidePage.expandAll")
|
||||
}
|
||||
aria-expanded={isExpanded}
|
||||
className="print:hidden shrink-0 text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
) : (
|
||||
<span className="shrink-0 w-[14px]" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
<span
|
||||
className="shrink-0 inline-block h-3 w-3 rounded-sm border border-[var(--border)]"
|
||||
style={{ backgroundColor: node.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<span
|
||||
className={
|
||||
depth === 0
|
||||
? "font-semibold text-[var(--foreground)]"
|
||||
: depth === 1
|
||||
? "font-medium text-[var(--foreground)]"
|
||||
: "text-[var(--foreground)]"
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{depth === 0 && (
|
||||
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
|
||||
({node.children.length})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visibleChildren.length > 0 && (
|
||||
<ul
|
||||
className={`list-none m-0 p-0 taxonomy-children ${
|
||||
childrenHidden ? "taxonomy-children-collapsed" : ""
|
||||
}`}
|
||||
>
|
||||
{visibleChildren.map((child) => (
|
||||
<NodeRow
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
visibleIds={visibleIds}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the set of node IDs whose visible subtree matches the query.
|
||||
* A node is kept if its translated name contains the query OR any of its descendants match.
|
||||
*/
|
||||
export function collectVisibleIds(
|
||||
roots: TaxonomyNode[],
|
||||
normalizedQuery: string,
|
||||
translate: (key: string, fallback: string) => string,
|
||||
): Set<number> {
|
||||
const visible = new Set<number>();
|
||||
if (normalizedQuery.length === 0) {
|
||||
const walk = (n: TaxonomyNode) => {
|
||||
visible.add(n.id);
|
||||
n.children.forEach(walk);
|
||||
};
|
||||
roots.forEach(walk);
|
||||
return visible;
|
||||
}
|
||||
|
||||
const walk = (node: TaxonomyNode): boolean => {
|
||||
const label = translate(node.i18n_key, node.name);
|
||||
const selfMatches = normalize(label).includes(normalizedQuery);
|
||||
let anyChildMatches = false;
|
||||
for (const child of node.children) {
|
||||
if (walk(child)) anyChildMatches = true;
|
||||
}
|
||||
if (selfMatches || anyChildMatches) {
|
||||
visible.add(node.id);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
roots.forEach(walk);
|
||||
return visible;
|
||||
}
|
||||
|
||||
// Case-and-accent insensitive normalization for search.
|
||||
export function normalize(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "");
|
||||
}
|
||||
|
||||
export default function CategoryTaxonomyTree({
|
||||
nodes,
|
||||
expanded,
|
||||
onToggle,
|
||||
searchQuery,
|
||||
}: CategoryTaxonomyTreeProps) {
|
||||
const { t } = useTranslation();
|
||||
const normalizedQuery = normalize(searchQuery.trim());
|
||||
|
||||
const visibleIds = useMemo(() => {
|
||||
if (normalizedQuery.length === 0) return null;
|
||||
return collectVisibleIds(nodes, normalizedQuery, (key, fallback) =>
|
||||
t(key, { defaultValue: fallback }),
|
||||
);
|
||||
}, [nodes, normalizedQuery, t]);
|
||||
|
||||
const visibleRoots =
|
||||
visibleIds === null ? nodes : nodes.filter((r) => visibleIds.has(r.id));
|
||||
|
||||
if (visibleRoots.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-[var(--muted-foreground)] p-4">
|
||||
{t("categoriesSeed.guidePage.noResults")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="list-none m-0 p-0">
|
||||
{visibleRoots.map((root) => (
|
||||
<NodeRow
|
||||
key={root.id}
|
||||
node={root}
|
||||
depth={0}
|
||||
expanded={expanded}
|
||||
onToggle={onToggle}
|
||||
visibleIds={visibleIds}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
38
src/components/settings/CategoriesCard.tsx
Normal file
38
src/components/settings/CategoriesCard.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FolderTree, ChevronRight } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Card that surfaces category-related entries in the Settings page.
|
||||
* For now: a single link to the read-only "standard categories guide"
|
||||
* (Livraison 1 of the IPC category refactor).
|
||||
*/
|
||||
export default function CategoriesCard() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Link
|
||||
to="/settings/categories/standard"
|
||||
className="block bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 hover:border-[var(--primary)] transition-colors group"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-[var(--primary)]/10 flex items-center justify-center text-[var(--primary)]">
|
||||
<FolderTree size={22} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.categoriesCard.standardGuideTitle")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{t("settings.categoriesCard.standardGuideDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight
|
||||
size={18}
|
||||
className="text-[var(--muted-foreground)] group-hover:text-[var(--primary)] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -564,6 +564,12 @@
|
|||
"title": "User Guide",
|
||||
"description": "Learn how to use all features of the app"
|
||||
},
|
||||
"categoriesCard": {
|
||||
"title": "Category management",
|
||||
"description": "Organize your expenses and income the way you want.",
|
||||
"standardGuideTitle": "Standard category structure",
|
||||
"standardGuideDescription": "Browse the CPI taxonomy (read-only)"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Logs",
|
||||
"clear": "Clear",
|
||||
|
|
@ -1229,6 +1235,38 @@
|
|||
"personnel": "Personal loan repayment"
|
||||
},
|
||||
"internes": "Internal transfers"
|
||||
},
|
||||
"guidePage": {
|
||||
"title": "Standard categories guide",
|
||||
"subtitle": "Based on the Statistics Canada CPI classification",
|
||||
"backToSettings": "Back to settings",
|
||||
"intro": {
|
||||
"title": "Why this structure?",
|
||||
"body": "Statistics Canada's official household expenditure classification (CPI basket) identifies the main budget components of a Canadian household. This structure covers the full range of financial flows of a typical Quebec household, makes it easier to benchmark against external references, and produces clearer reports."
|
||||
},
|
||||
"counter": "{{roots}} root categories · {{subcategories}} subcategories · {{leaves}} leaves · {{total}} categories in total",
|
||||
"searchPlaceholder": "Search a category...",
|
||||
"expandAll": "Expand all",
|
||||
"collapseAll": "Collapse all",
|
||||
"print": "Export as PDF",
|
||||
"printHint": "Use the print dialog to save as PDF.",
|
||||
"noResults": "No category matches your search.",
|
||||
"tooltip": {
|
||||
"key": "i18n key",
|
||||
"type": "Type",
|
||||
"id": "Identifier"
|
||||
},
|
||||
"type": {
|
||||
"income": "Income",
|
||||
"expense": "Expense",
|
||||
"transfer": "Transfer"
|
||||
},
|
||||
"legend": {
|
||||
"title": "Legend",
|
||||
"root": "Root category",
|
||||
"subcategory": "Subcategory",
|
||||
"leaf": "Leaf (final category)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -564,6 +564,12 @@
|
|||
"title": "Guide d'utilisation",
|
||||
"description": "Apprenez à utiliser toutes les fonctionnalités de l'application"
|
||||
},
|
||||
"categoriesCard": {
|
||||
"title": "Gestion des catégories",
|
||||
"description": "Organisez vos dépenses et revenus selon vos besoins.",
|
||||
"standardGuideTitle": "Structure standard des catégories",
|
||||
"standardGuideDescription": "Explorer la taxonomie IPC (lecture seule)"
|
||||
},
|
||||
"logs": {
|
||||
"title": "Journaux",
|
||||
"clear": "Effacer",
|
||||
|
|
@ -1229,6 +1235,38 @@
|
|||
"personnel": "Remboursement prêt perso"
|
||||
},
|
||||
"internes": "Transferts internes"
|
||||
},
|
||||
"guidePage": {
|
||||
"title": "Guide des catégories standard",
|
||||
"subtitle": "Inspiré de la classification IPC de Statistique Canada",
|
||||
"backToSettings": "Retour aux paramètres",
|
||||
"intro": {
|
||||
"title": "Pourquoi cette structure ?",
|
||||
"body": "La classification officielle des dépenses des ménages au Canada (Statistique Canada, panier IPC) identifie les grandes composantes du budget d'un ménage. Cette structure permet de couvrir l'ensemble des flux financiers d'un ménage québécois typique, de comparer vos dépenses à des références externes et d'obtenir des rapports plus lisibles."
|
||||
},
|
||||
"counter": "{{roots}} catégories racines · {{subcategories}} sous-catégories · {{leaves}} feuilles · {{total}} catégories au total",
|
||||
"searchPlaceholder": "Rechercher une catégorie...",
|
||||
"expandAll": "Tout déplier",
|
||||
"collapseAll": "Tout replier",
|
||||
"print": "Exporter en PDF",
|
||||
"printHint": "Utilisez la boîte de dialogue d'impression pour enregistrer en PDF.",
|
||||
"noResults": "Aucune catégorie ne correspond à votre recherche.",
|
||||
"tooltip": {
|
||||
"key": "Clé i18n",
|
||||
"type": "Type",
|
||||
"id": "Identifiant"
|
||||
},
|
||||
"type": {
|
||||
"income": "Revenu",
|
||||
"expense": "Dépense",
|
||||
"transfer": "Transfert"
|
||||
},
|
||||
"legend": {
|
||||
"title": "Légende",
|
||||
"root": "Catégorie racine",
|
||||
"subcategory": "Sous-catégorie",
|
||||
"leaf": "Feuille (catégorie finale)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
187
src/pages/CategoriesStandardGuidePage.tsx
Normal file
187
src/pages/CategoriesStandardGuidePage.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ArrowLeft, Search, Printer, ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
||||
import { useCategoryTaxonomy } from "../hooks/useCategoryTaxonomy";
|
||||
import CategoryTaxonomyTree from "../components/categories/CategoryTaxonomyTree";
|
||||
import type { TaxonomyNode } from "../services/categoryTaxonomyService";
|
||||
|
||||
function countNodes(nodes: TaxonomyNode[]): {
|
||||
roots: number;
|
||||
subcategories: number;
|
||||
leaves: number;
|
||||
} {
|
||||
let roots = 0;
|
||||
let subcategories = 0;
|
||||
let leaves = 0;
|
||||
for (const root of nodes) {
|
||||
roots += 1;
|
||||
for (const child of root.children) {
|
||||
if (child.children.length === 0) {
|
||||
// direct leaf under a root (rare but possible)
|
||||
leaves += 1;
|
||||
} else {
|
||||
subcategories += 1;
|
||||
for (const leaf of child.children) {
|
||||
if (leaf.children.length === 0) leaves += 1;
|
||||
else subcategories += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { roots, subcategories, leaves };
|
||||
}
|
||||
|
||||
function collectAllIds(nodes: TaxonomyNode[]): number[] {
|
||||
const ids: number[] = [];
|
||||
const walk = (n: TaxonomyNode) => {
|
||||
ids.push(n.id);
|
||||
n.children.forEach(walk);
|
||||
};
|
||||
nodes.forEach(walk);
|
||||
return ids;
|
||||
}
|
||||
|
||||
export default function CategoriesStandardGuidePage() {
|
||||
const { t } = useTranslation();
|
||||
const { taxonomy } = useCategoryTaxonomy();
|
||||
const [search, setSearch] = useState("");
|
||||
const [expanded, setExpanded] = useState<Set<number>>(() => {
|
||||
// Start with roots collapsed (user can expand as needed); counter and search still work.
|
||||
return new Set<number>();
|
||||
});
|
||||
|
||||
const counts = useMemo(() => countNodes(taxonomy.roots), [taxonomy.roots]);
|
||||
const total = counts.roots + counts.subcategories + counts.leaves;
|
||||
|
||||
const toggleNode = (id: number) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleExpandAll = () => {
|
||||
setExpanded(new Set(collectAllIds(taxonomy.roots)));
|
||||
};
|
||||
|
||||
const handleCollapseAll = () => {
|
||||
setExpanded(new Set());
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
// window.print() opens the browser print dialog; @media print rules strip chrome.
|
||||
window.print();
|
||||
};
|
||||
|
||||
const allExpanded = expanded.size > 0;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto space-y-6">
|
||||
{/* Back link (hidden in print) */}
|
||||
<div className="print:hidden">
|
||||
<Link
|
||||
to="/settings"
|
||||
className="inline-flex items-center gap-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
{t("categoriesSeed.guidePage.backToSettings")}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t("categoriesSeed.guidePage.title")}
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{t("categoriesSeed.guidePage.subtitle")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Intro card */}
|
||||
<section className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-2">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("categoriesSeed.guidePage.intro.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
{t("categoriesSeed.guidePage.intro.body")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Counter + toolbar */}
|
||||
<section className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-5 space-y-4">
|
||||
<p
|
||||
className="text-sm text-[var(--muted-foreground)]"
|
||||
aria-live="polite"
|
||||
>
|
||||
{t("categoriesSeed.guidePage.counter", {
|
||||
roots: counts.roots,
|
||||
subcategories: counts.subcategories,
|
||||
leaves: counts.leaves,
|
||||
total,
|
||||
})}
|
||||
</p>
|
||||
|
||||
{/* Toolbar: search + actions (hidden in print) */}
|
||||
<div className="print:hidden flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)] pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t("categoriesSeed.guidePage.searchPlaceholder")}
|
||||
aria-label={t("categoriesSeed.guidePage.searchPlaceholder")}
|
||||
className="w-full pl-9 pr-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/30"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={allExpanded ? handleCollapseAll : handleExpandAll}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
{allExpanded ? (
|
||||
<>
|
||||
<ChevronsDownUp size={16} />
|
||||
{t("categoriesSeed.guidePage.collapseAll")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronsUpDown size={16} />
|
||||
{t("categoriesSeed.guidePage.expandAll")}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrint}
|
||||
className="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity"
|
||||
title={t("categoriesSeed.guidePage.printHint")}
|
||||
>
|
||||
<Printer size={16} />
|
||||
{t("categoriesSeed.guidePage.print")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tree */}
|
||||
<section className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-3 taxonomy-tree-print">
|
||||
<CategoryTaxonomyTree
|
||||
nodes={taxonomy.roots}
|
||||
expanded={expanded}
|
||||
onToggle={toggleNode}
|
||||
searchQuery={search}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import LicenseCard from "../components/settings/LicenseCard";
|
|||
import AccountCard from "../components/settings/AccountCard";
|
||||
import LogViewerCard from "../components/settings/LogViewerCard";
|
||||
import TokenStoreFallbackBanner from "../components/settings/TokenStoreFallbackBanner";
|
||||
import CategoriesCard from "../components/settings/CategoriesCard";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
|
@ -121,6 +122,9 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Categories card — entry to the standard categories guide */}
|
||||
<CategoriesCard />
|
||||
|
||||
{/* Changelog card */}
|
||||
<Link
|
||||
to="/changelog"
|
||||
|
|
|
|||
|
|
@ -133,6 +133,19 @@ body {
|
|||
*, *::before, *::after {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Force the full categories taxonomy tree to expand on print */
|
||||
.taxonomy-children-collapsed {
|
||||
display: block !important;
|
||||
}
|
||||
.taxonomy-node {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
/* Screen-only: collapse state hides unexpanded children */
|
||||
.taxonomy-children-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
|
|
|
|||
Loading…
Reference in a new issue