feat(categories): add categories standard guide page (#117)
Adds a read-only Settings subpage at /settings/categories/standard that
exposes the full v1 IPC category taxonomy:
- Recursive tree with per-root expand/collapse (chevron buttons), clickable
only via the disclosure caret — no destructive actions anywhere on the
page.
- Live counter banner: roots / subcategories / leaves / total, computed
from the bundled categoryTaxonomyV1 JSON.
- Accent- and case-insensitive full-text search over translated names;
matching nodes keep their ancestor chain visible, non-matching branches
are pruned from the visible tree.
- Hover tooltips (native `title`) showing i18n_key, type (income /
expense / transfer — translated) and numeric id of each node — useful
for power-users cross-referencing the consolidated schema.
- Export as PDF button that triggers window.print(); a dedicated
@media print rule in styles.css forces every branch to render fully
expanded during printing regardless of the on-screen collapse state,
and hides the toolbar / back-link.
- All labels resolve via t(node.i18n_key, { defaultValue: node.name })
to be forward-compatible with future user-created taxonomy rows that
have no i18n_key.
Also:
- New CategoriesCard in Settings that links to the page (FolderTree
icon, consistent with the userGuide / changelog card pattern).
- i18n keys added under categoriesSeed.guidePage.* and
settings.categoriesCard.* (FR + EN).
- CHANGELOG.md + CHANGELOG.fr.md updated under [Unreleased] / Added.
Route uses the English-style `/settings/categories/standard` to match
the rest of the app (/settings, /categories, /changelog, ...). The
original spec mentions a French-accented path but the existing router
is English-only; documenting here so reviewers can see the decision.
No SQL migration, no schema change, no write to the database — this
is strictly a read-only view on the TS-side taxonomy bundle.
Type-check clean (tsc --noEmit), 148/148 vitest tests pass, vite build
succeeds.
This commit is contained in:
parent
b8fa089c5f
commit
defa63a063
10 changed files with 539 additions and 0 deletions
|
|
@ -3,6 +3,7 @@
|
||||||
## [Non publié]
|
## [Non publié]
|
||||||
|
|
||||||
### Ajouté
|
### 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)
|
- **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
|
## [0.8.3] - 2026-04-19
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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)
|
- **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
|
## [0.8.3] - 2026-04-19
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import ReportsComparePage from "./pages/ReportsComparePage";
|
||||||
import ReportsCategoryPage from "./pages/ReportsCategoryPage";
|
import ReportsCategoryPage from "./pages/ReportsCategoryPage";
|
||||||
import ReportsCartesPage from "./pages/ReportsCartesPage";
|
import ReportsCartesPage from "./pages/ReportsCartesPage";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
|
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
|
||||||
import DocsPage from "./pages/DocsPage";
|
import DocsPage from "./pages/DocsPage";
|
||||||
import ChangelogPage from "./pages/ChangelogPage";
|
import ChangelogPage from "./pages/ChangelogPage";
|
||||||
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
|
import ProfileSelectionPage from "./pages/ProfileSelectionPage";
|
||||||
|
|
@ -112,6 +113,10 @@ export default function App() {
|
||||||
<Route path="/reports/category" element={<ReportsCategoryPage />} />
|
<Route path="/reports/category" element={<ReportsCategoryPage />} />
|
||||||
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
|
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route
|
||||||
|
path="/settings/categories/standard"
|
||||||
|
element={<CategoriesStandardGuidePage />}
|
||||||
|
/>
|
||||||
<Route path="/docs" element={<DocsPage />} />
|
<Route path="/docs" element={<DocsPage />} />
|
||||||
<Route path="/changelog" element={<ChangelogPage />} />
|
<Route path="/changelog" element={<ChangelogPage />} />
|
||||||
</Route>
|
</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",
|
"title": "User Guide",
|
||||||
"description": "Learn how to use all features of the app"
|
"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": {
|
"logs": {
|
||||||
"title": "Logs",
|
"title": "Logs",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
|
|
@ -1229,6 +1235,38 @@
|
||||||
"personnel": "Personal loan repayment"
|
"personnel": "Personal loan repayment"
|
||||||
},
|
},
|
||||||
"internes": "Internal transfers"
|
"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",
|
"title": "Guide d'utilisation",
|
||||||
"description": "Apprenez à utiliser toutes les fonctionnalités de l'application"
|
"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": {
|
"logs": {
|
||||||
"title": "Journaux",
|
"title": "Journaux",
|
||||||
"clear": "Effacer",
|
"clear": "Effacer",
|
||||||
|
|
@ -1229,6 +1235,38 @@
|
||||||
"personnel": "Remboursement prêt perso"
|
"personnel": "Remboursement prêt perso"
|
||||||
},
|
},
|
||||||
"internes": "Transferts internes"
|
"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 AccountCard from "../components/settings/AccountCard";
|
||||||
import LogViewerCard from "../components/settings/LogViewerCard";
|
import LogViewerCard from "../components/settings/LogViewerCard";
|
||||||
import TokenStoreFallbackBanner from "../components/settings/TokenStoreFallbackBanner";
|
import TokenStoreFallbackBanner from "../components/settings/TokenStoreFallbackBanner";
|
||||||
|
import CategoriesCard from "../components/settings/CategoriesCard";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
|
@ -121,6 +122,9 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Categories card — entry to the standard categories guide */}
|
||||||
|
<CategoriesCard />
|
||||||
|
|
||||||
{/* Changelog card */}
|
{/* Changelog card */}
|
||||||
<Link
|
<Link
|
||||||
to="/changelog"
|
to="/changelog"
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,19 @@ body {
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
transition: none !important;
|
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 */
|
/* Scrollbar styling */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue