design(verwaltung): Einstellungen und Verwaltung aufgeraeumt

Gemeinsame Navigation fuer Einstellungen und Verwaltung (Verwaltung in
Benutzer und Zugang / Module / Anbindungen gegliedert), einheitlicher
Seitenkopf und Abschnittskarten, Speichern in der Kartenfusszeile.
Menuepunkt 'Administrator' heisst 'Verwaltung'; Seitentitel gleich den
Navigationseintraegen. Kein 'Zurueck zum Dashboard' mehr. Tabellen ohne
waagerechte Seitenscrollleiste; auf dem Handy Auswahlliste statt Liste.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-05 13:36:05 +02:00
parent 831c7b8f52
commit 0a35a32afb
32 changed files with 2579 additions and 2170 deletions
+40 -40
View File
@@ -2,6 +2,8 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { SettingsSection } from '@/components/control-center/settings-section';
import { useAuthStore } from '@/lib/stores/auth-store';
import { GroupFormModal } from './components/GroupFormModal';
import { GroupMembersModal } from './components/GroupMembersModal';
@@ -30,6 +32,7 @@ export interface Group {
export default function AdminGroupsPage() {
const t = useTranslations('admin.groups');
const tCommon = useTranslations('common');
const tCc = useTranslations('controlCenter');
const currentUser = useAuthStore((s) => s.user);
const [groups, setGroups] = useState<Group[]>([]);
@@ -122,20 +125,19 @@ export default function AdminGroupsPage() {
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<button
type="button"
onClick={openCreate}
className="btn btn-primary"
>
{t('create')}
</button>
</div>
<div>
<PageHeader
title={t('title')}
description={tCc('descriptions.groups')}
actions={
<button type="button" onClick={openCreate} className="btn btn-primary">
{t('create')}
</button>
}
/>
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<div className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
@@ -143,35 +145,33 @@ export default function AdminGroupsPage() {
{loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p>
) : groups.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<h2 className="text-lg font-semibold text-foreground mb-2">{t('noGroups')}</h2>
<p className="text-sm text-muted-foreground mb-6">{t('noGroupsBody')}</p>
<button
type="button"
onClick={openCreate}
className="btn btn-primary"
>
{t('create')}
</button>
</div>
<SettingsSection>
<div className="flex flex-col items-center justify-center py-10 text-center">
<h2 className="text-lg font-semibold text-foreground mb-2">{t('noGroups')}</h2>
<p className="text-sm text-muted-foreground mb-6">{t('noGroupsBody')}</p>
<button type="button" onClick={openCreate} className="btn btn-secondary">
{t('create')}
</button>
</div>
</SettingsSection>
) : (
<div className="overflow-x-auto rounded-md border border-border">
<SettingsSection flush>
<table className="w-full text-sm">
<thead className="bg-muted/50">
<thead className="border-b border-border">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('name')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('ldapBinding')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('defaultGroup')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('memberCount')}
</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
<th className="px-4 pb-2 text-right text-xs font-medium text-muted-foreground">
{t('actions')}
</th>
</tr>
@@ -179,10 +179,10 @@ export default function AdminGroupsPage() {
<tbody className="divide-y divide-border">
{groups.map((group) => (
<tr key={group.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-medium text-foreground">
<td className="px-4 py-2.5 font-medium text-foreground">
<span title={group.name}>{group.internalName ?? group.name}</span>
</td>
<td className="px-4 py-3">
<td className="px-4 py-2.5">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
group.ldapDn
@@ -193,7 +193,7 @@ export default function AdminGroupsPage() {
{group.ldapDn ? t('boundBadge') : t('manualBadge')}
</span>
</td>
<td className="px-4 py-3">
<td className="px-4 py-2.5">
<button
type="button"
onClick={() => handleToggleDefault(group)}
@@ -222,27 +222,27 @@ export default function AdminGroupsPage() {
</svg>
</button>
</td>
<td className="px-4 py-3 text-muted-foreground">{group.memberCount}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<td className="px-4 py-2.5 text-muted-foreground">{group.memberCount}</td>
<td className="px-4 py-2.5 text-right">
<div className="flex items-center justify-end gap-1">
<button
type="button"
onClick={() => openEdit(group)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
className="btn btn-subtle h-7 px-2 text-xs font-medium"
>
{tCommon('edit')}
</button>
<button
type="button"
onClick={() => setMembersGroup(group)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
className="btn btn-subtle h-7 px-2 text-xs font-medium"
>
{t('membersButton')}
</button>
<button
type="button"
onClick={() => setDeleteTarget(group)}
className="rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10 transition-colors"
className="btn btn-subtle h-7 px-2 text-xs font-medium text-destructive hover:bg-destructive/10"
>
{tCommon('delete')}
</button>
@@ -252,7 +252,7 @@ export default function AdminGroupsPage() {
))}
</tbody>
</table>
</div>
</SettingsSection>
)}
{showFormModal && (
+7 -40
View File
@@ -1,42 +1,9 @@
'use client';
import { ControlCenterLayout } from '@/components/control-center/control-center-layout';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { AdminSidebar } from '@/components/admin/admin-sidebar';
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const t = useTranslations('settings');
return (
<div className="flex h-full flex-col md:flex-row">
<AdminSidebar />
<div className="min-w-0 flex-1 overflow-y-auto p-3 sm:p-6">
<Link
href="/"
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<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="15 18 9 12 15 6" />
</svg>
{t('backToDashboard')}
</Link>
{children}
</div>
</div>
);
/**
* Einstellungen und Verwaltung teilen sich einen Rahmen mit einer
* gemeinsamen Navigation (siehe ControlCenterLayout).
*/
export default function Layout({ children }: { children: React.ReactNode }) {
return <ControlCenterLayout>{children}</ControlCenterLayout>;
}
File diff suppressed because it is too large Load Diff
@@ -151,10 +151,9 @@ describe('Kategorien-Verwaltungsseite', () => {
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',
);
// Die Navigation links reicht — kein eigener Ruecksprung-Link mehr.
expect(screen.queryByRole('link', { name: 'Zurück zu Module' })).toBeNull();
expect(screen.getByRole('heading', { level: 1, name: 'Kategorien' })).toBeInTheDocument();
});
it('sperrt Pfeil nach oben bei der ersten und nach unten bei der letzten Kategorie und beim Eintrag', async () => {
@@ -1,8 +1,9 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { PageHeader } from '@/components/control-center/page-header';
import { SettingsSection } from '@/components/control-center/settings-section';
import {
assignModuleCategory,
createModuleCategory,
@@ -40,8 +41,7 @@ function Arrow({ direction }: { direction: 'up' | 'down' }) {
);
}
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';
const arrowButtonClass = 'btn btn-subtle btn-icon disabled:opacity-40';
/** Vertauscht zwei Nachbarn einer Liste (Pfeil nach oben/unten). */
function swapped<T>(list: T[], index: number, direction: -1 | 1): T[] {
@@ -100,7 +100,7 @@ function DeleteCategoryDialog({
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">
<h2 id="delete-category-title" className="cc-section-title mb-4">
{t('deleteDialog.title')}
</h2>
<p className="mb-4 text-sm text-foreground">
@@ -164,6 +164,7 @@ function DeleteCategoryDialog({
export default function AdminModuleCategoriesPage() {
const t = useTranslations('adminModules.categories');
const tCommon = useTranslations('common');
const tCc = useTranslations('controlCenter');
const categoryLabel = useCategoryLabel();
const currentUser = useAuthStore((s) => s.user);
const bumpSidebarRefresh = useMarketplaceStore((s) => s.bumpSidebarRefresh);
@@ -277,85 +278,63 @@ export default function AdminModuleCategoriesPage() {
}
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>
<div>
<PageHeader title={t('title')} description={tCc('descriptions.categories')} />
{(loadError || actionError) && (
<div
role="alert"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
className="mb-4 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>
<div className="space-y-4">
<SettingsSection>
<form onSubmit={handleCreate} className="flex flex-wrap items-end gap-3">
<div className="w-full max-w-md 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-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>
</SettingsSection>
{loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p>
) : (
<ul className="grid gap-4">
{overview.map((category, index) => {
{loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p>
) : (
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 ? (
// Huelle nur fuer den Testanker; Abstand kommt von space-y-4.
<div key={category.key} data-testid={`category-${category.key}`}>
<SettingsSection
id={`category-section-${category.key}`}
title={label}
description={category.isSystem ? t('deleteBlocked') : undefined}
flush
actions={
renaming ? (
<form
className="flex flex-1 flex-wrap items-center gap-2"
className="flex flex-wrap items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
saveRename(category);
@@ -367,15 +346,8 @@ export default function AdminModuleCategoriesPage() {
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"
className="flex h-8 w-56 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)}
@@ -383,15 +355,41 @@ export default function AdminModuleCategoriesPage() {
>
{t('renameCancel')}
</button>
<button
type="submit"
disabled={busy || renameValue.trim() === ''}
className="btn btn-primary"
>
{t('renameSave')}
</button>
</form>
) : (
<>
<h2 className="flex-1 text-base font-semibold text-foreground">{label}</h2>
<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>
<button
type="button"
disabled={busy}
onClick={() => startRename(category)}
className="btn btn-secondary"
className="btn btn-subtle"
>
{t('rename')}
</button>
@@ -400,25 +398,22 @@ export default function AdminModuleCategoriesPage() {
disabled={busy || category.isSystem}
title={category.isSystem ? t('deleteBlocked') : undefined}
onClick={() => setDeleteTarget(category)}
className="btn btn-secondary"
className="btn btn-subtle"
>
{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>
<p className="px-5 pb-4 text-sm text-muted-foreground">{t('empty')}</p>
) : (
<ul className="mt-3 divide-y divide-border rounded-md border border-border">
<ul className="divide-y divide-border border-t 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"
className="flex flex-wrap items-center gap-2 px-5 py-2"
>
<div className="flex gap-1">
<button
@@ -456,7 +451,7 @@ export default function AdminModuleCategoriesPage() {
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"
className="h-8 rounded-md border border-input bg-background px-2 text-sm"
>
{overview.map((c) => (
<option key={c.key} value={c.key}>
@@ -470,16 +465,16 @@ export default function AdminModuleCategoriesPage() {
)}
{category.personalCount > 0 && (
<p className="mt-2 text-xs text-muted-foreground">
<p className="border-t border-border px-5 py-2 text-xs text-muted-foreground">
{t('personalNote', { count: category.personalCount })}
</p>
)}
</section>
</li>
</SettingsSection>
</div>
);
})}
</ul>
)}
})
)}
</div>
{deleteTarget && (
<DeleteCategoryDialog
@@ -3,6 +3,8 @@
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { SettingsSection } from '@/components/control-center/settings-section';
import { useAuthStore } from '@/lib/stores/auth-store';
import { useCategoryLabel } from '@/lib/use-category-label';
@@ -48,6 +50,7 @@ export default function AdminModuleGrantsPage() {
const t = useTranslations('adminModules.grants');
const tGrantLabel = useTranslations('admin.groups.grants');
const tCommon = useTranslations('common');
const tCc = useTranslations('controlCenter');
const categoryLabel = useCategoryLabel();
const currentUser = useAuthStore((s) => s.user);
@@ -231,13 +234,11 @@ export default function AdminModuleGrantsPage() {
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
</div>
<div>
<PageHeader title={t('title')} description={tCc('descriptions.grants')} />
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<div className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
@@ -245,31 +246,44 @@ export default function AdminModuleGrantsPage() {
{loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p>
) : modules.length === 0 ? (
<div className="rounded-md border border-border bg-card p-6 text-center">
<p className="text-sm text-muted-foreground">{t('emptyModules')}</p>
<Link
href="/admin/modules"
className="mt-2 inline-block text-sm font-medium link"
>
{t('emptyModulesLink')}
</Link>
</div>
<SettingsSection>
<div className="text-center">
<p className="text-sm text-muted-foreground">{t('emptyModules')}</p>
<Link
href="/admin/modules"
className="mt-2 inline-block text-sm font-medium link"
>
{t('emptyModulesLink')}
</Link>
</div>
</SettingsSection>
) : (
<>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('searchPlaceholder')}
className="flex h-9 w-full max-w-sm rounded-md border border-input bg-background px-3 py-1 text-sm"
/>
<SettingsSection
flush
footer={
<div className="mr-auto space-y-1">
<p className="text-xs text-muted-foreground">{t('levelExplanation')}</p>
<p className="text-xs text-muted-foreground">{t('adminNote')}</p>
</div>
}
>
<div className="px-5 pb-3">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('searchPlaceholder')}
aria-label={t('searchPlaceholder')}
className="flex h-9 w-full max-w-sm rounded-md border border-input bg-background px-3 py-1 text-sm"
/>
</div>
{noMatch ? (
<div className="rounded-md border border-border bg-card p-6 text-center">
<div className="border-t border-border p-6 text-center">
<p className="text-sm text-muted-foreground">{t('noSearchResults', { search })}</p>
</div>
) : (
<div className="overflow-x-auto overflow-y-auto max-h-[70vh] rounded-md border border-border">
<div className="overflow-x-auto overflow-y-auto max-h-[70vh] border-t border-border">
<table className="w-full text-sm border-collapse">
<thead>
<tr>
@@ -360,10 +374,7 @@ export default function AdminModuleGrantsPage() {
</table>
</div>
)}
<p className="text-xs text-muted-foreground">{t('levelExplanation')}</p>
<p className="text-xs text-muted-foreground">{t('adminNote')}</p>
</>
</SettingsSection>
)}
</div>
);
@@ -1,10 +1,12 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { SettingsSection } from '@/components/control-center/settings-section';
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';
import { ActivateModuleDialog } from './components/ActivateModuleDialog';
@@ -30,6 +32,7 @@ interface TenantActivation {
export default function AdminModulesPage() {
const t = useTranslations('adminModules');
const tCommon = useTranslations('common');
const tCc = useTranslations('controlCenter');
const categoryLabel = useCategoryLabel();
const currentUser = useAuthStore((s) => s.user);
// Shared signal that makes the sidebar refetch its active-module list
@@ -49,6 +52,9 @@ export default function AdminModulesPage() {
const [activateDialogModule, setActivateDialogModule] = useState<Module | null>(null);
const isAdmin = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
// Nur zum Sortieren der Kategorie-Abschnitte; der Stand wird von der
// Seitenleiste geladen (kein zusaetzlicher Abruf von dieser Seite).
const storedCategories = useModuleCategoryStore((s) => s.categories);
const fetchModules = useCallback(async () => {
try {
@@ -119,6 +125,25 @@ export default function AdminModulesPage() {
}
};
// Module je Kategorie (Kategorie steht schon in den Daten). Reihenfolge der
// Abschnitte: gespeicherte Kategorien-Reihenfolge, sonst erstes Auftreten.
const groupedModules = useMemo(() => {
const groups = new Map<string, Module[]>();
for (const mod of modules) {
const list = groups.get(mod.category);
if (list) list.push(mod);
else groups.set(mod.category, [mod]);
}
const order = new Map(storedCategories.map((c, i) => [c.key, i]));
return [...groups.entries()]
.map(([category, mods]) => ({ category, mods }))
.sort(
(a, b) =>
(order.get(a.category) ?? Number.MAX_SAFE_INTEGER) -
(order.get(b.category) ?? Number.MAX_SAFE_INTEGER),
);
}, [modules, storedCategories]);
const getDescription = (desc: Record<string, string> | string): string => {
if (typeof desc === 'string') return desc;
return desc.de || desc.en || Object.values(desc)[0] || '';
@@ -133,30 +158,11 @@ export default function AdminModulesPage() {
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t('description')}</p>
</div>
<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>
<div>
<PageHeader title={t('title')} description={tCc('descriptions.modules')} />
{error && (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<div className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
@@ -166,76 +172,75 @@ export default function AdminModulesPage() {
) : modules.length === 0 ? (
<p className="text-muted-foreground">{t('noModules')}</p>
) : (
<div className="grid gap-4">
{modules.map((mod) => {
const isActive = activations.has(mod.id);
const isToggling = toggling === mod.id;
groupedModules.map(({ category, mods }) => (
<SettingsSection key={category} id={`module-category-${category}`} title={categoryLabel(category)} flush>
<ul className="divide-y divide-border">
{mods.map((mod) => {
const isActive = activations.has(mod.id);
const isToggling = toggling === mod.id;
return (
<div
key={mod.id}
className="flex items-center justify-between rounded-lg border border-border bg-card p-4 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-foreground">{mod.name}</h3>
<span className="text-xs text-muted-foreground">
v{mod.version}
</span>
{mod.isSystem && (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
{t('system')}
return (
<li key={mod.id} className="flex items-center justify-between gap-4 px-5 py-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-medium text-foreground">{mod.name}</h3>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
v{mod.version}
</span>
{mod.isSystem && (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
{t('system')}
</span>
)}
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{getDescription(mod.description)}
</p>
</div>
<div className="flex shrink-0 items-center gap-3">
<span
className={`w-20 text-right text-xs font-medium ${
isActive
? 'text-green-600 dark:text-green-400'
: 'text-muted-foreground'
}`}
>
{isActive ? t('activated') : t('deactivated')}
</span>
)}
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{categoryLabel(mod.category)}
</span>
</div>
<p className="mt-1 text-sm text-muted-foreground truncate">
{getDescription(mod.description)}
</p>
</div>
<div className="ml-4 flex items-center gap-3">
<span
className={`text-xs font-medium ${
isActive
? 'text-green-600 dark:text-green-400'
: 'text-muted-foreground'
}`}
>
{isActive ? t('activated') : t('deactivated')}
</span>
<button
type="button"
onClick={() => {
if (isActive) {
// Deactivation is unchanged -- direct toggle, no dialog.
toggleModule(mod.id, true);
} else {
// Activation opens the confirmation dialog instead of
// activating directly (D-10).
setActivateDialogModule(mod);
}
}}
disabled={isToggling}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-wait disabled:opacity-50 ${
isActive ? 'bg-primary' : 'bg-input'
}`}
role="switch"
aria-checked={isActive}
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform duration-200 ${
isActive ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
);
})}
</div>
<button
type="button"
onClick={() => {
if (isActive) {
// Deactivation is unchanged -- direct toggle, no dialog.
toggleModule(mod.id, true);
} else {
// Activation opens the confirmation dialog instead of
// activating directly (D-10).
setActivateDialogModule(mod);
}
}}
disabled={isToggling}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-wait disabled:opacity-50 ${
isActive ? 'bg-primary' : 'bg-input'
}`}
role="switch"
aria-checked={isActive}
aria-label={mod.name}
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform duration-200 ${
isActive ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
</li>
);
})}
</ul>
</SettingsSection>
))
)}
{activateDialogModule && (
@@ -1,16 +1,16 @@
'use client';
import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { SmtpSettingsForm } from '@/components/settings/smtp-settings-form';
export default function AdminSmtpPage() {
const t = useTranslations('settings');
const tCc = useTranslations('controlCenter');
return (
<div>
<h1 className="mb-6 text-lg font-semibold text-foreground">
{t('smtp.title')}
</h1>
<PageHeader title={t('smtp.title')} description={tCc('descriptions.smtp')} />
<SmtpSettingsForm />
</div>
);
@@ -2,6 +2,8 @@
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { SettingsSection } from '@/components/control-center/settings-section';
import { useAuthStore } from '@/lib/stores/auth-store';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -26,6 +28,7 @@ interface TenantFormData {
export default function AdminTenantsPage() {
const t = useTranslations('admin.tenants');
const tCommon = useTranslations('common');
const tCc = useTranslations('controlCenter');
const currentUser = useAuthStore((s) => s.user);
const [tenants, setTenants] = useState<Tenant[]>([]);
@@ -143,18 +146,16 @@ export default function AdminTenantsPage() {
}
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<button
type="button"
onClick={openCreate}
className="btn btn-primary"
>
{t('create')}
</button>
</div>
<div>
<PageHeader
title={t('title')}
description={tCc('descriptions.tenants')}
actions={
<button type="button" onClick={openCreate} className="btn btn-primary">
{t('create')}
</button>
}
/>
{/* Tenants table */}
{loading ? (
@@ -162,23 +163,23 @@ export default function AdminTenantsPage() {
) : tenants.length === 0 ? (
<p className="text-muted-foreground">{t('noTenants')}</p>
) : (
<div className="overflow-x-auto rounded-md border border-border">
<SettingsSection flush>
<table className="w-full text-sm">
<thead className="bg-muted/50">
<thead className="border-b border-border">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('name')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('slug')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('status')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('userCount')}
</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
<th className="px-4 pb-2 text-right text-xs font-medium text-muted-foreground">
{t('actions')}
</th>
</tr>
@@ -189,46 +190,46 @@ export default function AdminTenantsPage() {
key={tenant.id}
className="hover:bg-muted/30 transition-colors"
>
<td className="px-4 py-3 font-medium text-foreground">
<td className="px-4 py-2.5 font-medium text-foreground">
{tenant.name}
</td>
<td className="px-4 py-3 text-muted-foreground font-mono text-xs">
<td className="px-4 py-2.5 text-muted-foreground font-mono text-xs">
{tenant.slug}
</td>
<td className="px-4 py-3">
<td className="px-4 py-2.5">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
tenant.isActive
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500'
? 'bg-status-ok/12 text-status-ok-fg'
: 'bg-muted text-muted-foreground'
}`}
>
{tenant.isActive ? tCommon('active') : tCommon('inactive')}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">
<td className="px-4 py-2.5 text-muted-foreground">
{tenant.userCount}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<td className="px-4 py-2.5 text-right">
<div className="flex items-center justify-end gap-1">
<button
type="button"
onClick={() => openEdit(tenant)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
className="btn btn-subtle h-7 px-2 text-xs font-medium"
>
{tCommon('edit')}
</button>
<button
type="button"
onClick={() => handleToggleActive(tenant)}
className="rounded px-2 py-1 text-xs text-foreground hover:bg-muted transition-colors"
className="btn btn-subtle h-7 px-2 text-xs font-medium"
>
{tenant.isActive ? tCommon('inactive') : tCommon('active')}
</button>
<button
type="button"
onClick={() => setDeleteConfirm(tenant.id)}
className="rounded px-2 py-1 text-xs text-destructive hover:bg-destructive/10 transition-colors"
className="btn btn-subtle h-7 px-2 text-xs font-medium text-destructive hover:bg-destructive/10"
>
{tCommon('delete')}
</button>
@@ -238,14 +239,14 @@ export default function AdminTenantsPage() {
))}
</tbody>
</table>
</div>
</SettingsSection>
)}
{/* Create/Edit modal */}
{showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 className="text-lg font-bold text-foreground mb-4">
<h2 className="text-lg font-semibold text-foreground mb-4">
{editingTenant ? t('edit') : t('create')}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
+58 -42
View File
@@ -2,6 +2,8 @@
import { useLocale, useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { PageHeader } from '@/components/control-center/page-header';
import { SettingsSection } from '@/components/control-center/settings-section';
import { useAuthStore } from '@/lib/stores/auth-store';
import { UserAccessModal } from './components/UserAccessModal';
@@ -62,6 +64,7 @@ export default function AdminUsersPage() {
const tCommon = useTranslations('common');
const tHeader = useTranslations('header');
const tWelcome = useTranslations('admin.users.welcomeMail');
const tCc = useTranslations('controlCenter');
const locale = useLocale();
const currentUser = useAuthStore((s) => s.user);
@@ -304,19 +307,21 @@ export default function AdminUsersPage() {
};
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-foreground">{t('title')}</h1>
<button type="button" onClick={openCreate} className="btn btn-primary">
{t('create')}
</button>
</div>
<div>
<PageHeader
title={t('title')}
description={tCc('descriptions.users')}
actions={
<button type="button" onClick={openCreate} className="btn btn-primary">
{t('create')}
</button>
}
/>
{loadError && (
<div
role="alert"
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
>
{loadError}
</div>
@@ -325,7 +330,7 @@ export default function AdminUsersPage() {
{welcomeSuccess && (
<div
role="status"
className="flex items-start justify-between gap-3 rounded-md border border-status-ok/40 bg-status-ok/12 p-3 text-sm text-status-ok-fg"
className="mb-4 flex items-start justify-between gap-3 rounded-md border border-status-ok/40 bg-status-ok/12 p-3 text-sm text-status-ok-fg"
>
<span>{welcomeSuccess}</span>
<button
@@ -339,72 +344,83 @@ export default function AdminUsersPage() {
</div>
)}
{/* Users table */}
{/* Users table: feste Spaltenbreiten, Nebeninfos als zweite Zeile —
passt ohne waagerechtes Scrollen in die Inhaltsbreite (~64rem). */}
{loading ? (
<p className="text-muted-foreground">{tCommon('loading')}</p>
) : loadError ? null : users.length === 0 ? (
<p className="text-muted-foreground">{t('noUsers')}</p>
) : (
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<SettingsSection flush>
<table className="w-full table-fixed text-sm">
<thead className="border-b border-border">
<tr>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="px-4 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('username')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
{t('email')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="w-[15%] px-3 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('displayName')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="w-28 px-3 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('role')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="w-36 px-3 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('status')}
</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
<th className="w-32 px-3 pb-2 text-left text-xs font-medium text-muted-foreground">
{t('lastLogin')}
</th>
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
<th className="w-[9.5rem] px-4 pb-2 text-right text-xs font-medium text-muted-foreground">
{t('actions')}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{users.map((user) => (
<tr key={user.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-medium text-foreground">{user.username}</td>
<td className="px-4 py-3 text-muted-foreground">{user.email ?? '–'}</td>
<td className="px-4 py-3 text-muted-foreground">{user.displayName ?? '-'}</td>
<td className="px-4 py-3">
<tr key={user.id} className="align-top hover:bg-muted/30 transition-colors">
<td className="px-4 py-2.5">
<div className="truncate font-medium text-foreground" title={user.username}>
{user.username}
</div>
<div
className="truncate text-xs text-muted-foreground"
title={user.email ?? undefined}
>
{user.email ?? '–'}
</div>
</td>
<td className="px-3 py-2.5 text-muted-foreground">
<div className="truncate" title={user.displayName ?? undefined}>
{user.displayName ?? '-'}
</div>
</td>
<td className="px-3 py-2.5">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(user.role)}`}
className={`inline-block whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-medium ${roleBadgeClass(user.role)}`}
>
{tHeader(`role.${user.role}`)}
</span>
</td>
<td className="px-4 py-3">
<td className="px-3 py-2.5">
<span
className={`inline-block rounded-full px-2 py-0.5 text-xs font-medium ${
user.isActive
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500'
? 'bg-status-ok/12 text-status-ok-fg'
: 'bg-muted text-muted-foreground'
}`}
>
{user.isActive ? tCommon('active') : tCommon('inactive')}
</span>
{user.welcomeMailSentAt && (
<div className="mt-1 text-xs text-muted-foreground">
<div className="mt-1 text-xs leading-4 text-muted-foreground">
{tWelcome('sentAt', { date: formatDateTime(user.welcomeMailSentAt) })}
</div>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-muted-foreground">
<td className="px-3 py-2.5 text-xs leading-5 text-muted-foreground">
{user.lastLoginAt ? formatDateTime(user.lastLoginAt) : t('neverLoggedIn')}
</td>
<td className="px-4 py-3 text-right">
<td className="px-4 py-2 text-right">
<div className="flex items-center justify-end gap-0.5">
{canSendWelcome(user) && (
<button
@@ -415,7 +431,7 @@ export default function AdminUsersPage() {
welcomeBlockedReason(user) ??
(user.welcomeMailSentAt ? tWelcome('resend') : tWelcome('button'))
}
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
className="btn btn-subtle btn-icon text-muted-foreground hover:text-foreground disabled:opacity-40"
>
<ActionIcon kind="mail" />
<span className="sr-only">
@@ -427,7 +443,7 @@ export default function AdminUsersPage() {
type="button"
onClick={() => setDetailsUser(user)}
title={t('grants.detailsButton')}
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
className="btn btn-subtle btn-icon text-muted-foreground hover:text-foreground"
>
<ActionIcon kind="details" />
<span className="sr-only">{t('grants.detailsButton')}</span>
@@ -437,7 +453,7 @@ export default function AdminUsersPage() {
type="button"
onClick={() => openEdit(user)}
title={tCommon('edit')}
className="rounded p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
className="btn btn-subtle btn-icon text-muted-foreground hover:text-foreground"
>
<ActionIcon kind="edit" />
<span className="sr-only">{tCommon('edit')}</span>
@@ -452,7 +468,7 @@ export default function AdminUsersPage() {
}}
disabled={user.id === currentUser?.id}
title={tCommon('delete')}
className="rounded p-1.5 text-destructive hover:bg-destructive/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed disabled:pointer-events-none"
className="btn btn-subtle btn-icon text-destructive hover:bg-destructive/10 disabled:opacity-30 disabled:pointer-events-none"
>
<ActionIcon kind="delete" />
<span className="sr-only">{tCommon('delete')}</span>
@@ -464,14 +480,14 @@ export default function AdminUsersPage() {
))}
</tbody>
</table>
</div>
</SettingsSection>
)}
{/* Create/Edit modal */}
{showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg">
<h2 className="text-lg font-bold text-foreground mb-4">
<h2 className="text-lg font-semibold text-foreground mb-4">
{editingUser ? t('edit') : t('create')}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
@@ -619,7 +635,7 @@ export default function AdminUsersPage() {
aria-labelledby="welcome-mail-title"
className="w-full max-w-md rounded-lg bg-card dark:border dark:border-border p-6 shadow-lg"
>
<h2 id="welcome-mail-title" className="text-lg font-bold text-foreground mb-3">
<h2 id="welcome-mail-title" className="text-lg font-semibold text-foreground mb-3">
{tWelcome('dialogTitle')}
</h2>
<p className="text-sm text-foreground">{tWelcome('dialogText')}</p>
@@ -1,6 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { CalendarSettingsPanel } from '@/components/settings/calendar-settings-panel';
/**
@@ -9,12 +10,11 @@ import { CalendarSettingsPanel } from '@/components/settings/calendar-settings-p
*/
export default function CalendarSettingsPage() {
const t = useTranslations('settings');
const tCc = useTranslations('controlCenter');
return (
<div>
<h1 className="mb-6 text-lg font-semibold text-foreground">
{t('categoryCalendar')}
</h1>
<PageHeader title={t('categoryCalendar')} description={tCc('descriptions.calendar')} />
<CalendarSettingsPanel />
</div>
);
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { WidgetSettingsPanel } from '@/components/settings/widget-settings-panel';
import { fetchDashboards, fetchWidgets } from '@/lib/dashboard-api';
@@ -20,6 +21,7 @@ import { fetchDashboards, fetchWidgets } from '@/lib/dashboard-api';
*/
export default function WidgetSettingsPage() {
const t = useTranslations('settings');
const tCc = useTranslations('controlCenter');
const tCommon = useTranslations('common');
const [widgets, setWidgets] = useState<
Array<{ id: string; widgetType: string; config: Record<string, unknown> }>
@@ -42,9 +44,7 @@ export default function WidgetSettingsPage() {
return (
<div>
<h1 className="mb-6 text-lg font-semibold text-foreground">
{t('categoryWidgets')}
</h1>
<PageHeader title={t('categoryWidgets')} description={tCc('descriptions.widgets')} />
{isLoading ? (
<p className="text-sm text-muted-foreground">{tCommon('loading')}</p>
@@ -1,6 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { AccountSettingsForm } from '@/components/settings/account-settings-form';
/**
@@ -9,12 +10,11 @@ import { AccountSettingsForm } from '@/components/settings/account-settings-form
*/
export default function AccountSettingsPage() {
const t = useTranslations('settings');
const tCc = useTranslations('controlCenter');
return (
<div>
<h1 className="mb-6 text-lg font-semibold text-foreground">
{t('account.title')}
</h1>
<PageHeader title={t('account.title')} description={tCc('descriptions.account')} />
<AccountSettingsForm />
</div>
);
@@ -1,6 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { PageHeader } from '@/components/control-center/page-header';
import { DesktopAppSettings } from '@/components/settings/desktop-app-settings';
/**
@@ -9,12 +10,11 @@ import { DesktopAppSettings } from '@/components/settings/desktop-app-settings';
*/
export default function DesktopSettingsPage() {
const t = useTranslations('settings');
const tCc = useTranslations('controlCenter');
return (
<div>
<h1 className="mb-6 text-lg font-semibold text-foreground">
{t('desktop.title')}
</h1>
<PageHeader title={t('desktop.title')} description={tCc('descriptions.desktop')} />
<DesktopAppSettings />
</div>
);
+5 -44
View File
@@ -1,48 +1,9 @@
'use client';
import Link from 'next/link';
import { useTranslations } from 'next-intl';
import { SettingsSidebar } from '@/components/settings/settings-sidebar';
import { ControlCenterLayout } from '@/components/control-center/control-center-layout';
/**
* Settings nested layout (D-19, D-20).
* Renders within the portal AppShell, adding its own sub-sidebar on the left.
* Back-to-dashboard link at top of content area per UI-SPEC.
* Einstellungen und Verwaltung teilen sich einen Rahmen mit einer
* gemeinsamen Navigation (siehe ControlCenterLayout).
*/
export default function SettingsLayout({
children,
}: {
children: React.ReactNode;
}) {
const t = useTranslations('settings');
return (
<div className="flex h-full flex-col md:flex-row">
<SettingsSidebar />
<div className="min-w-0 flex-1 overflow-y-auto p-3 sm:p-6">
{/* Back to Dashboard link */}
<Link
href="/"
className="mb-4 inline-flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<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="15 18 9 12 15 6" />
</svg>
{t('backToDashboard')}
</Link>
{children}
</div>
</div>
);
export default function Layout({ children }: { children: React.ReactNode }) {
return <ControlCenterLayout>{children}</ControlCenterLayout>;
}
+2 -2
View File
@@ -1,8 +1,8 @@
import { redirect } from 'next/navigation';
/**
* Settings index page — redirects to /settings/dashboard (the default category).
* Settings index page — leitet auf Konto um (erster Eintrag der Navigation).
*/
export default function SettingsPage() {
redirect('/settings/dashboard');
redirect('/settings/general/account');
}