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');
}
+182
View File
@@ -426,6 +426,188 @@ body {
background-color: var(--primary-strong);
}
/* ---------------------------------------------------------------------------
Einstellungen und Verwaltung (Design-Runde „Verwaltung aufgeraeumt“)
Eine Navigation fuer beide Bereiche, ein Seitenkopf, eine Abschnittskarte.
Inhalt mit begrenzter Lesebreite statt ueber den ganzen Bildschirm.
------------------------------------------------------------------------- */
.cc-aside {
flex-shrink: 0;
border-bottom: 1px solid var(--border);
background: var(--background);
overflow-y: auto;
}
@media (min-width: 768px) {
.cc-aside {
width: 248px;
border-bottom: 0;
border-right: 1px solid var(--border);
}
}
.cc-nav-mobile {
padding: 0.75rem 1rem;
}
@media (min-width: 768px) {
.cc-nav-mobile {
display: none;
}
}
@media (max-width: 767px) {
.cc-nav {
display: none !important;
}
}
.cc-nav {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1.25rem 0.75rem 1.5rem;
}
.cc-nav-block {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.cc-nav-title {
padding: 0 0.625rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--foreground);
}
.cc-nav-block + .cc-nav-block {
border-top: 1px solid var(--border);
padding-top: 1.25rem;
}
.cc-nav-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.cc-nav-heading {
padding: 0 0.625rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--muted-foreground);
}
.cc-nav-item {
display: flex;
height: 2rem;
align-items: center;
gap: 0.625rem;
border-radius: var(--radius);
padding: 0 0.625rem;
font-size: 0.875rem;
color: var(--sidebar-foreground);
transition: background-color 120ms ease;
}
.cc-nav-item:hover {
background: var(--sidebar-hover);
}
.cc-nav-item.is-active {
background: var(--sidebar-accent);
color: var(--sidebar-accent-foreground);
font-weight: 600;
}
.cc-nav-item:focus-visible {
outline: 2px solid var(--ring);
outline-offset: -2px;
}
.cc-nav-icon {
display: inline-flex;
color: var(--muted-foreground);
}
.cc-nav-item.is-active .cc-nav-icon {
color: var(--foreground);
}
.cc-content {
max-width: 64rem;
padding: 1.5rem 1rem 3rem;
}
@media (min-width: 640px) {
.cc-content {
padding: 2rem 2.5rem 4rem;
}
}
.cc-page-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem 1.5rem;
margin-bottom: 1.5rem;
}
.cc-page-title {
font-size: 1.375rem;
line-height: 1.75rem;
font-weight: 600;
color: var(--foreground);
}
.cc-page-description {
margin-top: 0.375rem;
max-width: 42rem;
font-size: 0.875rem;
line-height: 1.375rem;
color: var(--muted-foreground);
}
.cc-section {
border: 1px solid var(--border);
border-radius: calc(var(--radius) + 2px);
background: var(--card);
box-shadow: var(--shadow-sm);
}
.cc-section + .cc-section {
margin-top: 1rem;
}
.cc-section-head {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1rem 1.25rem 0;
}
.cc-section-title {
font-size: 0.9375rem;
font-weight: 600;
color: var(--foreground);
}
.cc-section-description {
margin-top: 0.25rem;
max-width: 40rem;
font-size: 0.8125rem;
line-height: 1.25rem;
color: var(--muted-foreground);
}
.cc-section-body {
padding: 1rem 1.25rem 1.25rem;
}
.cc-section-body-flush {
padding-top: 0.75rem;
overflow-x: auto;
}
/* Tabellen schrumpfen in schmalen Karten (Handy, kleines Fenster) nicht bis
zur Unlesbarkeit, sondern lassen sich innerhalb der Karte seitlich
schieben. Gemessen an der Kartenbreite, nicht am Bildschirm, weil zwei
Seitenleisten davon schon fast 500 px belegen. */
.cc-section-body-flush {
container-type: inline-size;
}
@container (max-width: 50rem) {
.cc-section-body-flush table {
min-width: 50rem;
}
}
.cc-section-footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 0.5rem;
border-top: 1px solid var(--border);
padding: 0.75rem 1.25rem;
background: var(--well);
border-radius: 0 0 calc(var(--radius) + 2px) calc(var(--radius) + 2px);
}
/* Checkboxen und Optionsfelder in der Link-Farbe statt Browser-Blau bzw.
Gelb (Gelb mit weissem Haken waere kaum lesbar). */
input[type="checkbox"], input[type="radio"] {