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:
@@ -13,6 +13,8 @@ export interface CustomModule {
|
||||
name: string;
|
||||
url: string;
|
||||
category: string;
|
||||
/** Reihenfolge innerhalb der Kategorie (nur gemeinsame Eintraege); `null` = nicht sortiert. */
|
||||
sortOrder?: number | null;
|
||||
/** `true` = persoenlicher Eintrag des angemeldeten Benutzers, `false` = gemeinsam (vom Administrator). */
|
||||
personal: boolean;
|
||||
createdAt: string;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Modulkategorien — API-Client (quick-261003-387). Konsumiert
|
||||
* `/module-categories`. Muster `custom-modules-api.ts`: `credentials:
|
||||
* 'include'` fuer Cookie-Auth, `NEXT_PUBLIC_API_URL` als Basis.
|
||||
*/
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
export interface ModuleCategoryInfo {
|
||||
id: string;
|
||||
/** Unveraenderliche Kennung (URL-Segment /modules/<key>/<slug>). */
|
||||
key: string;
|
||||
/** Eigener Name; `null` = Uebersetzung `moduleCategories.<key>`. */
|
||||
name: string | null;
|
||||
sortOrder: number;
|
||||
/** `true` nur fuer „Eigene Module“: umbenennbar, nicht loeschbar. */
|
||||
isSystem: boolean;
|
||||
}
|
||||
|
||||
/** Alle Kategorien der Organisation in ihrer Reihenfolge (jeder Angemeldete). */
|
||||
export async function listModuleCategories(): Promise<ModuleCategoryInfo[]> {
|
||||
const res = await fetch(`${API_URL}/module-categories`, { credentials: 'include' });
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`);
|
||||
return res.json();
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { categoryRank, compareSidebarEntries } from './module-category-order';
|
||||
|
||||
describe('categoryRank', () => {
|
||||
const cats = [{ key: 'b' }, { key: 'a' }];
|
||||
it('liefert den Index in der eingestellten Reihenfolge', () => {
|
||||
expect(categoryRank(cats, 'b')).toBe(0);
|
||||
expect(categoryRank(cats, 'a')).toBe(1);
|
||||
});
|
||||
it('stellt unbekannte Kennungen hinter alle bekannten', () => {
|
||||
expect(categoryRank(cats, 'x')).toBe(2);
|
||||
expect(categoryRank([], 'x')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareSidebarEntries', () => {
|
||||
const e = (name: string, sortOrder: number | null, custom = false) => ({
|
||||
name,
|
||||
sortOrder,
|
||||
custom,
|
||||
});
|
||||
const sorted = (list: ReturnType<typeof e>[]) =>
|
||||
[...list].sort(compareSidebarEntries).map((x) => x.name);
|
||||
|
||||
it('sortOrder aufsteigend, null zuletzt', () => {
|
||||
expect(sorted([e('c', null), e('b', 2), e('a', 1)])).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
it('bei Gleichstand stehen eingebaute Module vor eigenen', () => {
|
||||
expect(sorted([e('Aaa', null, true), e('Zzz', null, false)])).toEqual(['Zzz', 'Aaa']);
|
||||
expect(sorted([e('Aaa', 1, true), e('Zzz', 1, false)])).toEqual(['Zzz', 'Aaa']);
|
||||
});
|
||||
it('danach nach Name', () => {
|
||||
expect(sorted([e('b', null), e('a', null)])).toEqual(['a', 'b']);
|
||||
});
|
||||
it('ein sortierter Eintrag steht vor einem unsortierten, auch wenn der unsortierte eingebaut ist', () => {
|
||||
expect(sorted([e('Eingebaut', null, false), e('Eigenes', 0, true)])).toEqual([
|
||||
'Eigenes',
|
||||
'Eingebaut',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Reihenfolge von Kategorien und Seitenleisten-Eintraegen (quick-261003-387,
|
||||
* E-08). Reine Funktionen — der Stand kommt aus dem Kategorienspeicher.
|
||||
*/
|
||||
|
||||
/** Rang einer Kategorie in der eingestellten Reihenfolge; unbekannte zuletzt (alle gleich). */
|
||||
export function categoryRank(categories: ReadonlyArray<{ key: string }>, key: string): number {
|
||||
const index = categories.findIndex((c) => c.key === key);
|
||||
return index === -1 ? categories.length : index;
|
||||
}
|
||||
|
||||
export interface SortableEntry {
|
||||
name: string;
|
||||
sortOrder: number | null;
|
||||
/** `true` = eigenes Modul. */
|
||||
custom: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reihenfolge innerhalb einer Kategorie: `sortOrder` aufsteigend, `null`
|
||||
* zuletzt; bei Gleichstand eingebaute Module vor eigenen, dann nach Name.
|
||||
*/
|
||||
export function compareSidebarEntries(a: SortableEntry, b: SortableEntry): number {
|
||||
if (a.sortOrder !== b.sortOrder) {
|
||||
if (a.sortOrder === null) return 1;
|
||||
if (b.sortOrder === null) return -1;
|
||||
return a.sortOrder - b.sortOrder;
|
||||
}
|
||||
if (a.custom !== b.custom) return a.custom ? 1 : -1;
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { create } from 'zustand';
|
||||
import { listModuleCategories, type ModuleCategoryInfo } from '@/lib/module-categories-api';
|
||||
|
||||
/**
|
||||
* Geteilter Kategorienstand (quick-261003-387): Beschriftung, Reihenfolge und
|
||||
* Auswahlfelder lesen alle denselben Stand. Geladen wird von der Seitenleiste
|
||||
* (sie ist im Portal immer da) und von Seiten, die die Reihenfolge brauchen
|
||||
* (`ensureLoaded`). Ein Fehler laesst den bisherigen Stand stehen — wie beim
|
||||
* Modulabruf der Seitenleiste.
|
||||
*/
|
||||
interface ModuleCategoryState {
|
||||
categories: ModuleCategoryInfo[];
|
||||
loaded: boolean;
|
||||
load: () => Promise<void>;
|
||||
ensureLoaded: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useModuleCategoryStore = create<ModuleCategoryState>()((set, get) => ({
|
||||
categories: [],
|
||||
loaded: false,
|
||||
load: async () => {
|
||||
try {
|
||||
const categories = await listModuleCategories();
|
||||
set({ categories, loaded: true });
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
},
|
||||
ensureLoaded: async () => {
|
||||
if (!get().loaded) await get().load();
|
||||
},
|
||||
}));
|
||||
@@ -2,16 +2,25 @@
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback } from 'react';
|
||||
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
|
||||
|
||||
/**
|
||||
* Liefert den Anzeigenamen einer Modulkategorie in Alltagssprache
|
||||
* (`domain-tools` → „Domains“). Unbekannte Kategorien behalten ihre Kennung.
|
||||
* (`domain-tools` → „Domains“). Reihenfolge der Quellen (quick-261003-387):
|
||||
* ein von Administratoren gespeicherter Name, dann die Uebersetzung, dann
|
||||
* die Kennung selbst. Der Hook abonniert den Kategorienspeicher, damit
|
||||
* Beschriftungen nach dem Laden oder Umbenennen neu gezeichnet werden.
|
||||
*/
|
||||
export function useCategoryLabel(): (slug: string) => string {
|
||||
const t = useTranslations('moduleCategories');
|
||||
const categories = useModuleCategoryStore((s) => s.categories);
|
||||
return useCallback(
|
||||
// `t.has` fehlt in einfachen Test-Attrappen von next-intl — dann Kennung.
|
||||
(slug: string) => (typeof t.has === 'function' && t.has(slug) ? t(slug) : slug),
|
||||
[t],
|
||||
(slug: string) => {
|
||||
const stored = categories.find((c) => c.key === slug)?.name;
|
||||
if (stored) return stored;
|
||||
// `t.has` fehlt in einfachen Test-Attrappen von next-intl — dann Kennung.
|
||||
return typeof t.has === 'function' && t.has(slug) ? t(slug) : slug;
|
||||
},
|
||||
[t, categories],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user