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:
@@ -6,6 +6,7 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
|||||||
|
|
||||||
### Neu
|
### Neu
|
||||||
|
|
||||||
|
- Kategorien der Module lassen sich jetzt von Administratoren selbst gestalten, unter Administrator → Module → „Kategorien“. Sie können neue Kategorien anlegen, bestehende umbenennen und mit Pfeilen in eine andere Reihenfolge bringen, jedes Modul (auch gemeinsame eigene Module) einer Kategorie zuordnen und die Module innerhalb einer Kategorie sortieren. Seitenleiste, Marktplatz und Freigaben-Matrix folgen dieser Reihenfolge und den neuen Namen. Löschen Sie eine Kategorie, in der noch Module liegen, wählen Sie eine Zielkategorie: Alle Module wandern dorthin, auch die persönlichen Einträge der Benutzer, es geht nichts verloren. „Eigene Module“ lässt sich umbenennen und verschieben, aber nicht löschen. Benutzer können für ihre eigenen Einträge jetzt jede vorhandene Kategorie wählen. Alte Modul-Adressen aus Lesezeichen funktionieren weiter.
|
||||||
- Neues Modul „Nextcloud-Status“ in der Gruppe „Infrastruktur“. Es zeigt für jede eingetragene Nextcloud-Cloud Ihrer Kunden eine Kachel mit Logo (oder Initialen), Kundenname, Adresse (öffnet in einem neuen Tab), installierter Version, Ampelfarbe mit kurzer Begründung und dem Zeitpunkt der letzten Prüfung; oben steht die neueste Nextcloud-Version. Grün heißt: neuester Stand seiner Version und der Support läuft noch mehr als drei Monate. Gelb heißt: ein Update steht an oder der Support endet in den nächsten drei Monaten. Rot heißt: der Support ist abgelaufen, die Cloud ist nicht erreichbar, im Wartungsmodus oder wartet auf eine Datenbank-Aktualisierung. Grau heißt: Bewertung nicht möglich, zum Beispiel wenn die Versionsdaten gerade nicht abrufbar sind. Tessera prüft jede Cloud automatisch einmal pro Stunde; „Jetzt prüfen“ und ein Knopf je Kachel prüfen sofort. Die Kacheln lassen sich nach Kundenname, Status (Rot zuerst), Version oder Support-Ende sortieren, die Wahl merkt sich Tessera für jeden Benutzer. Clouds eintragen, ändern, entfernen und Logos hinterlegen (Bild hochladen bis 1 MB oder eine https-Bildadresse) dürfen Administratoren und Benutzer mit der Freigabestufe „Verwalten“; alle anderen mit Freigabe sehen die Kacheln. Aktivieren Sie das Modul als Administrator im Marktplatz und erteilen Sie die Freigabe.
|
- Neues Modul „Nextcloud-Status“ in der Gruppe „Infrastruktur“. Es zeigt für jede eingetragene Nextcloud-Cloud Ihrer Kunden eine Kachel mit Logo (oder Initialen), Kundenname, Adresse (öffnet in einem neuen Tab), installierter Version, Ampelfarbe mit kurzer Begründung und dem Zeitpunkt der letzten Prüfung; oben steht die neueste Nextcloud-Version. Grün heißt: neuester Stand seiner Version und der Support läuft noch mehr als drei Monate. Gelb heißt: ein Update steht an oder der Support endet in den nächsten drei Monaten. Rot heißt: der Support ist abgelaufen, die Cloud ist nicht erreichbar, im Wartungsmodus oder wartet auf eine Datenbank-Aktualisierung. Grau heißt: Bewertung nicht möglich, zum Beispiel wenn die Versionsdaten gerade nicht abrufbar sind. Tessera prüft jede Cloud automatisch einmal pro Stunde; „Jetzt prüfen“ und ein Knopf je Kachel prüfen sofort. Die Kacheln lassen sich nach Kundenname, Status (Rot zuerst), Version oder Support-Ende sortieren, die Wahl merkt sich Tessera für jeden Benutzer. Clouds eintragen, ändern, entfernen und Logos hinterlegen (Bild hochladen bis 1 MB oder eine https-Bildadresse) dürfen Administratoren und Benutzer mit der Freigabestufe „Verwalten“; alle anderen mit Freigabe sehen die Kacheln. Aktivieren Sie das Modul als Administrator im Marktplatz und erteilen Sie die Freigabe.
|
||||||
- Nextcloud-Status: Auf jeder Kachel gibt es jetzt eine Glocke „Benachrichtigen“, die jeder Benutzer mit Zugriff auf das Modul für sich ein- und ausschalten kann. Ist sie an, meldet Tessera per E-Mail und, solange Tessera geöffnet ist, als Benachrichtigung auf dem Bildschirm (in der Desktop-App als Windows-Benachrichtigung), wenn die Cloud eine Störung hat – nicht erreichbar, keine gültige Antwort, Wartungsmodus, ausstehende Datenbank-Aktualisierung oder abgelaufener Support – und wenn sie wieder in Ordnung ist. Pro Änderung kommt genau eine Nachricht, solange die Störung anhält, nicht jede Stunde neu. Ein einzelner fehlgeschlagener Abruf löst keine Meldung aus: Die Kachel zeigt weiter den letzten guten Stand mit dem Hinweis „Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt“, und Tessera prüft nach etwa fünf Minuten erneut. Für die E-Mails muss der Mailversand eingerichtet sein und im Benutzerprofil eine E-Mail-Adresse stehen. Außerdem nennt die Kachel bei „Nicht erreichbar“ jetzt den Grund in Klartext, zum Beispiel „Zertifikat passt nicht zur Adresse“, „Adresse nicht gefunden“ oder „Zeitüberschreitung“, statt eines technischen Fehlercodes.
|
- Nextcloud-Status: Auf jeder Kachel gibt es jetzt eine Glocke „Benachrichtigen“, die jeder Benutzer mit Zugriff auf das Modul für sich ein- und ausschalten kann. Ist sie an, meldet Tessera per E-Mail und, solange Tessera geöffnet ist, als Benachrichtigung auf dem Bildschirm (in der Desktop-App als Windows-Benachrichtigung), wenn die Cloud eine Störung hat – nicht erreichbar, keine gültige Antwort, Wartungsmodus, ausstehende Datenbank-Aktualisierung oder abgelaufener Support – und wenn sie wieder in Ordnung ist. Pro Änderung kommt genau eine Nachricht, solange die Störung anhält, nicht jede Stunde neu. Ein einzelner fehlgeschlagener Abruf löst keine Meldung aus: Die Kachel zeigt weiter den letzten guten Stand mit dem Hinweis „Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt“, und Tessera prüft nach etwa fünf Minuten erneut. Für die E-Mails muss der Mailversand eingerichtet sein und im Benutzerprofil eine E-Mail-Adresse stehen. Außerdem nennt die Kachel bei „Nicht erreichbar“ jetzt den Grund in Klartext, zum Beispiel „Zertifikat passt nicht zur Adresse“, „Adresse nicht gefunden“ oder „Zeitüberschreitung“, statt eines technischen Fehlercodes.
|
||||||
- Neue Dashboard-Kachel „Nextcloud-Status“: drei Zähler für Grün, Gelb und Rot und darunter die roten und gelben Clouds mit Kundenname und Grund. Ein Klick öffnet das Modul. Die Kachel erscheint nur für Benutzer, die das Modul nutzen dürfen.
|
- Neue Dashboard-Kachel „Nextcloud-Status“: drei Zähler für Grün, Gelb und Rot und darunter die roten und gelben Clouds mit Kundenname und Grund. Ein Klick öffnet das Modul. Die Kachel erscheint nur für Benutzer, die das Modul nutzen dürfen.
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ vi.mock('@/lib/stores/auth-store', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
|
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
|
||||||
|
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
|
||||||
import AdminCustomModulesPage from './page';
|
import AdminCustomModulesPage from './page';
|
||||||
|
|
||||||
const wiki = {
|
const wiki = {
|
||||||
@@ -236,6 +237,47 @@ describe('AdminCustomModulesPage', () => {
|
|||||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
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 () => {
|
it('Anlegen: die Kategorie ist vorbelegt mit „Eigene Module“', async () => {
|
||||||
render(<AdminCustomModulesPage />);
|
render(<AdminCustomModulesPage />);
|
||||||
await screen.findByText('Noch keine gemeinsamen Einträge');
|
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 { useTranslations } from 'next-intl';
|
||||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||||
|
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||||
import { ActivateModuleDialog } from './components/ActivateModuleDialog';
|
import { ActivateModuleDialog } from './components/ActivateModuleDialog';
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
@@ -29,6 +30,7 @@ interface TenantActivation {
|
|||||||
export default function AdminModulesPage() {
|
export default function AdminModulesPage() {
|
||||||
const t = useTranslations('adminModules');
|
const t = useTranslations('adminModules');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
|
const categoryLabel = useCategoryLabel();
|
||||||
const currentUser = useAuthStore((s) => s.user);
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
// Shared signal that makes the sidebar refetch its active-module list
|
// Shared signal that makes the sidebar refetch its active-module list
|
||||||
// (same mechanism the marketplace pages use). Without bumping this after a
|
// (same mechanism the marketplace pages use). Without bumping this after a
|
||||||
@@ -137,6 +139,13 @@ export default function AdminModulesPage() {
|
|||||||
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href="/admin/modules/categories"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
{t('categoriesLink')}
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/admin/modules/grants"
|
href="/admin/modules/grants"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
@@ -144,6 +153,7 @@ export default function AdminModulesPage() {
|
|||||||
{t('grantsLink')}
|
{t('grantsLink')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
@@ -178,7 +188,7 @@ export default function AdminModulesPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||||
{mod.category}
|
{categoryLabel(mod.category)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-muted-foreground truncate">
|
<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';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
// Statisch statt je Test dynamisch importiert: Laden/Umwandeln der Seite faellt
|
// 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).
|
// 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';
|
import Page from './page';
|
||||||
|
|
||||||
vi.mock('next-intl', () => ({
|
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 () => {
|
it('search + status + category filters compose together (AND)', async () => {
|
||||||
render(<Page />);
|
render(<Page />);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||||
|
import { categoryRank } from '@/lib/module-category-order';
|
||||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||||
|
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
|
||||||
import { MarketplaceCard } from './components/MarketplaceCard';
|
import { MarketplaceCard } from './components/MarketplaceCard';
|
||||||
import { MarketplaceSearch } from './components/MarketplaceSearch';
|
import { MarketplaceSearch } from './components/MarketplaceSearch';
|
||||||
import { StatusFilter, type StatusValue } from './components/StatusFilter';
|
import { StatusFilter, type StatusValue } from './components/StatusFilter';
|
||||||
@@ -43,6 +45,9 @@ export default function MarketplacePage() {
|
|||||||
const currentUser = useAuthStore((s) => s.user);
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore();
|
const { selectedTenantId, bumpSidebarRefresh } = useMarketplaceStore();
|
||||||
const addToast = useToastStore((s) => s.addToast);
|
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 [modules, setModules] = useState<CatalogModule[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -85,6 +90,10 @@ export default function MarketplacePage() {
|
|||||||
fetchModules();
|
fetchModules();
|
||||||
}, [fetchModules]);
|
}, [fetchModules]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ensureCategories();
|
||||||
|
}, [ensureCategories]);
|
||||||
|
|
||||||
|
|
||||||
const locale = typeof window !== 'undefined' ? document.documentElement.lang || 'de' : 'de';
|
const locale = typeof window !== 'undefined' ? document.documentElement.lang || 'de' : 'de';
|
||||||
|
|
||||||
@@ -104,9 +113,15 @@ export default function MarketplacePage() {
|
|||||||
});
|
});
|
||||||
}, [modules, searchQuery, statusFilter, categoryFilter, locale]);
|
}, [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(
|
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(() => ({
|
const statusCounts = useMemo(() => ({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||||
import { ModuleCard, type ModuleCardProps } from './components/ModuleCard';
|
import { ModuleCard, type ModuleCardProps } from './components/ModuleCard';
|
||||||
|
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
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 params = useParams<{ category: string }>();
|
||||||
const category = params.category;
|
const category = params.category;
|
||||||
const t = useTranslations('modules');
|
const t = useTranslations('modules');
|
||||||
|
const categoryLabel = useCategoryLabel();
|
||||||
|
|
||||||
const [modules, setModules] = useState<ModuleCardProps[]>([]);
|
const [modules, setModules] = useState<ModuleCardProps[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -89,7 +91,10 @@ export default function CategoryPage() {
|
|||||||
fetchModules();
|
fetchModules();
|
||||||
}, [category]);
|
}, [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 (
|
return (
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { CUSTOM_MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
|
import { CUSTOM_MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
type CustomModule,
|
type CustomModule,
|
||||||
CustomModuleRequestError,
|
CustomModuleRequestError,
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
updateCustomModule,
|
updateCustomModule,
|
||||||
} from '@/lib/custom-modules-api';
|
} from '@/lib/custom-modules-api';
|
||||||
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
import { useCustomModuleCacheStore } from '@/lib/stores/custom-module-cache-store';
|
||||||
|
import { useModuleCategoryStore } from '@/lib/stores/module-category-store';
|
||||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||||
|
|
||||||
interface CustomModuleFormModalProps {
|
interface CustomModuleFormModalProps {
|
||||||
@@ -63,6 +64,8 @@ export function CustomModuleFormModal({
|
|||||||
const t = useTranslations('customModules.form');
|
const t = useTranslations('customModules.form');
|
||||||
const tCommon = useTranslations('common');
|
const tCommon = useTranslations('common');
|
||||||
const categoryLabel = useCategoryLabel();
|
const categoryLabel = useCategoryLabel();
|
||||||
|
const storeCategories = useModuleCategoryStore((s) => s.categories);
|
||||||
|
const ensureCategories = useModuleCategoryStore((s) => s.ensureLoaded);
|
||||||
|
|
||||||
const [name, setName] = useState(mod?.name ?? '');
|
const [name, setName] = useState(mod?.name ?? '');
|
||||||
const [url, setUrl] = useState(mod?.url ?? '');
|
const [url, setUrl] = useState(mod?.url ?? '');
|
||||||
@@ -72,6 +75,20 @@ export function CustomModuleFormModal({
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -167,7 +184,7 @@ export function CustomModuleFormModal({
|
|||||||
onChange={(e) => setCategory(e.target.value)}
|
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"
|
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}>
|
<option key={c} value={c}>
|
||||||
{categoryLabel(c)}
|
{categoryLabel(c)}
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -17,9 +17,108 @@ export interface ModuleCategoryInfo {
|
|||||||
isSystem: boolean;
|
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). */
|
/** Alle Kategorien der Organisation in ihrer Reihenfolge (jeder Angemeldete). */
|
||||||
export async function listModuleCategories(): Promise<ModuleCategoryInfo[]> {
|
export async function listModuleCategories(): Promise<ModuleCategoryInfo[]> {
|
||||||
const res = await fetch(`${API_URL}/module-categories`, { credentials: 'include' });
|
const res = await fetch(`${API_URL}/module-categories`, { credentials: 'include' });
|
||||||
if (!res.ok) throw new Error(`Request failed (${res.status})`);
|
if (!res.ok) throw new Error(`Request failed (${res.status})`);
|
||||||
return res.json();
|
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 () => {
|
load: async () => {
|
||||||
try {
|
try {
|
||||||
const categories = await listModuleCategories();
|
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 {
|
} catch {
|
||||||
// silently fail
|
// silently fail
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -864,6 +864,7 @@
|
|||||||
"deactivateSuccess": "Modul erfolgreich deaktiviert",
|
"deactivateSuccess": "Modul erfolgreich deaktiviert",
|
||||||
"error": "Fehler bei der Modulverwaltung",
|
"error": "Fehler bei der Modulverwaltung",
|
||||||
"grantsLink": "Freigaben-Matrix",
|
"grantsLink": "Freigaben-Matrix",
|
||||||
|
"categoriesLink": "Kategorien",
|
||||||
"grants": {
|
"grants": {
|
||||||
"title": "Freigaben-Matrix",
|
"title": "Freigaben-Matrix",
|
||||||
"searchPlaceholder": "Module oder Gruppen durchsuchen...",
|
"searchPlaceholder": "Module oder Gruppen durchsuchen...",
|
||||||
@@ -874,6 +875,40 @@
|
|||||||
"saveError": "Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
"saveError": "Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.",
|
||||||
"noSearchResults": "Kein Treffer für „{search}\" — weder bei den Modulen noch bei den Gruppen."
|
"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": {
|
"activationDialog": {
|
||||||
"title": "Modul aktivieren: {module}",
|
"title": "Modul aktivieren: {module}",
|
||||||
"body": "Soll die Standardgruppe sofort Zugriff auf dieses Modul erhalten, oder möchten Sie die Freigaben separat konfigurieren?",
|
"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",
|
"deactivateSuccess": "Module deactivated successfully",
|
||||||
"error": "Error managing module",
|
"error": "Error managing module",
|
||||||
"grantsLink": "Grants Matrix",
|
"grantsLink": "Grants Matrix",
|
||||||
|
"categoriesLink": "Categories",
|
||||||
"grants": {
|
"grants": {
|
||||||
"title": "Grants Matrix",
|
"title": "Grants Matrix",
|
||||||
"searchPlaceholder": "Search modules or groups...",
|
"searchPlaceholder": "Search modules or groups...",
|
||||||
@@ -874,6 +875,40 @@
|
|||||||
"saveError": "Could not save grant. Please try again.",
|
"saveError": "Could not save grant. Please try again.",
|
||||||
"noSearchResults": "No match for \"{search}\" — neither in the modules nor in the groups."
|
"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": {
|
"activationDialog": {
|
||||||
"title": "Activate Module: {module}",
|
"title": "Activate Module: {module}",
|
||||||
"body": "Should the default group get immediate access to this module, or would you rather configure grants separately?",
|
"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.
|
* and must never be touched by the replacement or flagged by the guard.
|
||||||
*/
|
*/
|
||||||
export const UMLAUT_ALLOWLIST: readonly string[] = [
|
export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||||
|
// quick-261003-387: Kategorienverwaltung (korrektes Deutsch)
|
||||||
|
'Neuer',
|
||||||
// quick-261002-k67: Nextcloud-Status (korrektes Deutsch)
|
// quick-261002-k67: Nextcloud-Status (korrektes Deutsch)
|
||||||
'Neueste',
|
'Neueste',
|
||||||
'ausstehend',
|
'ausstehend',
|
||||||
|
|||||||
@@ -257,6 +257,19 @@ Hat jemand über mehrere Wege Zugriff (zum Beispiel direkt „Benutzen“ und ü
|
|||||||
|
|
||||||
Weiterhin **ausschließlich Administratoren** vorbehalten bleiben: Freigaben erteilen und deren Stufe wählen, Module aktivieren und deaktivieren, Benutzer, Gruppen, LDAP, SMTP, die Willkommensmail, gemeinsame eigene Module sowie die plattformweiten Einstellungen des Ausschreibungs-Radars (Abrufintervall, „Jetzt abrufen“, plattformweite RSS-Feeds). Wer „Verwalten“ für Proxmox erhält, darf Serveradressen eintragen; vergeben Sie die Stufe deshalb nur an Personen, denen Sie das zutrauen.
|
Weiterhin **ausschließlich Administratoren** vorbehalten bleiben: Freigaben erteilen und deren Stufe wählen, Module aktivieren und deaktivieren, Benutzer, Gruppen, LDAP, SMTP, die Willkommensmail, gemeinsame eigene Module sowie die plattformweiten Einstellungen des Ausschreibungs-Radars (Abrufintervall, „Jetzt abrufen“, plattformweite RSS-Feeds). Wer „Verwalten“ für Proxmox erhält, darf Serveradressen eintragen; vergeben Sie die Stufe deshalb nur an Personen, denen Sie das zutrauen.
|
||||||
|
|
||||||
|
### Kategorien
|
||||||
|
|
||||||
|
Unter „Module → Kategorien“ bestimmen Sie, wie die Module in der Seitenleiste, im Marktplatz und in der Freigaben-Matrix gruppiert und sortiert werden. Die Einstellung gilt für alle Benutzer. Die Seite ist nur für Administratoren erreichbar.
|
||||||
|
|
||||||
|
- **Kategorie anlegen:** Name eintragen und „Kategorie anlegen“ wählen. Die neue Kategorie steht hinten. Der Name lässt sich später jederzeit ändern; die Adresse der Kategorie (der Teil nach /modules/ in der Adresszeile) bleibt dabei bestehen, damit gespeicherte Lesezeichen weiter funktionieren.
|
||||||
|
- **Umbenennen:** „Umbenennen“ an der Kategorie, neuen Namen eintragen, „Speichern“. Eine Kategorie, die Sie nicht umbenannt haben, zeigt weiterhin den mitgelieferten Namen in der Sprache des Benutzers.
|
||||||
|
- **Reihenfolge der Kategorien:** Mit den Pfeilen schieben Sie eine Kategorie nach oben oder unten. Diese Reihenfolge gilt in der Seitenleiste, bei den Filtern im Marktplatz und in der Freigaben-Matrix.
|
||||||
|
- **Module zuordnen:** Bei jedem Eintrag wählen Sie im Auswahlfeld die Kategorie. Das gilt für die Module aus dem Marktplatz und für gemeinsame eigene Module. Der Eintrag steht danach am Ende der gewählten Kategorie.
|
||||||
|
- **Reihenfolge innerhalb einer Kategorie:** Mit den Pfeilen neben dem Eintrag legen Sie fest, in welcher Reihenfolge die Module in der Seitenleiste stehen. Noch nicht sortierte Einträge stehen nach den sortierten, eingebaute Module vor eigenen. Persönliche eigene Module der Benutzer stehen damit immer hinter Ihrer Reihenfolge.
|
||||||
|
- **Löschen:** Eine leere Kategorie löschen Sie nach einer Rückfrage. Enthält die Kategorie Einträge, fragt Tessera nach einer **Zielkategorie**: Alle Module wandern dorthin – auch die persönlichen Einträge der Benutzer, die dann in der Zielkategorie stehen. Es geht nichts verloren. Ohne Zielkategorie löscht Tessera nichts. Die Kategorie „Eigene Module“ lässt sich umbenennen und verschieben, aber nicht löschen.
|
||||||
|
|
||||||
|
Benutzer können für ihre persönlichen Einträge unter „Einstellungen → Eigene Module“ jede vorhandene Kategorie wählen, ebenso im Formular für gemeinsame eigene Module. Alte Modul-Adressen aus Lesezeichen öffnen das Modul weiterhin, auch wenn Sie es in eine andere Kategorie verschoben haben.
|
||||||
|
|
||||||
### Eigene Module (Seitenleisten-Einträge)
|
### Eigene Module (Seitenleisten-Einträge)
|
||||||
|
|
||||||
Unter „Verwaltung → Eigene Module“ legen Sie **gemeinsame** Seitenleisten-Einträge an, die alle Benutzer des Mandanten sehen: Name, Adresse (nur https, ohne Benutzername und Kennwort) und Kategorie. Ein Klick auf den Eintrag zeigt die Seite direkt in Tessera; manche Webseiten verbieten das Einbetten, dann hilft der Knopf „In neuem Tab öffnen“. Diese Einträge hängen an keiner Modul-Aktivierung und keiner Freigabe – sie erscheinen für jeden angemeldeten Benutzer. Nur Administratoren dürfen sie anlegen, ändern und löschen.
|
Unter „Verwaltung → Eigene Module“ legen Sie **gemeinsame** Seitenleisten-Einträge an, die alle Benutzer des Mandanten sehen: Name, Adresse (nur https, ohne Benutzername und Kennwort) und Kategorie. Ein Klick auf den Eintrag zeigt die Seite direkt in Tessera; manche Webseiten verbieten das Einbetten, dann hilft der Knopf „In neuem Tab öffnen“. Diese Einträge hängen an keiner Modul-Aktivierung und keiner Freigabe – sie erscheinen für jeden angemeldeten Benutzer. Nur Administratoren dürfen sie anlegen, ändern und löschen.
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ Das Modul ordnet Handelswaren-Umsätze aus einer Excel-Liste den Erlöskonten zu
|
|||||||
- **Akzentfarbe:** Passt die Hauptfarbe der Oberfläche an Ihren Geschmack an; über „Zurücksetzen" kehren Sie zur Standardfarbe zurück.
|
- **Akzentfarbe:** Passt die Hauptfarbe der Oberfläche an Ihren Geschmack an; über „Zurücksetzen" kehren Sie zur Standardfarbe zurück.
|
||||||
- **Passwort ändern:** Nur sichtbar und nutzbar, wenn Ihr Konto **lokal** in Tessera verwaltet wird. Wird Ihr Konto stattdessen über das Verzeichnis (LDAP/Active Directory) verwaltet, zeigt Tessera stattdessen den Hinweis „Ihr Passwort wird über das Verzeichnis (LDAP) verwaltet. Eine Änderung ist hier nicht möglich." — in diesem Fall ändern Sie Ihr Passwort über die üblichen Firmenwege (z. B. Windows-Anmeldung), nicht in Tessera.
|
- **Passwort ändern:** Nur sichtbar und nutzbar, wenn Ihr Konto **lokal** in Tessera verwaltet wird. Wird Ihr Konto stattdessen über das Verzeichnis (LDAP/Active Directory) verwaltet, zeigt Tessera stattdessen den Hinweis „Ihr Passwort wird über das Verzeichnis (LDAP) verwaltet. Eine Änderung ist hier nicht möglich." — in diesem Fall ändern Sie Ihr Passwort über die üblichen Firmenwege (z. B. Windows-Anmeldung), nicht in Tessera.
|
||||||
|
|
||||||
**Allgemein > Eigene Module:** Hier nehmen Sie Webseiten, die Sie oft brauchen, als eigene Einträge in Ihre Seitenleiste auf. Diese Einträge sehen nur Sie – kein anderer Benutzer und auch kein Administrator. Mit „Eigenes Modul anlegen“ vergeben Sie einen Namen, tragen die Adresse ein (sie muss mit https:// beginnen und darf keinen Benutzernamen und kein Kennwort enthalten) und wählen die Kategorie, unter der der Eintrag in der Seitenleiste erscheint, zum Beispiel „Infrastruktur“ – oder „Eigene Module“, dann stehen Ihre Einträge gesammelt in einer eigenen Gruppe ganz unten in der Seitenleiste. Diese Gruppe erscheint nur, solange ein Eintrag darin liegt. In der Liste ändern („Bearbeiten“) oder entfernen („Löschen“) Sie Ihre Einträge; die Seitenleiste zieht sofort nach. Ein Klick auf den Eintrag zeigt die Seite direkt in Tessera; manche Webseiten erlauben das Einbetten nicht – der Rahmen bleibt dann leer, der Knopf „In neuem Tab öffnen“ funktioniert trotzdem. Einträge, die ein Administrator für alle angelegt hat, erscheinen ebenfalls in Ihrer Seitenleiste, lassen sich hier aber nicht ändern.
|
**Allgemein > Eigene Module:** Hier nehmen Sie Webseiten, die Sie oft brauchen, als eigene Einträge in Ihre Seitenleiste auf. Diese Einträge sehen nur Sie – kein anderer Benutzer und auch kein Administrator. Mit „Eigenes Modul anlegen“ vergeben Sie einen Namen, tragen die Adresse ein (sie muss mit https:// beginnen und darf keinen Benutzernamen und kein Kennwort enthalten) und wählen die Kategorie, unter der der Eintrag in der Seitenleiste erscheint, zum Beispiel „Infrastruktur“ – oder „Eigene Module“, dann stehen Ihre Einträge gesammelt in einer eigenen Gruppe, standardmäßig ganz unten in der Seitenleiste. Zur Wahl stehen alle Kategorien, die Ihr Administrator eingerichtet hat; er legt auch fest, in welcher Reihenfolge die Gruppen und die Einträge darin stehen. Eine Gruppe erscheint nur, solange ein Eintrag darin liegt. In der Liste ändern („Bearbeiten“) oder entfernen („Löschen“) Sie Ihre Einträge; die Seitenleiste zieht sofort nach. Ein Klick auf den Eintrag zeigt die Seite direkt in Tessera; manche Webseiten erlauben das Einbetten nicht – der Rahmen bleibt dann leer, der Knopf „In neuem Tab öffnen“ funktioniert trotzdem. Einträge, die ein Administrator für alle angelegt hat, erscheinen ebenfalls in Ihrer Seitenleiste, lassen sich hier aber nicht ändern.
|
||||||
|
|
||||||
**Dashboard > Widgets:** Hier finden Sie für jedes auf Ihrem Dashboard platzierte Widget die zugehörigen Einstellungen, zum Beispiel eigene Suchanbieter für die Suchleiste, beim Kalender die Monatsansicht (ein/aus), die Anzahl der angezeigten Termine (bis zu zehn, oder ausgeblendet) und den Zeitraum (7 bis 90 Tage), oder bei Notizen und Favoriten die Überschrift der Kachel. Beim Bilderrahmen verwalten Sie hier die Bilder: „Bild hochladen“ wählt eine Datei von Ihrem Rechner (PNG, JPEG, GIF oder WebP, höchstens 5 MB, bis zu 30 Bilder), „Webadresse hinzufügen“ bindet ein Bild über seine https-Adresse ein (http-Adressen werden abgewiesen). Jeder Eintrag zeigt eine Vorschau, ein Feld für die Bildunterschrift, die Pfeile „Nach oben“/„Nach unten“ für die Reihenfolge und „Bild entfernen“; ein hochgeladenes Bild wird beim Entfernen auch vom Server gelöscht. Darüber legen Sie den Bildausschnitt (ganz sichtbar oder formatfüllend), das Wechselintervall (kein Wechsel oder 5 Sekunden bis 60 Minuten) und die Reihenfolge (der Reihe nach oder zufällig) fest. Beim XFrame tragen Sie die https-Adresse der Webseite ein (http-Adressen werden abgewiesen), optional einen Titel für die Kopfleiste und wählen, ob die Seite automatisch neu geladen wird; ein dauerhafter Hinweis erinnert daran, dass manche Webseiten das Einbetten verweigern. Mit „Nur einen Ausschnitt der Seite anzeigen“ erscheint eine Vorschau der Seite, in der Sie den Rahmen verschieben und an den Ecken ziehen oder die Werte eintippen; der Ausschnitt ist eine Position auf der Seite und muss neu gesetzt werden, wenn die Seite ihren Aufbau ändert. Bei der Proxmox-Kachel legen Sie einen Titel fest und haken an, welche Server sie zeigt; ohne Haken zeigt sie alle Server. Dieselbe Auswahl erreichen Sie im Bearbeitungsmodus des Dashboards auch direkt an der Kachel über „Server auswählen“ — das ist der Weg für eine Proxmox-Kachel auf einem weiteren Reiter, denn diese Seite zeigt nur die Kacheln des ersten Reiters.
|
**Dashboard > Widgets:** Hier finden Sie für jedes auf Ihrem Dashboard platzierte Widget die zugehörigen Einstellungen, zum Beispiel eigene Suchanbieter für die Suchleiste, beim Kalender die Monatsansicht (ein/aus), die Anzahl der angezeigten Termine (bis zu zehn, oder ausgeblendet) und den Zeitraum (7 bis 90 Tage), oder bei Notizen und Favoriten die Überschrift der Kachel. Beim Bilderrahmen verwalten Sie hier die Bilder: „Bild hochladen“ wählt eine Datei von Ihrem Rechner (PNG, JPEG, GIF oder WebP, höchstens 5 MB, bis zu 30 Bilder), „Webadresse hinzufügen“ bindet ein Bild über seine https-Adresse ein (http-Adressen werden abgewiesen). Jeder Eintrag zeigt eine Vorschau, ein Feld für die Bildunterschrift, die Pfeile „Nach oben“/„Nach unten“ für die Reihenfolge und „Bild entfernen“; ein hochgeladenes Bild wird beim Entfernen auch vom Server gelöscht. Darüber legen Sie den Bildausschnitt (ganz sichtbar oder formatfüllend), das Wechselintervall (kein Wechsel oder 5 Sekunden bis 60 Minuten) und die Reihenfolge (der Reihe nach oder zufällig) fest. Beim XFrame tragen Sie die https-Adresse der Webseite ein (http-Adressen werden abgewiesen), optional einen Titel für die Kopfleiste und wählen, ob die Seite automatisch neu geladen wird; ein dauerhafter Hinweis erinnert daran, dass manche Webseiten das Einbetten verweigern. Mit „Nur einen Ausschnitt der Seite anzeigen“ erscheint eine Vorschau der Seite, in der Sie den Rahmen verschieben und an den Ecken ziehen oder die Werte eintippen; der Ausschnitt ist eine Position auf der Seite und muss neu gesetzt werden, wenn die Seite ihren Aufbau ändert. Bei der Proxmox-Kachel legen Sie einen Titel fest und haken an, welche Server sie zeigt; ohne Haken zeigt sie alle Server. Dieselbe Auswahl erreichen Sie im Bearbeitungsmodus des Dashboards auch direkt an der Kachel über „Server auswählen“ — das ist der Weg für eine Proxmox-Kachel auf einem weiteren Reiter, denn diese Seite zeigt nur die Kacheln des ersten Reiters.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user