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:
2026-10-03 02:48:04 +02:00
parent af1878d5ee
commit f2c0a896e8
16 changed files with 1173 additions and 14 deletions
@@ -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 />);
+17 -2
View File
@@ -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>
+99
View File
@@ -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
}
+35
View File
@@ -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?",
+35
View File
@@ -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',