Simpl-Resultat/src/pages/CategoriesStandardGuidePage.tsx
le king fu f02fd95ab1
All checks were successful
PR Check / rust (pull_request) Successful in 22m55s
PR Check / frontend (pull_request) Successful in 2m27s
refactor(settings): split monolithic Settings page into 3 sub-pages (#190)
The single 12-card SettingsPage is replaced by a hub at /settings linking
to three thematic sub-pages mounted via a shared SettingsLayout (Outlet):

  /settings           SettingsHomePage     (3 cards-cluster + PageHelp)
  /settings/users     UsersSettingsPage    (Account, License, DocsContent)
  /settings/data      DataSettingsPage     (Categories, DataManagement,
                                            PriceFetchConsentToggle)
  /settings/systems   SystemsSettingsPage  (Version, UpdateCard,
                                            ChangelogContent, LogViewer)

DocsPage and ChangelogPage are extracted into reusable DocsContent /
ChangelogContent components and the standalone /docs and /changelog
routes become Navigate redirects to preserve external bookmarks and
release-note links. UpdateCard is extracted from the inline updater
block for symmetry and testability.

TokenStoreFallbackBanner is mounted once in SettingsLayout, surfacing
the OS-keychain-fallback warning across the four main routes only.
The two existing /settings/categories/{standard,migrate} sub-routes
stay flat (siblings of SettingsLayout) to keep their focused flows
free of the banner — their internal back-links now point to
/settings/data.

i18n FR/EN gain settings.{home, users, data, systems, backToHome};
docs/architecture.md and CHANGELOG{,.fr}.md updated. Pure refactor of
presentation: no new business logic, no Tauri commands, no SQL
migrations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:50:02 -04:00

187 lines
6.3 KiB
TypeScript

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/data"
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>
);
}