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:
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user