feat(B3): add tenant management UI (CRUD + tenant selector)

- frontend/src/api/tenants.ts: Tenant CRUD API client (getTenants, getTenant, createTenant, updateTenant, deleteTenant)
- frontend/src/pages/Tenants.tsx: Admin page with table, create/edit modals, delete confirm, and cross-tenant selector persisted in localStorage
- App.tsx: /tenants route (AdminRoute-guarded)
- Layout.tsx: Tenants sidebar link (admin-only, Building2 icon)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 16:13:26 +01:00
parent 995339959e
commit 82bf46725b
4 changed files with 514 additions and 1 deletions
+442
View File
@@ -0,0 +1,442 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import {
Plus, Trash2, Pencil, X, Building2, ChevronDown, Check, Users,
} from 'lucide-react'
import {
getTenants, createTenant, updateTenant, deleteTenant,
} from '../api/tenants'
import type { Tenant, TenantCreate, TenantUpdate } from '../api/tenants'
const TENANT_CONTEXT_KEY = 'schaeffler_tenant_id'
function slugify(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
export default function TenantsPage() {
const qc = useQueryClient()
// --- Tenant selector state (persisted in localStorage) ---
const [activeTenantId, setActiveTenantId] = useState<string | null>(
() => localStorage.getItem(TENANT_CONTEXT_KEY),
)
const [selectorOpen, setSelectorOpen] = useState(false)
useEffect(() => {
if (activeTenantId) {
localStorage.setItem(TENANT_CONTEXT_KEY, activeTenantId)
} else {
localStorage.removeItem(TENANT_CONTEXT_KEY)
}
}, [activeTenantId])
// --- Create modal state ---
const [showCreate, setShowCreate] = useState(false)
const [createForm, setCreateForm] = useState<TenantCreate>({ name: '', slug: '', is_active: true })
const [slugEdited, setSlugEdited] = useState(false)
// --- Edit modal state ---
const [editingTenant, setEditingTenant] = useState<Tenant | null>(null)
const [editForm, setEditForm] = useState<TenantUpdate>({})
// --- Delete confirm state ---
const [deletingId, setDeletingId] = useState<string | null>(null)
const { data: tenants = [], isLoading } = useQuery({
queryKey: ['tenants'],
queryFn: getTenants,
})
const activeTenant = tenants.find((t) => t.id === activeTenantId) ?? null
const createMut = useMutation({
mutationFn: (data: TenantCreate) => createTenant(data),
onSuccess: () => {
toast.success('Tenant erstellt')
qc.invalidateQueries({ queryKey: ['tenants'] })
setShowCreate(false)
setCreateForm({ name: '', slug: '', is_active: true })
setSlugEdited(false)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Fehler beim Erstellen'),
})
const updateMut = useMutation({
mutationFn: ({ id, data }: { id: string; data: TenantUpdate }) => updateTenant(id, data),
onSuccess: () => {
toast.success('Tenant aktualisiert')
qc.invalidateQueries({ queryKey: ['tenants'] })
setEditingTenant(null)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Fehler beim Aktualisieren'),
})
const deleteMut = useMutation({
mutationFn: (id: string) => deleteTenant(id),
onSuccess: (_data, id) => {
toast.success('Tenant gelöscht')
qc.invalidateQueries({ queryKey: ['tenants'] })
if (activeTenantId === id) setActiveTenantId(null)
setDeletingId(null)
},
onError: (e: any) => toast.error(e.response?.data?.detail || 'Fehler beim Löschen'),
})
// Auto-generate slug from name unless manually edited
const handleCreateNameChange = (name: string) => {
setCreateForm((prev) => ({
...prev,
name,
slug: slugEdited ? prev.slug : slugify(name),
}))
}
const handleCreateSlugChange = (slug: string) => {
setSlugEdited(true)
setCreateForm((prev) => ({ ...prev, slug }))
}
const openEdit = (tenant: Tenant) => {
setEditingTenant(tenant)
setEditForm({ name: tenant.name, slug: tenant.slug, is_active: tenant.is_active })
}
return (
<div className="p-6 max-w-5xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Building2 size={24} className="text-accent" />
<div>
<h1 className="text-xl font-bold text-content">Tenants</h1>
<p className="text-sm text-content-muted">Mandanten verwalten und Kontext wählen</p>
</div>
</div>
<button
onClick={() => { setShowCreate(true); setSlugEdited(false) }}
className="flex items-center gap-2 px-4 py-2 bg-accent text-accent-text rounded-md text-sm font-medium hover:bg-accent-hover transition-colors"
>
<Plus size={16} />
Neuer Tenant
</button>
</div>
{/* Tenant Selector */}
<div className="mb-6 p-4 bg-surface border border-border-default rounded-lg">
<p className="text-xs font-semibold text-content-muted uppercase tracking-wider mb-2">
Admin Cross-Tenant-Ansicht
</p>
<div className="relative inline-block">
<button
onClick={() => setSelectorOpen((o) => !o)}
className="flex items-center gap-2 px-3 py-2 bg-surface-alt border border-border-default rounded-md text-sm text-content hover:bg-surface-hover transition-colors min-w-[220px]"
>
<Building2 size={14} className="text-content-muted shrink-0" />
<span className="flex-1 text-left truncate">
{activeTenant ? activeTenant.name : 'Alle Tenants / Admin-Ansicht'}
</span>
<ChevronDown size={14} className="text-content-muted shrink-0" />
</button>
{selectorOpen && (
<div className="absolute z-20 top-full left-0 mt-1 min-w-[220px] bg-surface border border-border-default rounded-md shadow-lg overflow-hidden">
<button
onClick={() => { setActiveTenantId(null); setSelectorOpen(false) }}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-content hover:bg-surface-hover text-left"
>
{activeTenantId === null && <Check size={14} className="text-accent shrink-0" />}
{activeTenantId !== null && <span className="w-[14px] shrink-0" />}
<span>Alle Tenants / Admin-Ansicht</span>
</button>
{tenants.map((t) => (
<button
key={t.id}
onClick={() => { setActiveTenantId(t.id); setSelectorOpen(false) }}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-content hover:bg-surface-hover text-left"
>
{activeTenantId === t.id && <Check size={14} className="text-accent shrink-0" />}
{activeTenantId !== t.id && <span className="w-[14px] shrink-0" />}
<span className="flex-1 truncate">{t.name}</span>
{!t.is_active && (
<span className="text-xs px-1.5 py-0.5 rounded bg-surface-muted text-content-muted">inaktiv</span>
)}
</button>
))}
</div>
)}
</div>
{activeTenant && (
<p className="mt-2 text-xs text-content-muted">
API-Requests werden mit Header <code className="font-mono bg-surface-alt px-1 rounded">X-Tenant-ID: {activeTenant.id}</code> gesendet.
</p>
)}
</div>
{/* Table */}
<div className="bg-surface border border-border-default rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-default bg-surface-alt">
<th className="px-4 py-3 text-left font-semibold text-content-secondary">Name</th>
<th className="px-4 py-3 text-left font-semibold text-content-secondary">Slug</th>
<th className="px-4 py-3 text-left font-semibold text-content-secondary">Status</th>
<th className="px-4 py-3 text-left font-semibold text-content-secondary">
<span className="flex items-center gap-1"><Users size={13} /> Nutzer</span>
</th>
<th className="px-4 py-3 text-left font-semibold text-content-secondary">Erstellt</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-content-muted">Lade Tenants</td>
</tr>
)}
{!isLoading && tenants.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-content-muted">
Noch keine Tenants vorhanden.
</td>
</tr>
)}
{tenants.map((tenant) => (
<tr key={tenant.id} className="border-b border-border-default last:border-0 hover:bg-surface-hover transition-colors">
<td className="px-4 py-3 font-medium text-content">
<div className="flex items-center gap-2">
{activeTenantId === tenant.id && (
<span className="w-1.5 h-1.5 rounded-full bg-accent shrink-0" title="Aktiver Kontext" />
)}
{tenant.name}
</div>
</td>
<td className="px-4 py-3 text-content-muted font-mono text-xs">{tenant.slug}</td>
<td className="px-4 py-3">
{tenant.is_active ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-status-success-bg text-status-success-text">
aktiv
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-surface-muted text-content-muted">
inaktiv
</span>
)}
</td>
<td className="px-4 py-3 text-content-muted">
{tenant.user_count != null ? tenant.user_count : '—'}
</td>
<td className="px-4 py-3 text-content-muted">{formatDate(tenant.created_at)}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => openEdit(tenant)}
className="p-1.5 rounded hover:bg-surface-alt text-content-muted hover:text-content transition-colors"
title="Bearbeiten"
>
<Pencil size={15} />
</button>
<button
onClick={() => setDeletingId(tenant.id)}
className="p-1.5 rounded hover:bg-status-error-bg text-content-muted hover:text-status-error-text transition-colors"
title="Löschen"
>
<Trash2 size={15} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Create Modal */}
{showCreate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-surface rounded-xl shadow-xl w-full max-w-md mx-4 p-6">
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-semibold text-content">Neuer Tenant</h2>
<button
onClick={() => setShowCreate(false)}
className="p-1.5 rounded hover:bg-surface-alt text-content-muted transition-colors"
>
<X size={18} />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">Name *</label>
<input
type="text"
value={createForm.name}
onChange={(e) => handleCreateNameChange(e.target.value)}
placeholder="z.B. Schaeffler GmbH"
className="w-full px-3 py-2 rounded-md border border-border-default bg-surface text-content text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
</div>
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">
Slug *
<span className="text-xs font-normal text-content-muted ml-1">(URL-Kennung, automatisch generiert)</span>
</label>
<input
type="text"
value={createForm.slug}
onChange={(e) => handleCreateSlugChange(e.target.value)}
placeholder="z.B. schaeffler-gmbh"
className="w-full px-3 py-2 rounded-md border border-border-default bg-surface text-content text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
</div>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={createForm.is_active ?? true}
onChange={(e) => setCreateForm((prev) => ({ ...prev, is_active: e.target.checked }))}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-surface-muted rounded-full peer peer-checked:bg-accent transition-colors after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4" />
</label>
<span className="text-sm text-content-secondary">Aktiv</span>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={() => setShowCreate(false)}
className="px-4 py-2 text-sm rounded-md border border-border-default text-content-secondary hover:bg-surface-alt transition-colors"
>
Abbrechen
</button>
<button
onClick={() => createMut.mutate(createForm)}
disabled={!createForm.name.trim() || !createForm.slug.trim() || createMut.isPending}
className="px-4 py-2 text-sm rounded-md bg-accent text-accent-text font-medium hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{createMut.isPending ? 'Erstelle…' : 'Erstellen'}
</button>
</div>
</div>
</div>
)}
{/* Edit Modal */}
{editingTenant && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-surface rounded-xl shadow-xl w-full max-w-md mx-4 p-6">
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-semibold text-content">Tenant bearbeiten</h2>
<button
onClick={() => setEditingTenant(null)}
className="p-1.5 rounded hover:bg-surface-alt text-content-muted transition-colors"
>
<X size={18} />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">Name</label>
<input
type="text"
value={editForm.name ?? ''}
onChange={(e) => setEditForm((prev) => ({ ...prev, name: e.target.value }))}
className="w-full px-3 py-2 rounded-md border border-border-default bg-surface text-content text-sm focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
</div>
<div>
<label className="block text-sm font-medium text-content-secondary mb-1">Slug</label>
<input
type="text"
value={editForm.slug ?? ''}
onChange={(e) => setEditForm((prev) => ({ ...prev, slug: e.target.value }))}
className="w-full px-3 py-2 rounded-md border border-border-default bg-surface text-content text-sm font-mono focus:outline-none focus:ring-2 focus:ring-accent/50"
/>
</div>
<div className="flex items-center gap-3">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={editForm.is_active ?? true}
onChange={(e) => setEditForm((prev) => ({ ...prev, is_active: e.target.checked }))}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-surface-muted rounded-full peer peer-checked:bg-accent transition-colors after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4" />
</label>
<span className="text-sm text-content-secondary">Aktiv</span>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={() => setEditingTenant(null)}
className="px-4 py-2 text-sm rounded-md border border-border-default text-content-secondary hover:bg-surface-alt transition-colors"
>
Abbrechen
</button>
<button
onClick={() => updateMut.mutate({ id: editingTenant.id, data: editForm })}
disabled={updateMut.isPending}
className="px-4 py-2 text-sm rounded-md bg-accent text-accent-text font-medium hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{updateMut.isPending ? 'Speichere…' : 'Speichern'}
</button>
</div>
</div>
</div>
)}
{/* Delete Confirm Modal */}
{deletingId && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-surface rounded-xl shadow-xl w-full max-w-sm mx-4 p-6">
<div className="flex items-start gap-3 mb-4">
<div className="w-9 h-9 rounded-full bg-status-error-bg flex items-center justify-center shrink-0">
<Trash2 size={16} className="text-status-error-text" />
</div>
<div>
<h2 className="text-base font-semibold text-content">Tenant löschen?</h2>
<p className="text-sm text-content-muted mt-1">
Diese Aktion kann nicht rückgängig gemacht werden.
</p>
</div>
</div>
<div className="flex justify-end gap-2">
<button
onClick={() => setDeletingId(null)}
className="px-4 py-2 text-sm rounded-md border border-border-default text-content-secondary hover:bg-surface-alt transition-colors"
>
Abbrechen
</button>
<button
onClick={() => deleteMut.mutate(deletingId)}
disabled={deleteMut.isPending}
className="px-4 py-2 text-sm rounded-md bg-status-error-bg text-status-error-text font-medium hover:opacity-80 disabled:opacity-50 disabled:cursor-not-allowed transition-opacity"
>
{deleteMut.isPending ? 'Lösche…' : 'Löschen'}
</button>
</div>
</div>
</div>
)}
</div>
)
}