feat(quick-261003-387): Modulkategorien je Organisation - Tabellen, Grundbestand, Lese-Endpunkt bis in die Seitenleiste

- Tabellen ModuleCategory und ModuleCategoryPlacement mit Zeilenschutz, Spalte CustomModule.sortOrder
- Dienst legt den Grundbestand beim ersten Lesen an, legt die wirksame Kategorie ueber Modullisten
- GET /module-categories, PATCH :key (nur Administratoren), /modules/active liefert wirksame Kategorie
- Seitenleiste ordnet Gruppen und Eintraege nach Kategorienspeicher, zeigt umbenannte Namen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-03 02:33:59 +02:00
parent 53a49a109f
commit 8ec116c75a
21 changed files with 1052 additions and 16 deletions
+29 -9
View File
@@ -1,6 +1,5 @@
'use client';
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
@@ -11,7 +10,9 @@ import { AppVersionBadge } from '@/components/layout/app-version-badge';
import { SidebarSearch } from '@/components/layout/sidebar-search';
import { ModuleTile } from '@/components/modules/module-tile';
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
import { categoryRank, compareSidebarEntries } from '@/lib/module-category-order';
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
import { useNavStore } from '@/lib/stores/nav-store';
import { useSidebarStore } from '@/lib/stores/sidebar-store';
import { useCategoryLabel } from '@/lib/use-category-label';
@@ -34,6 +35,8 @@ interface SidebarModule {
slug: string;
name: string;
category: string;
/** Reihenfolge innerhalb der Kategorie (Administrator); `null`/fehlt = nicht sortiert. */
sortOrder?: number | null;
}
/**
@@ -48,6 +51,10 @@ interface SidebarEntry {
href: string;
/** Kennung fuer das Kachelsymbol; `custom` hat kein eigenes Symbol (allgemeine Kachel). */
tileSlug: string;
/** Reihenfolge innerhalb der Kategorie; `null` = nicht sortiert, steht hinten. */
sortOrder: number | null;
/** `true` = eigenes Modul (steht bei Gleichstand hinter eingebauten). */
custom: boolean;
/** Weitere Pfade, unter denen der Eintrag als ausgewaehlt gilt. */
extraActiveHref?: string;
}
@@ -60,6 +67,8 @@ function moduleEntry(mod: SidebarModule): SidebarEntry {
category,
href: `/modules/${category}/${mod.slug}`,
tileSlug: mod.slug,
sortOrder: mod.sortOrder ?? null,
custom: false,
extraActiveHref: `/modules/${mod.slug}`,
};
}
@@ -71,6 +80,8 @@ function customEntry(mod: CustomModule): SidebarEntry {
category: mod.category || 'other',
href: `/modules/custom/${mod.id}`,
tileSlug: 'custom',
sortOrder: mod.sortOrder ?? null,
custom: true,
};
}
@@ -85,6 +96,8 @@ export function Sidebar() {
// sind der wichtigste Wegweiser. Gemerkt werden die ZUGEKLAPPTEN.
const [closedCategories, setClosedCategories] = useState<Set<string>>(new Set());
const categoryLabel = useCategoryLabel();
const orderedCategoryList = useModuleCategoryStore((s) => s.categories);
const loadCategories = useModuleCategoryStore((s) => s.load);
const setNavModules = useNavStore((s) => s.setModules);
const pathname = usePathname();
const sidebarRefreshKey = useMarketplaceStore((s) => s.sidebarRefreshKey);
@@ -148,8 +161,10 @@ export function Sidebar() {
// silently fail
}
};
await Promise.all([loadBuiltIn(), loadCustom()]);
}, []);
// Kategorien (quick-261003-387) ebenfalls im selben Takt: Reihenfolge und
// Namen stammen aus dem geteilten Kategorienspeicher.
await Promise.all([loadBuiltIn(), loadCustom(), loadCategories()]);
}, [loadCategories]);
// Die Kopfzeile liest den Seitentitel aus dem geteilten Stand; eigene
// Module gehen mit slug = id hinein, damit `resolvePageTitle` das
@@ -166,9 +181,13 @@ export function Sidebar() {
]);
}, [activeModules, customModules, setNavModules]);
// Eingebaute Module stehen innerhalb einer Kategorie vor eigenen.
// Reihenfolge innerhalb einer Kategorie: sortOrder aufsteigend (null zuletzt),
// bei Gleichstand eingebaute Module vor eigenen, dann nach Name.
const entries = useMemo<SidebarEntry[]>(
() => [...activeModules.map(moduleEntry), ...customModules.map(customEntry)],
() =>
[...activeModules.map(moduleEntry), ...customModules.map(customEntry)].sort(
compareSidebarEntries,
),
[activeModules, customModules],
);
@@ -190,8 +209,9 @@ export function Sidebar() {
});
};
// Eintraege nach Kategorie gruppiert: „Eigene Module“ steht immer zuletzt,
// die uebrigen in Fundreihenfolge. Grundlage fuer BEIDE Ansichten.
// Eintraege nach Kategorie gruppiert, Gruppen in der von Administratoren
// eingestellten Reihenfolge (Standard: „Eigene Module“ zuletzt); unbekannte
// Kennungen stehen zuletzt in Fundreihenfolge. Grundlage fuer BEIDE Ansichten.
const orderedCategories = useMemo(() => {
const categories = new Map<string, SidebarEntry[]>();
for (const entry of entries) {
@@ -200,9 +220,9 @@ export function Sidebar() {
else categories.set(entry.category, [entry]);
}
return Array.from(categories.entries()).sort(
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
([a], [b]) => categoryRank(orderedCategoryList, a) - categoryRank(orderedCategoryList, b),
);
}, [entries]);
}, [entries, orderedCategoryList]);
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — die Eintraege
// stehen gruppenweise hintereinander, „Eigene Module“ also auch hier zuletzt.