feat(quick-261003-387): Verwaltungsseite Kategorien, Marktplatz-Reihenfolge, Formular, Texte, CHANGELOG und Handbuch
- Administrator -> Module -> Kategorien: anlegen, umbenennen, sortieren, Eintraege zuordnen und sortieren, loeschen mit Zielauswahl - Marktplatz-Filter folgen der Kategorienreihenfolge, Kategorieseite und Modulliste zeigen gespeicherte Namen - Formular fuer eigene Module bietet alle Kategorien der Organisation an - Texte de/en, CHANGELOG, Handbuch Administration und Anwender Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -113,6 +113,7 @@ vi.mock('@/lib/stores/auth-store', () => ({
|
||||
}));
|
||||
|
||||
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
|
||||
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
|
||||
import AdminCustomModulesPage from './page';
|
||||
|
||||
const wiki = {
|
||||
@@ -236,6 +237,47 @@ describe('AdminCustomModulesPage', () => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Anlegen: das Formular bietet alle Kategorien der Organisation in ihrer Reihenfolge an', async () => {
|
||||
useModuleCategoryStore.setState({
|
||||
loaded: true,
|
||||
categories: [
|
||||
{ id: 'a', key: 'werkzeuge-tools', name: 'Werkzeuge', sortOrder: 0, isSystem: false },
|
||||
{ id: 'b', key: 'infrastructure', name: null, sortOrder: 1, isSystem: false },
|
||||
{ id: 'c', key: 'custom-modules', name: null, sortOrder: 2, isSystem: true },
|
||||
],
|
||||
});
|
||||
render(<AdminCustomModulesPage />);
|
||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||
await userEvent.click(
|
||||
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||
);
|
||||
|
||||
const options = within(screen.getByLabelText('Kategorie'))
|
||||
.getAllByRole('option')
|
||||
.map((o) => o.getAttribute('value'));
|
||||
expect(options).toEqual(['werkzeuge-tools', 'infrastructure', 'custom-modules']);
|
||||
useModuleCategoryStore.setState({ categories: [], loaded: false });
|
||||
});
|
||||
|
||||
it('Bearbeiten: ein vorhandener Wert, der nicht im Speicher steht, bleibt als Option erhalten', async () => {
|
||||
useModuleCategoryStore.setState({
|
||||
loaded: true,
|
||||
categories: [
|
||||
{ id: 'c', key: 'custom-modules', name: null, sortOrder: 0, isSystem: true },
|
||||
],
|
||||
});
|
||||
mockList.mockResolvedValue([wiki]);
|
||||
render(<AdminCustomModulesPage />);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Bearbeiten' }));
|
||||
|
||||
const select = within(screen.getByRole('dialog')).getByLabelText('Kategorie');
|
||||
expect(select).toHaveValue('infrastructure');
|
||||
expect(
|
||||
within(select).getAllByRole('option').map((o) => o.getAttribute('value')),
|
||||
).toEqual(['custom-modules', 'infrastructure']);
|
||||
useModuleCategoryStore.setState({ categories: [], loaded: false });
|
||||
});
|
||||
|
||||
it('Anlegen: die Kategorie ist vorbelegt mit „Eigene Module“', async () => {
|
||||
render(<AdminCustomModulesPage />);
|
||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import de from '@/messages/de.json';
|
||||
|
||||
// next-intl-Attrappe, die die ECHTEN deutschen Texte aus de.json aufloest
|
||||
// (so faellt ein fehlender Schluessel im Test auf). {param} wird ersetzt, die
|
||||
// eine ICU-Mehrzahl der Seite („one {...} other {...}“) mit aufgeloest.
|
||||
function resolve(ns: string, key: string, params?: Record<string, unknown>): string {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let val: any = de;
|
||||
for (const part of [...ns.split('.'), ...key.split('.')]) val = val?.[part];
|
||||
if (typeof val !== 'string') return `${ns}.${key}`;
|
||||
const values = params ?? {};
|
||||
val = val.replace(
|
||||
/\{(\w+), plural, one \{([^}]*)\} other \{([^}]*)\}\}/g,
|
||||
(_m: string, k: string, one: string, other: string) =>
|
||||
(Number(values[k]) === 1 ? one : other).replace('#', String(values[k])),
|
||||
);
|
||||
for (const [k, v] of Object.entries(values)) val = val.replaceAll(`{${k}}`, String(v));
|
||||
return val;
|
||||
}
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) =>
|
||||
resolve(ns, key, params),
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockAuthStore = vi.fn();
|
||||
vi.mock('@/lib/stores/auth-store', () => ({
|
||||
useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector),
|
||||
}));
|
||||
|
||||
const mockBump = vi.fn();
|
||||
vi.mock('@/lib/stores/marketplace-store', () => ({
|
||||
useMarketplaceStore: (selector: (state: { bumpSidebarRefresh: () => void }) => unknown) =>
|
||||
selector({ bumpSidebarRefresh: mockBump }),
|
||||
}));
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getModuleCategoryOverview: vi.fn(),
|
||||
createModuleCategory: vi.fn(),
|
||||
renameModuleCategory: vi.fn(),
|
||||
reorderModuleCategories: vi.fn(),
|
||||
reorderModuleCategoryItems: vi.fn(),
|
||||
assignModuleCategory: vi.fn(),
|
||||
deleteModuleCategory: vi.fn(),
|
||||
listModuleCategories: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/lib/module-categories-api', () => ({
|
||||
...api,
|
||||
ModuleCategoryRequestError: class extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
|
||||
import AdminModuleCategoriesPage from './page';
|
||||
|
||||
function category(
|
||||
key: string,
|
||||
name: string | null,
|
||||
sortOrder: number,
|
||||
items: Array<{ type: 'module' | 'custom'; id: string; name: string }> = [],
|
||||
extra: { isSystem?: boolean; personalCount?: number } = {},
|
||||
) {
|
||||
return {
|
||||
id: `id-${key}`,
|
||||
key,
|
||||
name,
|
||||
sortOrder,
|
||||
isSystem: extra.isSystem ?? false,
|
||||
items,
|
||||
personalCount: extra.personalCount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function standardOverview() {
|
||||
return [
|
||||
category('fleet', 'Fuhrpark', 0, [
|
||||
{ type: 'module', id: 'm1', name: 'DKV' },
|
||||
{ type: 'custom', id: 'c1', name: 'Tankportal' },
|
||||
]),
|
||||
category('infrastructure', 'Infrastruktur', 1, [{ type: 'module', id: 'm2', name: 'Proxmox' }]),
|
||||
category('procurement', 'Beschaffung', 2, [], { personalCount: 2 }),
|
||||
category('custom-modules', 'Eigene Module', 3, [], { isSystem: true }),
|
||||
];
|
||||
}
|
||||
|
||||
function stubUser(role: string) {
|
||||
mockAuthStore.mockImplementation(
|
||||
(selector: (state: { user: { id: string; role: string; tenantId: string } }) => unknown) =>
|
||||
selector({ user: { id: 'u1', role, tenantId: 't1' } }),
|
||||
);
|
||||
}
|
||||
|
||||
function card(key: string) {
|
||||
return screen.getByTestId(`category-${key}`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stubUser('ADMIN');
|
||||
api.getModuleCategoryOverview.mockResolvedValue(standardOverview());
|
||||
api.listModuleCategories.mockResolvedValue([]);
|
||||
for (const fn of [
|
||||
api.createModuleCategory,
|
||||
api.renameModuleCategory,
|
||||
api.reorderModuleCategories,
|
||||
api.reorderModuleCategoryItems,
|
||||
api.assignModuleCategory,
|
||||
api.deleteModuleCategory,
|
||||
]) {
|
||||
fn.mockResolvedValue({});
|
||||
}
|
||||
useModuleCategoryStore.setState({ categories: [], loaded: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function renderPage() {
|
||||
render(<AdminModuleCategoriesPage />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Fuhrpark' })).toBeInTheDocument();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Kategorien-Verwaltungsseite', () => {
|
||||
it('listet die Kategorien in Reihenfolge mit ihren Eintraegen', async () => {
|
||||
await renderPage();
|
||||
const headings = screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent);
|
||||
expect(headings).toEqual(['Fuhrpark', 'Infrastruktur', 'Beschaffung', 'Eigene Module']);
|
||||
expect(within(card('fleet')).getByText('DKV')).toBeInTheDocument();
|
||||
expect(within(card('fleet')).getByText('Tankportal')).toBeInTheDocument();
|
||||
expect(within(card('fleet')).getByText('Eigenes Modul')).toBeInTheDocument();
|
||||
expect(
|
||||
within(card('procurement')).getByText('Keine Module in dieser Kategorie'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(card('procurement')).getByText('Außerdem 2 persönliche Einträge von Benutzern'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Zurück zu Module' })).toHaveAttribute(
|
||||
'href',
|
||||
'/admin/modules',
|
||||
);
|
||||
});
|
||||
|
||||
it('sperrt Pfeil nach oben bei der ersten und nach unten bei der letzten Kategorie und beim Eintrag', async () => {
|
||||
await renderPage();
|
||||
const up = 'Kategorie nach oben verschieben';
|
||||
const down = 'Kategorie nach unten verschieben';
|
||||
expect(within(card('fleet')).getByRole('button', { name: up })).toBeDisabled();
|
||||
expect(within(card('fleet')).getByRole('button', { name: down })).toBeEnabled();
|
||||
expect(within(card('custom-modules')).getByRole('button', { name: down })).toBeDisabled();
|
||||
expect(within(card('infrastructure')).getByRole('button', { name: up })).toBeEnabled();
|
||||
|
||||
const itemUp = within(card('fleet')).getAllByRole('button', {
|
||||
name: 'Eintrag nach oben verschieben',
|
||||
});
|
||||
const itemDown = within(card('fleet')).getAllByRole('button', {
|
||||
name: 'Eintrag nach unten verschieben',
|
||||
});
|
||||
expect(itemUp[0]).toBeDisabled();
|
||||
expect(itemDown[itemDown.length - 1]).toBeDisabled();
|
||||
});
|
||||
|
||||
it('„Kategorie anlegen“ sendet den Namen, laedt neu und frischt die Seitenleiste auf', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await user.type(screen.getByLabelText('Neue Kategorie'), ' Werkzeuge ');
|
||||
await user.click(screen.getByRole('button', { name: 'Kategorie anlegen' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.createModuleCategory).toHaveBeenCalledWith('Werkzeuge');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockBump).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// Uebersicht (1x beim Laden, 1x danach) und Kategorienspeicher neu geladen.
|
||||
expect(api.getModuleCategoryOverview).toHaveBeenCalledTimes(2);
|
||||
expect(api.listModuleCategories).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('„Kategorie anlegen“ ist ohne Namen gesperrt', async () => {
|
||||
await renderPage();
|
||||
expect(screen.getByRole('button', { name: 'Kategorie anlegen' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('Umbenennen sendet PATCH mit dem neuen Namen', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await user.click(within(card('infrastructure')).getByRole('button', { name: 'Umbenennen' }));
|
||||
const input = within(card('infrastructure')).getByLabelText('Neuer Name');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Technik');
|
||||
await user.click(within(card('infrastructure')).getByRole('button', { name: 'Speichern' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.renameModuleCategory).toHaveBeenCalledWith('infrastructure', 'Technik');
|
||||
});
|
||||
});
|
||||
|
||||
it('Pfeile verschieben die Kategorie und senden die vollstaendige neue Reihenfolge', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await user.click(
|
||||
within(card('infrastructure')).getByRole('button', {
|
||||
name: 'Kategorie nach oben verschieben',
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(api.reorderModuleCategories).toHaveBeenCalledWith([
|
||||
'infrastructure',
|
||||
'fleet',
|
||||
'procurement',
|
||||
'custom-modules',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('Pfeile verschieben einen Eintrag und senden die vollstaendige Reihenfolge der Kategorie', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await user.click(
|
||||
within(card('fleet')).getAllByRole('button', { name: 'Eintrag nach unten verschieben' })[0],
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(api.reorderModuleCategoryItems).toHaveBeenCalledWith('fleet', [
|
||||
{ type: 'custom', id: 'c1' },
|
||||
{ type: 'module', id: 'm1' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('das Auswahlfeld je Eintrag sendet die Zuordnung', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await user.selectOptions(screen.getByLabelText('Kategorie für Proxmox'), 'fleet');
|
||||
await waitFor(() => {
|
||||
expect(api.assignModuleCategory).toHaveBeenCalledWith({
|
||||
type: 'module',
|
||||
id: 'm2',
|
||||
categoryKey: 'fleet',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('„Eigene Module“ ist nicht loeschbar, hat aber Pfeile und „Umbenennen“', async () => {
|
||||
await renderPage();
|
||||
const custom = card('custom-modules');
|
||||
expect(within(custom).getByRole('button', { name: 'Löschen' })).toBeDisabled();
|
||||
expect(
|
||||
within(custom).getByText('Diese Kategorie kann nicht gelöscht werden'),
|
||||
).toBeInTheDocument();
|
||||
expect(within(custom).getByRole('button', { name: 'Umbenennen' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('eine leere Kategorie: einfache Rueckfrage, DELETE ohne Ziel', async () => {
|
||||
const user = userEvent.setup();
|
||||
api.getModuleCategoryOverview.mockResolvedValue([
|
||||
...standardOverview().slice(0, 2),
|
||||
category('leer', 'Leer', 2),
|
||||
standardOverview()[3],
|
||||
]);
|
||||
await renderPage();
|
||||
await user.click(within(card('leer')).getByRole('button', { name: 'Löschen' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(
|
||||
within(dialog).getByText('Die Kategorie „Leer“ ist leer und wird gelöscht.'),
|
||||
).toBeInTheDocument();
|
||||
expect(within(dialog).queryByRole('combobox')).toBeNull();
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Löschen' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.deleteModuleCategory).toHaveBeenCalledWith('leer', undefined);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('eine nicht leere Kategorie: Zielauswahl ohne die zu loeschende, DELETE mit moveTo', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await user.click(within(card('fleet')).getByRole('button', { name: 'Löschen' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(within(dialog).getByText(/Es geht nichts verloren/)).toBeInTheDocument();
|
||||
const select = within(dialog).getByLabelText('Verschieben nach');
|
||||
const options = within(select)
|
||||
.getAllByRole('option')
|
||||
.map((o) => o.getAttribute('value'));
|
||||
expect(options).toEqual(['infrastructure', 'procurement', 'custom-modules']);
|
||||
await user.selectOptions(select, 'procurement');
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Verschieben und löschen' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.deleteModuleCategory).toHaveBeenCalledWith('fleet', 'procurement');
|
||||
});
|
||||
});
|
||||
|
||||
it('eine Kategorie nur mit persoenlichen Eintraegen verlangt ebenfalls ein Ziel', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderPage();
|
||||
await user.click(within(card('procurement')).getByRole('button', { name: 'Löschen' }));
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByLabelText('Verschieben nach'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ein Fehler beim Loeschen laesst den Dialog offen und zeigt ihn', async () => {
|
||||
const user = userEvent.setup();
|
||||
api.deleteModuleCategory.mockRejectedValue(new Error('x'));
|
||||
await renderPage();
|
||||
await user.click(within(card('fleet')).getByRole('button', { name: 'Löschen' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Verschieben und löschen' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(screen.getByRole('dialog')).getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('ein Fehler beim Speichern zeigt einen Hinweis und laedt den Stand neu', async () => {
|
||||
const user = userEvent.setup();
|
||||
api.reorderModuleCategories.mockRejectedValue(new Error('x'));
|
||||
await renderPage();
|
||||
await user.click(
|
||||
within(card('infrastructure')).getByRole('button', {
|
||||
name: 'Kategorie nach oben verschieben',
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'Die Änderung konnte nicht gespeichert werden',
|
||||
);
|
||||
});
|
||||
expect(mockBump).not.toHaveBeenCalled();
|
||||
expect(api.getModuleCategoryOverview).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('ein Nicht-Administrator sieht den Zugriffshinweis, es wird nichts geladen', async () => {
|
||||
stubUser('USER');
|
||||
render(<AdminModuleCategoriesPage />);
|
||||
expect(await screen.findByText('Zugriff verweigert')).toBeInTheDocument();
|
||||
expect(api.getModuleCategoryOverview).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,502 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
assignModuleCategory,
|
||||
createModuleCategory,
|
||||
deleteModuleCategory,
|
||||
getModuleCategoryOverview,
|
||||
type ModuleCategoryOverview,
|
||||
type ModuleCategoryOverviewItem,
|
||||
ModuleCategoryRequestError,
|
||||
renameModuleCategory,
|
||||
reorderModuleCategories,
|
||||
reorderModuleCategoryItems,
|
||||
} from '@/lib/module-categories-api';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
|
||||
/** Pfeil nach oben/unten fuer die Sortierknoepfe. */
|
||||
function Arrow({ direction }: { direction: 'up' | 'down' }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points={direction === 'up' ? '18 15 12 9 6 15' : '6 9 12 15 18 9'} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const arrowButtonClass =
|
||||
'inline-flex h-8 w-8 items-center justify-center rounded-md border border-border bg-card text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-40';
|
||||
|
||||
/** Vertauscht zwei Nachbarn einer Liste (Pfeil nach oben/unten). */
|
||||
function swapped<T>(list: T[], index: number, direction: -1 | 1): T[] {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= list.length) return list;
|
||||
const next = [...list];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
return next;
|
||||
}
|
||||
|
||||
interface DeleteDialogProps {
|
||||
category: ModuleCategoryOverview;
|
||||
others: Array<{ key: string; label: string }>;
|
||||
hasContents: boolean;
|
||||
label: string;
|
||||
onCancel: () => void;
|
||||
/** Loescht; wirft bei Fehler, damit der Dialog offen bleibt. */
|
||||
onConfirm: (moveTo: string | undefined) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loeschdialog (Vorbild DeleteGroupDialog). Leere Kategorie: einfache
|
||||
* Rueckfrage. Nicht leere: Zielkategorie waehlen — alle Module einschliesslich
|
||||
* persoenlicher Eintraege von Benutzern wandern dorthin, nichts geht verloren.
|
||||
* Bei einem Fehler bleibt der Dialog offen und zeigt ihn.
|
||||
*/
|
||||
function DeleteCategoryDialog({
|
||||
category,
|
||||
others,
|
||||
hasContents,
|
||||
label,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: DeleteDialogProps) {
|
||||
const t = useTranslations('adminModules.categories');
|
||||
const [target, setTarget] = useState(others[0]?.key ?? '');
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const confirm = async () => {
|
||||
setDeleting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onConfirm(hasContents ? target : undefined);
|
||||
} catch {
|
||||
setError(t('saveError'));
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-category-title"
|
||||
className="w-full max-w-md rounded-lg bg-card p-6 shadow-lg dark:border dark:border-border"
|
||||
>
|
||||
<h2 id="delete-category-title" className="mb-4 text-lg font-semibold text-foreground">
|
||||
{t('deleteDialog.title')}
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-foreground">
|
||||
{hasContents
|
||||
? t('deleteDialog.bodyMove', { name: label })
|
||||
: t('deleteDialog.bodyEmpty', { name: label })}
|
||||
</p>
|
||||
{hasContents && (
|
||||
<div className="mb-4 space-y-2">
|
||||
<label htmlFor="delete-category-target" className="text-sm font-medium text-foreground">
|
||||
{t('deleteDialog.target')}
|
||||
</label>
|
||||
<select
|
||||
id="delete-category-target"
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{others.map((o) => (
|
||||
<option key={o.key} value={o.key}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button type="button" onClick={onCancel} className="btn btn-secondary">
|
||||
{t('deleteDialog.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirm}
|
||||
disabled={deleting || (hasContents && target === '')}
|
||||
data-category-key={category.key}
|
||||
className="rounded-md bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{hasContents ? t('deleteDialog.confirmMove') : t('deleteDialog.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwaltung der Modulkategorien (quick-261003-387): Administratoren legen
|
||||
* Kategorien an, benennen sie um, sortieren sie, ordnen Module zu, sortieren
|
||||
* Eintraege innerhalb einer Kategorie und loeschen Kategorien (mit Ziel fuer
|
||||
* den Inhalt). Unterseite von /admin/modules. Jede Aenderung laedt Uebersicht
|
||||
* und Kategorienspeicher neu und frischt die Seitenleiste auf.
|
||||
*/
|
||||
export default function AdminModuleCategoriesPage() {
|
||||
const t = useTranslations('adminModules.categories');
|
||||
const tCommon = useTranslations('common');
|
||||
const categoryLabel = useCategoryLabel();
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const bumpSidebarRefresh = useMarketplaceStore((s) => s.bumpSidebarRefresh);
|
||||
const loadCategories = useModuleCategoryStore((s) => s.load);
|
||||
|
||||
const hasAccess = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
|
||||
|
||||
const [overview, setOverview] = useState<ModuleCategoryOverview[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [renamingKey, setRenamingKey] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<ModuleCategoryOverview | null>(null);
|
||||
|
||||
// Anzeigename: der frisch geladene gespeicherte Name, sonst Uebersetzung/Kennung.
|
||||
const labelOf = useCallback(
|
||||
(c: Pick<ModuleCategoryOverview, 'key' | 'name'>) => c.name ?? categoryLabel(c.key),
|
||||
[categoryLabel],
|
||||
);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
setOverview(await getModuleCategoryOverview());
|
||||
setLoadError(false);
|
||||
} catch {
|
||||
setLoadError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAccess) {
|
||||
reload();
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [hasAccess, reload]);
|
||||
|
||||
// Nach jeder Aenderung: Uebersicht + Kategorienspeicher neu, Seitenleiste auffrischen.
|
||||
const afterChange = useCallback(async () => {
|
||||
await Promise.all([reload(), loadCategories()]);
|
||||
bumpSidebarRefresh();
|
||||
}, [reload, loadCategories, bumpSidebarRefresh]);
|
||||
|
||||
const run = async (action: () => Promise<unknown>): Promise<boolean> => {
|
||||
setBusy(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await action();
|
||||
await afterChange();
|
||||
return true;
|
||||
} catch (err) {
|
||||
const detail =
|
||||
err instanceof ModuleCategoryRequestError && (err.status === 400 || err.status === 409)
|
||||
? ` ${err.message}`
|
||||
: '';
|
||||
setActionError(`${t('saveError')}${detail}`);
|
||||
// Stand neu holen, damit die Anzeige nicht von der Datenbank abweicht.
|
||||
await reload();
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const name = newName.trim();
|
||||
if (name === '') return;
|
||||
if (await run(() => createModuleCategory(name))) setNewName('');
|
||||
};
|
||||
|
||||
const moveCategory = (index: number, direction: -1 | 1) => {
|
||||
const keys = swapped(
|
||||
overview.map((c) => c.key),
|
||||
index,
|
||||
direction,
|
||||
);
|
||||
return run(() => reorderModuleCategories(keys));
|
||||
};
|
||||
|
||||
const moveItem = (category: ModuleCategoryOverview, index: number, direction: -1 | 1) => {
|
||||
const items = swapped(category.items, index, direction).map(({ type, id }) => ({ type, id }));
|
||||
return run(() => reorderModuleCategoryItems(category.key, items));
|
||||
};
|
||||
|
||||
const assign = (item: ModuleCategoryOverviewItem, categoryKey: string) =>
|
||||
run(() => assignModuleCategory({ type: item.type, id: item.id, categoryKey }));
|
||||
|
||||
const startRename = (category: ModuleCategoryOverview) => {
|
||||
setRenamingKey(category.key);
|
||||
setRenameValue(labelOf(category));
|
||||
};
|
||||
|
||||
const saveRename = async (category: ModuleCategoryOverview) => {
|
||||
const name = renameValue.trim();
|
||||
if (name === '') return;
|
||||
if (await run(() => renameModuleCategory(category.key, name))) setRenamingKey(null);
|
||||
};
|
||||
|
||||
if (!hasAccess) {
|
||||
return (
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<p className="text-lg text-muted-foreground">{tCommon('accessDenied')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Link href="/admin/modules" className="text-sm font-medium link">
|
||||
{t('backToModules')}
|
||||
</Link>
|
||||
<h1 className="mt-2 text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
||||
</div>
|
||||
|
||||
{(loadError || actionError) && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
||||
>
|
||||
{actionError ?? t('loadError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleCreate} className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="new-category-name" className="text-sm font-medium text-foreground">
|
||||
{t('createLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="new-category-name"
|
||||
type="text"
|
||||
maxLength={60}
|
||||
value={newName}
|
||||
placeholder={t('createPlaceholder')}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="flex h-10 w-72 max-w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={busy || newName.trim() === ''} className="btn btn-primary">
|
||||
{t('createButton')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground">{tCommon('loading')}</p>
|
||||
) : (
|
||||
<ul className="grid gap-4">
|
||||
{overview.map((category, index) => {
|
||||
const label = labelOf(category);
|
||||
const renaming = renamingKey === category.key;
|
||||
return (
|
||||
<li
|
||||
key={category.key}
|
||||
data-testid={`category-${category.key}`}
|
||||
className="rounded-lg border border-border bg-card p-4"
|
||||
>
|
||||
<section aria-label={label}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('moveUp')}
|
||||
title={t('moveUp')}
|
||||
disabled={busy || index === 0}
|
||||
onClick={() => moveCategory(index, -1)}
|
||||
className={arrowButtonClass}
|
||||
>
|
||||
<Arrow direction="up" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('moveDown')}
|
||||
title={t('moveDown')}
|
||||
disabled={busy || index === overview.length - 1}
|
||||
onClick={() => moveCategory(index, 1)}
|
||||
className={arrowButtonClass}
|
||||
>
|
||||
<Arrow direction="down" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{renaming ? (
|
||||
<form
|
||||
className="flex flex-1 flex-wrap items-center gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
saveRename(category);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t('renameLabel')}
|
||||
maxLength={60}
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
className="flex h-9 w-64 max-w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || renameValue.trim() === ''}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('renameSave')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenamingKey(null)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('renameCancel')}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="flex-1 text-base font-semibold text-foreground">{label}</h2>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => startRename(category)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('rename')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || category.isSystem}
|
||||
title={category.isSystem ? t('deleteBlocked') : undefined}
|
||||
onClick={() => setDeleteTarget(category)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('delete')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{category.isSystem && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('deleteBlocked')}</p>
|
||||
)}
|
||||
|
||||
{category.items.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">{t('empty')}</p>
|
||||
) : (
|
||||
<ul className="mt-3 divide-y divide-border rounded-md border border-border">
|
||||
{category.items.map((item, itemIndex) => (
|
||||
<li
|
||||
key={`${item.type}:${item.id}`}
|
||||
className="flex flex-wrap items-center gap-2 px-3 py-2"
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('itemMoveUp')}
|
||||
title={t('itemMoveUp')}
|
||||
disabled={busy || itemIndex === 0}
|
||||
onClick={() => moveItem(category, itemIndex, -1)}
|
||||
className={arrowButtonClass}
|
||||
>
|
||||
<Arrow direction="up" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('itemMoveDown')}
|
||||
title={t('itemMoveDown')}
|
||||
disabled={busy || itemIndex === category.items.length - 1}
|
||||
onClick={() => moveItem(category, itemIndex, 1)}
|
||||
className={arrowButtonClass}
|
||||
>
|
||||
<Arrow direction="down" />
|
||||
</button>
|
||||
</div>
|
||||
<span className="flex-1 text-sm text-foreground">
|
||||
{item.name}
|
||||
{item.type === 'custom' && (
|
||||
<span className="ml-2 rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{t('customBadge')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<select
|
||||
aria-label={t('itemCategoryLabel', { name: item.name })}
|
||||
title={t('itemCategory')}
|
||||
value={category.key}
|
||||
disabled={busy}
|
||||
onChange={(e) => assign(item, e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-2 text-sm"
|
||||
>
|
||||
{overview.map((c) => (
|
||||
<option key={c.key} value={c.key}>
|
||||
{labelOf(c)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{category.personalCount > 0 && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t('personalNote', { count: category.personalCount })}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{deleteTarget && (
|
||||
<DeleteCategoryDialog
|
||||
category={deleteTarget}
|
||||
label={labelOf(deleteTarget)}
|
||||
hasContents={deleteTarget.items.length > 0 || deleteTarget.personalCount > 0}
|
||||
others={overview
|
||||
.filter((c) => c.key !== deleteTarget.key)
|
||||
.map((c) => ({ key: c.key, label: labelOf(c) }))}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={async (moveTo) => {
|
||||
await deleteModuleCategory(deleteTarget.key, moveTo);
|
||||
setDeleteTarget(null);
|
||||
await afterChange();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
import { ActivateModuleDialog } from './components/ActivateModuleDialog';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
@@ -29,6 +30,7 @@ interface TenantActivation {
|
||||
export default function AdminModulesPage() {
|
||||
const t = useTranslations('adminModules');
|
||||
const tCommon = useTranslations('common');
|
||||
const categoryLabel = useCategoryLabel();
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
// Shared signal that makes the sidebar refetch its active-module list
|
||||
// (same mechanism the marketplace pages use). Without bumping this after a
|
||||
@@ -137,12 +139,20 @@ export default function AdminModulesPage() {
|
||||
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/modules/grants"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('grantsLink')}
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/admin/modules/categories"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('categoriesLink')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/modules/grants"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('grantsLink')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -178,7 +188,7 @@ export default function AdminModulesPage() {
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{mod.category}
|
||||
{categoryLabel(mod.category)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground truncate">
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
// Statisch statt je Test dynamisch importiert: Laden/Umwandeln der Seite faellt
|
||||
// in die Sammelphase, nicht in die 5-s-Frist eines Tests (quick-260924-m4n).
|
||||
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
|
||||
import Page from './page';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
@@ -197,6 +198,27 @@ describe('Marketplace Filters', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('Kategorie-Chips folgen der eingestellten Kategorienreihenfolge (quick-261003-387)', async () => {
|
||||
useModuleCategoryStore.setState({
|
||||
loaded: true,
|
||||
categories: [
|
||||
{ id: 'u', key: 'Utilities', name: null, sortOrder: 0, isSystem: false },
|
||||
{ id: 'd', key: 'Domain-Tools', name: 'Meine Domains', sortOrder: 1, isSystem: false },
|
||||
],
|
||||
});
|
||||
render(<Page />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Domaincheck')).toBeInTheDocument();
|
||||
});
|
||||
const chips = ['Utilities', 'Meine Domains'].map((name) =>
|
||||
screen.getByRole('button', { name }),
|
||||
);
|
||||
// compareDocumentPosition: 4 = „folgt“ — der erste Chip steht vor dem zweiten.
|
||||
expect(chips[0].compareDocumentPosition(chips[1]) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
useModuleCategoryStore.setState({ categories: [], loaded: false });
|
||||
});
|
||||
|
||||
it('search + status + category filters compose together (AND)', async () => {
|
||||
render(<Page />);
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { categoryRank } from '@/lib/module-category-order';
|
||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
|
||||
import { MarketplaceCard } from './components/MarketplaceCard';
|
||||
import { MarketplaceSearch } from './components/MarketplaceSearch';
|
||||
import { StatusFilter, type StatusValue } from './components/StatusFilter';
|
||||
@@ -43,6 +45,9 @@ export default function MarketplacePage() {
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore();
|
||||
const addToast = useToastStore((s) => s.addToast);
|
||||
// Reihenfolge der Filter-Chips = eingestellte Kategorienreihenfolge (quick-261003-387).
|
||||
const orderedCategories = useModuleCategoryStore((s) => s.categories);
|
||||
const ensureCategories = useModuleCategoryStore((s) => s.ensureLoaded);
|
||||
|
||||
const [modules, setModules] = useState<CatalogModule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -85,6 +90,10 @@ export default function MarketplacePage() {
|
||||
fetchModules();
|
||||
}, [fetchModules]);
|
||||
|
||||
useEffect(() => {
|
||||
ensureCategories();
|
||||
}, [ensureCategories]);
|
||||
|
||||
|
||||
const locale = typeof window !== 'undefined' ? document.documentElement.lang || 'de' : 'de';
|
||||
|
||||
@@ -104,9 +113,15 @@ export default function MarketplacePage() {
|
||||
});
|
||||
}, [modules, searchQuery, statusFilter, categoryFilter, locale]);
|
||||
|
||||
// Chips in der eingestellten Kategorienreihenfolge; unbekannte Kennungen
|
||||
// (Speicher noch nicht geladen) stehen zuletzt in Fundreihenfolge — der
|
||||
// Server liefert die Module schon in dieser Reihenfolge.
|
||||
const categories = useMemo(
|
||||
() => [...new Set(modules.map((m) => m.category))].sort(),
|
||||
[modules],
|
||||
() =>
|
||||
[...new Set(modules.map((m) => m.category))].sort(
|
||||
(a, b) => categoryRank(orderedCategories, a) - categoryRank(orderedCategories, b),
|
||||
),
|
||||
[modules, orderedCategories],
|
||||
);
|
||||
|
||||
const statusCounts = useMemo(() => ({
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
import { ModuleCard, type ModuleCardProps } from './components/ModuleCard';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
@@ -46,6 +47,7 @@ export default function CategoryPage() {
|
||||
const params = useParams<{ category: string }>();
|
||||
const category = params.category;
|
||||
const t = useTranslations('modules');
|
||||
const categoryLabel = useCategoryLabel();
|
||||
|
||||
const [modules, setModules] = useState<ModuleCardProps[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -89,7 +91,10 @@ export default function CategoryPage() {
|
||||
fetchModules();
|
||||
}, [category]);
|
||||
|
||||
const categoryTitle = formatCategoryName(category);
|
||||
// Gespeicherter Name, Uebersetzung oder — fuer eine Kennung ohne beides —
|
||||
// die lesbar gemachte Kennung (quick-261003-387).
|
||||
const label = categoryLabel(category);
|
||||
const categoryTitle = label === category ? formatCategoryName(category) : label;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { CUSTOM_MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
type CustomModule,
|
||||
CustomModuleRequestError,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
updateCustomModule,
|
||||
} from '@/lib/custom-modules-api';
|
||||
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
||||
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
|
||||
interface CustomModuleFormModalProps {
|
||||
@@ -63,6 +64,8 @@ export function CustomModuleFormModal({
|
||||
const t = useTranslations('customModules.form');
|
||||
const tCommon = useTranslations('common');
|
||||
const categoryLabel = useCategoryLabel();
|
||||
const storeCategories = useModuleCategoryStore((s) => s.categories);
|
||||
const ensureCategories = useModuleCategoryStore((s) => s.ensureLoaded);
|
||||
|
||||
const [name, setName] = useState(mod?.name ?? '');
|
||||
const [url, setUrl] = useState(mod?.url ?? '');
|
||||
@@ -72,6 +75,20 @@ export function CustomModuleFormModal({
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Alle Kategorien der Organisation in der eingestellten Reihenfolge
|
||||
// (quick-261003-387). Solange der Speicher leer ist, gilt die Standardliste;
|
||||
// ein vorhandener Wert, der (noch) nicht im Speicher steht, bleibt Option.
|
||||
useEffect(() => {
|
||||
ensureCategories();
|
||||
}, [ensureCategories]);
|
||||
const categoryOptions = useMemo(() => {
|
||||
const keys: string[] =
|
||||
storeCategories.length > 0
|
||||
? storeCategories.map((c) => c.key)
|
||||
: [...CUSTOM_MODULE_CATEGORIES];
|
||||
return keys.includes(category) ? keys : [...keys, category];
|
||||
}, [storeCategories, category]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
@@ -167,7 +184,7 @@ export function CustomModuleFormModal({
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{CUSTOM_MODULE_CATEGORIES.map((c) => (
|
||||
{categoryOptions.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{categoryLabel(c)}
|
||||
</option>
|
||||
|
||||
@@ -17,9 +17,108 @@ export interface ModuleCategoryInfo {
|
||||
isSystem: boolean;
|
||||
}
|
||||
|
||||
/** Ein Eintrag einer Kategorie in der Verwaltungsuebersicht. */
|
||||
export interface ModuleCategoryOverviewItem {
|
||||
/** `module` = Marktplatz-Modul, `custom` = gemeinsames eigenes Modul. */
|
||||
type: 'module' | 'custom';
|
||||
id: string;
|
||||
name: string;
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
/** Eine Kategorie samt Eintraegen fuer die Verwaltungsseite. */
|
||||
export interface ModuleCategoryOverview extends ModuleCategoryInfo {
|
||||
items: ModuleCategoryOverviewItem[];
|
||||
/** Anzahl persoenlicher eigener Module von Benutzern (nie einzeln benannt). */
|
||||
personalCount: number;
|
||||
}
|
||||
|
||||
/** Fehler mit HTTP-Status und Servermeldung (falls vorhanden). */
|
||||
export class ModuleCategoryRequestError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ModuleCategoryRequestError';
|
||||
}
|
||||
}
|
||||
|
||||
async function failure(res: Response): Promise<ModuleCategoryRequestError> {
|
||||
let message = `Request failed (${res.status})`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
const raw = body?.message;
|
||||
if (Array.isArray(raw)) message = raw.join(' ');
|
||||
else if (typeof raw === 'string') message = raw;
|
||||
} catch {
|
||||
// Antwort ohne JSON-Koerper — Standardmeldung bleibt.
|
||||
}
|
||||
return new ModuleCategoryRequestError(res.status, message);
|
||||
}
|
||||
|
||||
async function send<T>(path: string, method: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(`${API_URL}/module-categories${path}`, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
...(body === undefined
|
||||
? {}
|
||||
: { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
|
||||
});
|
||||
if (!res.ok) throw await failure(res);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 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();
|
||||
}
|
||||
|
||||
/** Kategorien mit ihren Eintraegen (nur Administratoren). */
|
||||
export function getModuleCategoryOverview(): Promise<ModuleCategoryOverview[]> {
|
||||
return send('/overview', 'GET');
|
||||
}
|
||||
|
||||
export function createModuleCategory(name: string): Promise<ModuleCategoryInfo> {
|
||||
return send('', 'POST', { name });
|
||||
}
|
||||
|
||||
export function renameModuleCategory(key: string, name: string): Promise<ModuleCategoryInfo> {
|
||||
return send(`/${encodeURIComponent(key)}`, 'PATCH', { name });
|
||||
}
|
||||
|
||||
/** Neue Reihenfolge ALLER Kategorien (Kennungen). */
|
||||
export function reorderModuleCategories(keys: string[]): Promise<ModuleCategoryInfo[]> {
|
||||
return send('/order', 'PUT', { keys });
|
||||
}
|
||||
|
||||
/** Neue Reihenfolge ALLER nicht persoenlichen Eintraege einer Kategorie. */
|
||||
export function reorderModuleCategoryItems(
|
||||
key: string,
|
||||
items: Array<Pick<ModuleCategoryOverviewItem, 'type' | 'id'>>,
|
||||
): Promise<{ ok: true }> {
|
||||
return send(`/${encodeURIComponent(key)}/items`, 'PUT', { items });
|
||||
}
|
||||
|
||||
/** Einen Eintrag einer Kategorie zuordnen (hinten angehaengt). */
|
||||
export function assignModuleCategory(input: {
|
||||
type: 'module' | 'custom';
|
||||
id: string;
|
||||
categoryKey: string;
|
||||
}): Promise<unknown> {
|
||||
return send('/assignment', 'PUT', input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kategorie loeschen. Eine nicht leere Kategorie braucht `moveTo`, sonst
|
||||
* antwortet die API mit 409 und loescht nichts.
|
||||
*/
|
||||
export function deleteModuleCategory(
|
||||
key: string,
|
||||
moveTo?: string,
|
||||
): Promise<{ deleted: true; moved: number }> {
|
||||
const query = moveTo ? `?moveTo=${encodeURIComponent(moveTo)}` : '';
|
||||
return send(`/${encodeURIComponent(key)}${query}`, 'DELETE');
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ export const useModuleCategoryStore = create<ModuleCategoryState>()((set, get) =
|
||||
load: async () => {
|
||||
try {
|
||||
const categories = await listModuleCategories();
|
||||
set({ categories, loaded: true });
|
||||
// Eine unerwartete Antwort (kein Feld) laesst den bisherigen Stand stehen.
|
||||
if (Array.isArray(categories)) set({ categories, loaded: true });
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"deactivateSuccess": "Modul erfolgreich deaktiviert",
|
||||
"error": "Fehler bei der Modulverwaltung",
|
||||
"grantsLink": "Freigaben-Matrix",
|
||||
"categoriesLink": "Kategorien",
|
||||
"grants": {
|
||||
"title": "Freigaben-Matrix",
|
||||
"searchPlaceholder": "Module oder Gruppen durchsuchen...",
|
||||
@@ -874,6 +875,40 @@
|
||||
"saveError": "Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||
"noSearchResults": "Kein Treffer für „{search}\" — weder bei den Modulen noch bei den Gruppen."
|
||||
},
|
||||
"categories": {
|
||||
"title": "Kategorien",
|
||||
"description": "Legen Sie fest, wie die Module in der Seitenleiste und im Marktplatz gruppiert und sortiert werden. Die Einstellung gilt für alle Benutzer.",
|
||||
"backToModules": "Zurück zu Module",
|
||||
"createLabel": "Neue Kategorie",
|
||||
"createPlaceholder": "Name der Kategorie",
|
||||
"createButton": "Kategorie anlegen",
|
||||
"moveUp": "Kategorie nach oben verschieben",
|
||||
"moveDown": "Kategorie nach unten verschieben",
|
||||
"itemMoveUp": "Eintrag nach oben verschieben",
|
||||
"itemMoveDown": "Eintrag nach unten verschieben",
|
||||
"rename": "Umbenennen",
|
||||
"renameLabel": "Neuer Name",
|
||||
"renameSave": "Speichern",
|
||||
"renameCancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"deleteBlocked": "Diese Kategorie kann nicht gelöscht werden",
|
||||
"empty": "Keine Module in dieser Kategorie",
|
||||
"customBadge": "Eigenes Modul",
|
||||
"itemCategory": "Kategorie",
|
||||
"itemCategoryLabel": "Kategorie für {name}",
|
||||
"personalNote": "Außerdem {count, plural, one {# persönlicher Eintrag} other {# persönliche Einträge}} von Benutzern",
|
||||
"loadError": "Die Kategorien konnten nicht geladen werden. Bitte versuchen Sie es erneut.",
|
||||
"saveError": "Die Änderung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.",
|
||||
"deleteDialog": {
|
||||
"title": "Kategorie löschen",
|
||||
"bodyEmpty": "Die Kategorie „{name}“ ist leer und wird gelöscht.",
|
||||
"bodyMove": "Die Kategorie „{name}“ enthält Einträge. Diese Module werden in die Kategorie verschoben, die Sie hier wählen – auch persönliche Einträge von Benutzern. Es geht nichts verloren.",
|
||||
"target": "Verschieben nach",
|
||||
"confirm": "Löschen",
|
||||
"confirmMove": "Verschieben und löschen",
|
||||
"cancel": "Abbrechen"
|
||||
}
|
||||
},
|
||||
"activationDialog": {
|
||||
"title": "Modul aktivieren: {module}",
|
||||
"body": "Soll die Standardgruppe sofort Zugriff auf dieses Modul erhalten, oder möchten Sie die Freigaben separat konfigurieren?",
|
||||
|
||||
@@ -864,6 +864,7 @@
|
||||
"deactivateSuccess": "Module deactivated successfully",
|
||||
"error": "Error managing module",
|
||||
"grantsLink": "Grants Matrix",
|
||||
"categoriesLink": "Categories",
|
||||
"grants": {
|
||||
"title": "Grants Matrix",
|
||||
"searchPlaceholder": "Search modules or groups...",
|
||||
@@ -874,6 +875,40 @@
|
||||
"saveError": "Could not save grant. Please try again.",
|
||||
"noSearchResults": "No match for \"{search}\" — neither in the modules nor in the groups."
|
||||
},
|
||||
"categories": {
|
||||
"title": "Categories",
|
||||
"description": "Decide how modules are grouped and sorted in the sidebar and in the marketplace. The setting applies to all users.",
|
||||
"backToModules": "Back to Modules",
|
||||
"createLabel": "New category",
|
||||
"createPlaceholder": "Category name",
|
||||
"createButton": "Add category",
|
||||
"moveUp": "Move category up",
|
||||
"moveDown": "Move category down",
|
||||
"itemMoveUp": "Move entry up",
|
||||
"itemMoveDown": "Move entry down",
|
||||
"rename": "Rename",
|
||||
"renameLabel": "New name",
|
||||
"renameSave": "Save",
|
||||
"renameCancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"deleteBlocked": "This category cannot be deleted",
|
||||
"empty": "No modules in this category",
|
||||
"customBadge": "Custom module",
|
||||
"itemCategory": "Category",
|
||||
"itemCategoryLabel": "Category for {name}",
|
||||
"personalNote": "Also {count, plural, one {# personal entry} other {# personal entries}} created by users",
|
||||
"loadError": "The categories could not be loaded. Please try again.",
|
||||
"saveError": "The change could not be saved. Please try again.",
|
||||
"deleteDialog": {
|
||||
"title": "Delete category",
|
||||
"bodyEmpty": "The category “{name}” is empty and will be deleted.",
|
||||
"bodyMove": "The category “{name}” contains entries. These modules are moved to the category you choose here, including personal entries created by users. Nothing is lost.",
|
||||
"target": "Move to",
|
||||
"confirm": "Delete",
|
||||
"confirmMove": "Move and delete",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
},
|
||||
"activationDialog": {
|
||||
"title": "Activate Module: {module}",
|
||||
"body": "Should the default group get immediate access to this module, or would you rather configure grants separately?",
|
||||
|
||||
@@ -103,6 +103,8 @@ export const UMLAUT_REPLACEMENTS: Record<string, string> = {
|
||||
* and must never be touched by the replacement or flagged by the guard.
|
||||
*/
|
||||
export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
// quick-261003-387: Kategorienverwaltung (korrektes Deutsch)
|
||||
'Neuer',
|
||||
// quick-261002-k67: Nextcloud-Status (korrektes Deutsch)
|
||||
'Neueste',
|
||||
'ausstehend',
|
||||
|
||||
Reference in New Issue
Block a user