refactor(admin): split multi-tenant page.tsx into colocated components

Extract types, constants, helpers, and UI pieces (LoadingSkeleton,
EmptyState, StatCard, ComplianceRing, Modal, TenantCard,
CreateTenantModal, EditTenantModal, TenantDetailModal) into
_components/ and _types.ts to bring page.tsx from 1663 LOC to
432 LOC (under the 500 hard cap). Behavior preserved.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-11 22:47:59 +02:00
parent 74927c6f66
commit dca0c96f2a
13 changed files with 1268 additions and 1246 deletions

View File

@@ -0,0 +1,42 @@
'use client'
import { getScoreColor } from './helpers'
export function ComplianceRing({ score, size = 64 }: { score: number; size?: number }) {
const strokeWidth = 5
const radius = (size - strokeWidth) / 2
const circumference = 2 * Math.PI * radius
const progress = Math.max(0, Math.min(100, score))
const offset = circumference - (progress / 100) * circumference
const color = getScoreColor(score)
return (
<div className="relative" style={{ width: size, height: size }}>
<svg width={size} height={size} className="transform -rotate-90">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#e2e8f0"
strokeWidth={strokeWidth}
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-700 ease-out"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-bold text-slate-900">{score}</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,160 @@
'use client'
import React, { useState } from 'react'
import { AlertTriangle, Loader2 } from 'lucide-react'
import type { CreateTenantForm } from '../_types'
import { EMPTY_CREATE_FORM } from './constants'
import { apiFetch, slugify } from './helpers'
import { Modal } from './Modal'
export function CreateTenantModal({
open,
onClose,
onCreated,
}: {
open: boolean
onClose: () => void
onCreated: () => void
}) {
const [form, setForm] = useState<CreateTenantForm>({ ...EMPTY_CREATE_FORM })
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [slugManual, setSlugManual] = useState(false)
const handleNameChange = (name: string) => {
setForm((prev) => ({
...prev,
name,
slug: slugManual ? prev.slug : slugify(name),
}))
}
const handleSlugChange = (slug: string) => {
setSlugManual(true)
setForm((prev) => ({ ...prev, slug: slugify(slug) }))
}
const isValid = form.name.trim().length >= 2 && form.slug.trim().length >= 2
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!isValid) return
setSaving(true)
setError(null)
try {
await apiFetch('/tenants', {
method: 'POST',
body: JSON.stringify({
name: form.name.trim(),
slug: form.slug.trim(),
max_users: form.max_users,
llm_quota_monthly: form.llm_quota_monthly,
}),
})
setForm({ ...EMPTY_CREATE_FORM })
setSlugManual(false)
onCreated()
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Erstellen')
} finally {
setSaving(false)
}
}
const handleClose = () => {
setForm({ ...EMPTY_CREATE_FORM })
setSlugManual(false)
setError(null)
onClose()
}
return (
<Modal open={open} onClose={handleClose} title="Neuen Mandanten erstellen">
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="flex items-center gap-2 p-3 text-sm text-red-700 bg-red-50 rounded-lg border border-red-200">
<AlertTriangle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="z.B. Musterfirma GmbH"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Slug <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.slug}
onChange={(e) => handleSlugChange(e.target.value)}
placeholder="musterfirma-gmbh"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
<p className="mt-1 text-xs text-slate-400">
Kleinbuchstaben, keine Leerzeichen. Wird automatisch aus dem Namen generiert.
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Max. Benutzer
</label>
<input
type="number"
value={form.max_users}
onChange={(e) => setForm((prev) => ({ ...prev, max_users: parseInt(e.target.value) || 0 }))}
min={1}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
LLM Kontingent / Monat
</label>
<input
type="number"
value={form.llm_quota_monthly}
onChange={(e) => setForm((prev) => ({ ...prev, llm_quota_monthly: parseInt(e.target.value) || 0 }))}
min={0}
step={1000}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={handleClose}
className="px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 hover:bg-slate-200 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
type="submit"
disabled={!isValid || saving}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors"
>
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
Mandant erstellen
</button>
</div>
</form>
</Modal>
)
}

View File

@@ -0,0 +1,155 @@
'use client'
import React, { useEffect, useState } from 'react'
import { AlertTriangle, Loader2 } from 'lucide-react'
import type { EditTenantForm, TenantOverview } from '../_types'
import { STATUS_OPTIONS } from './constants'
import { apiFetch } from './helpers'
import { Modal } from './Modal'
export function EditTenantModal({
open,
onClose,
tenant,
onUpdated,
}: {
open: boolean
onClose: () => void
tenant: TenantOverview | null
onUpdated: () => void
}) {
const [form, setForm] = useState<EditTenantForm>({
name: '',
max_users: 100,
llm_quota_monthly: 10000,
status: 'active',
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (tenant) {
setForm({
name: tenant.name,
max_users: tenant.max_users,
llm_quota_monthly: tenant.llm_quota_monthly,
status: tenant.status,
})
setError(null)
}
}, [tenant])
const isValid = form.name.trim().length >= 2
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!isValid || !tenant) return
setSaving(true)
setError(null)
try {
await apiFetch(`/tenants/${tenant.id}`, {
method: 'PUT',
body: JSON.stringify({
name: form.name.trim(),
max_users: form.max_users,
llm_quota_monthly: form.llm_quota_monthly,
status: form.status,
}),
})
onUpdated()
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Aktualisieren')
} finally {
setSaving(false)
}
}
return (
<Modal open={open} onClose={onClose} title={`Mandant bearbeiten: ${tenant?.name || ''}`}>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="flex items-center gap-2 p-3 text-sm text-red-700 bg-red-50 rounded-lg border border-red-200">
<AlertTriangle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Status
</label>
<select
value={form.status}
onChange={(e) => setForm((prev) => ({ ...prev, status: e.target.value }))}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Max. Benutzer
</label>
<input
type="number"
value={form.max_users}
onChange={(e) => setForm((prev) => ({ ...prev, max_users: parseInt(e.target.value) || 0 }))}
min={1}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
LLM Kontingent / Monat
</label>
<input
type="number"
value={form.llm_quota_monthly}
onChange={(e) => setForm((prev) => ({ ...prev, llm_quota_monthly: parseInt(e.target.value) || 0 }))}
min={0}
step={1000}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 hover:bg-slate-200 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
type="submit"
disabled={!isValid || saving}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors"
>
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
Speichern
</button>
</div>
</form>
</Modal>
)
}

View File

@@ -0,0 +1,26 @@
'use client'
import React from 'react'
export function EmptyState({
icon,
title,
description,
action,
}: {
icon: React.ReactNode
title: string
description: string
action?: React.ReactNode
}) {
return (
<div className="text-center py-16">
<div className="w-16 h-16 mx-auto bg-indigo-50 rounded-full flex items-center justify-center mb-4">
{icon}
</div>
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
<p className="mt-2 text-sm text-slate-500 max-w-md mx-auto">{description}</p>
{action && <div className="mt-6">{action}</div>}
</div>
)
}

View File

@@ -0,0 +1,42 @@
'use client'
export function LoadingSkeleton() {
return (
<div className="space-y-8 animate-pulse">
{/* Header Skeleton */}
<div className="flex items-center justify-between">
<div>
<div className="h-8 w-80 bg-slate-200 rounded mb-2" />
<div className="h-4 w-56 bg-slate-200 rounded" />
</div>
<div className="flex gap-2">
<div className="h-10 w-10 bg-slate-200 rounded-lg" />
<div className="h-10 w-52 bg-slate-200 rounded-lg" />
</div>
</div>
{/* Stats Skeleton */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-white rounded-xl border border-slate-200 p-5">
<div className="h-4 w-28 bg-slate-200 rounded mb-3" />
<div className="h-8 w-16 bg-slate-200 rounded" />
</div>
))}
</div>
{/* Cards Skeleton */}
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-white rounded-xl border border-slate-200 p-5">
<div className="h-5 w-40 bg-slate-200 rounded mb-3" />
<div className="h-4 w-28 bg-slate-200 rounded mb-4" />
<div className="h-16 w-16 bg-slate-200 rounded-full mx-auto mb-4" />
<div className="space-y-2">
<div className="h-3 w-full bg-slate-200 rounded" />
<div className="h-3 w-3/4 bg-slate-200 rounded" />
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,38 @@
'use client'
import React from 'react'
import { X } from 'lucide-react'
export function Modal({
open,
onClose,
title,
children,
maxWidth = 'max-w-lg',
}: {
open: boolean
onClose: () => void
title: string
children: React.ReactNode
maxWidth?: string
}) {
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
<div className={`relative bg-white rounded-2xl shadow-2xl w-full ${maxWidth} max-h-[90vh] overflow-y-auto`}>
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="px-6 py-5">{children}</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,51 @@
'use client'
import React from 'react'
export function StatCard({
label,
value,
icon,
color,
}: {
label: string
value: string | number
icon: React.ReactNode
color: 'indigo' | 'green' | 'blue' | 'red'
}) {
const colorMap = {
indigo: {
iconBg: 'bg-indigo-100',
iconText: 'text-indigo-600',
valueBg: '',
},
green: {
iconBg: 'bg-green-100',
iconText: 'text-green-600',
valueBg: '',
},
blue: {
iconBg: 'bg-blue-100',
iconText: 'text-blue-600',
valueBg: '',
},
red: {
iconBg: 'bg-red-100',
iconText: 'text-red-600',
valueBg: '',
},
}
const c = colorMap[color]
return (
<div className="bg-white rounded-xl border border-slate-200 p-5 hover:shadow-sm transition-shadow">
<div className="flex items-center gap-3 mb-3">
<div className={`w-10 h-10 rounded-lg ${c.iconBg} flex items-center justify-center`}>
<span className={c.iconText}>{icon}</span>
</div>
<span className="text-sm font-medium text-slate-500">{label}</span>
</div>
<div className="text-2xl font-bold text-slate-900">{value}</div>
</div>
)
}

View File

@@ -0,0 +1,151 @@
'use client'
import { useState } from 'react'
import {
AlertTriangle,
ChevronDown,
ChevronUp,
Eye,
Globe,
GraduationCap,
Pencil,
Shield,
Truck,
Users,
} from 'lucide-react'
import type { TenantOverview } from '../_types'
import { formatDate, formatDateTime, getRiskBadgeClasses, getStatusBadge } from './helpers'
import { ComplianceRing } from './ComplianceRing'
export function TenantCard({
tenant,
onEdit,
onViewDetails,
onSwitchTenant,
}: {
tenant: TenantOverview
onEdit: (t: TenantOverview) => void
onViewDetails: (t: TenantOverview) => void
onSwitchTenant: (t: TenantOverview) => void
}) {
const [expanded, setExpanded] = useState(false)
const statusInfo = getStatusBadge(tenant.status)
return (
<div className="bg-white rounded-xl border border-slate-200 hover:border-indigo-300 hover:shadow-md transition-all overflow-hidden">
{/* Card Header */}
<div className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-base font-semibold text-slate-900 truncate">{tenant.name}</h3>
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full ${statusInfo.bg} ${statusInfo.text}`}>
{statusInfo.icon}
{statusInfo.label}
</span>
</div>
<p className="text-xs text-slate-400 font-mono">{tenant.slug}</p>
</div>
<ComplianceRing score={tenant.compliance_score} size={56} />
</div>
{/* Risk Level */}
<div className="flex items-center gap-2 mb-4">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-semibold rounded-full ${getRiskBadgeClasses(tenant.risk_level)}`}>
<Shield className="w-3 h-3" />
{tenant.risk_level}
</span>
<span className="text-xs text-slate-400">
{tenant.namespace_count} Namespace{tenant.namespace_count !== 1 ? 's' : ''}
</span>
<span className="text-xs text-slate-400 ml-auto">
{formatDate(tenant.created_at)}
</span>
</div>
{/* Quick Metrics */}
<div className="grid grid-cols-2 gap-2 mb-4">
<div className="flex items-center gap-1.5 text-xs text-slate-600 bg-slate-50 rounded-lg px-2.5 py-1.5">
<AlertTriangle className="w-3.5 h-3.5 text-orange-500" />
<span>{tenant.open_incidents} Vorfaelle</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-slate-600 bg-slate-50 rounded-lg px-2.5 py-1.5">
<Shield className="w-3.5 h-3.5 text-indigo-500" />
<span>{tenant.open_reports} Meldungen</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-slate-600 bg-slate-50 rounded-lg px-2.5 py-1.5">
<Users className="w-3.5 h-3.5 text-blue-500" />
<span>{tenant.pending_dsrs} DSRs</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-slate-600 bg-slate-50 rounded-lg px-2.5 py-1.5">
<GraduationCap className="w-3.5 h-3.5 text-green-500" />
<span>{tenant.training_completion_rate}% Training</span>
</div>
</div>
{/* Vendor Risk Info */}
{tenant.vendor_risk_high > 0 && (
<div className="flex items-center gap-1.5 text-xs text-orange-600 bg-orange-50 rounded-lg px-2.5 py-1.5 mb-4">
<Truck className="w-3.5 h-3.5" />
<span>{tenant.vendor_risk_high} Dienstleister mit hohem Risiko</span>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2">
<button
onClick={() => onViewDetails(tenant)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-indigo-600 bg-indigo-50 hover:bg-indigo-100 rounded-lg transition-colors"
>
<Eye className="w-3.5 h-3.5" />
Details
</button>
<button
onClick={() => onEdit(tenant)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-slate-600 bg-slate-50 hover:bg-slate-100 rounded-lg transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
Bearbeiten
</button>
<button
onClick={() => onSwitchTenant(tenant)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors ml-auto"
>
<Globe className="w-3.5 h-3.5" />
Wechseln
</button>
<button
onClick={() => setExpanded(!expanded)}
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
>
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
</div>
</div>
{/* Expanded Section */}
{expanded && (
<div className="border-t border-slate-100 bg-slate-50/50 px-5 py-4 space-y-3">
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<span className="text-slate-400">Max. Benutzer</span>
<p className="font-semibold text-slate-700">{tenant.max_users.toLocaleString('de-DE')}</p>
</div>
<div>
<span className="text-slate-400">LLM Kontingent / Monat</span>
<p className="font-semibold text-slate-700">{tenant.llm_quota_monthly.toLocaleString('de-DE')}</p>
</div>
<div>
<span className="text-slate-400">Compliance Score</span>
<p className="font-semibold text-slate-700">{tenant.compliance_score} / 100</p>
</div>
<div>
<span className="text-slate-400">Aktualisiert</span>
<p className="font-semibold text-slate-700">{formatDateTime(tenant.updated_at)}</p>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,348 @@
'use client'
import React, { useCallback, useEffect, useState } from 'react'
import {
Activity,
AlertTriangle,
BarChart3,
Globe,
GraduationCap,
Loader2,
Plus,
Settings,
Shield,
Truck,
Users,
} from 'lucide-react'
import type { CreateNamespaceForm, TenantNamespace, TenantOverview } from '../_types'
import { DATA_CLASSIFICATIONS, EMPTY_NAMESPACE_FORM, ISOLATION_LEVELS } from './constants'
import { apiFetch, formatDate, getRiskBadgeClasses, getScoreColor, getStatusBadge, slugify } from './helpers'
import { ComplianceRing } from './ComplianceRing'
import { Modal } from './Modal'
export function TenantDetailModal({
open,
onClose,
tenant,
onSwitchTenant,
}: {
open: boolean
onClose: () => void
tenant: TenantOverview | null
onSwitchTenant: (t: TenantOverview) => void
}) {
const [namespaces, setNamespaces] = useState<TenantNamespace[]>([])
const [namespacesLoading, setNamespacesLoading] = useState(false)
const [namespacesError, setNamespacesError] = useState<string | null>(null)
const [showCreateNs, setShowCreateNs] = useState(false)
const [nsForm, setNsForm] = useState<CreateNamespaceForm>({ ...EMPTY_NAMESPACE_FORM })
const [nsCreating, setNsCreating] = useState(false)
const [nsError, setNsError] = useState<string | null>(null)
const loadNamespaces = useCallback(async () => {
if (!tenant) return
setNamespacesLoading(true)
setNamespacesError(null)
try {
const data = await apiFetch<{ namespaces: TenantNamespace[]; total: number }>(
`/tenants/${tenant.id}/namespaces`
)
setNamespaces(data.namespaces || [])
} catch (err) {
setNamespacesError(err instanceof Error ? err.message : 'Fehler beim Laden')
} finally {
setNamespacesLoading(false)
}
}, [tenant])
useEffect(() => {
if (open && tenant) {
loadNamespaces()
setShowCreateNs(false)
setNsForm({ ...EMPTY_NAMESPACE_FORM })
setNsError(null)
}
}, [open, tenant, loadNamespaces])
const handleNsNameChange = (name: string) => {
setNsForm((prev) => ({
...prev,
name,
slug: slugify(name),
}))
}
const handleCreateNamespace = async (e: React.FormEvent) => {
e.preventDefault()
if (!tenant || nsForm.name.trim().length < 2) return
setNsCreating(true)
setNsError(null)
try {
await apiFetch(`/tenants/${tenant.id}/namespaces`, {
method: 'POST',
body: JSON.stringify({
name: nsForm.name.trim(),
slug: nsForm.slug.trim(),
isolation_level: nsForm.isolation_level,
data_classification: nsForm.data_classification,
}),
})
setNsForm({ ...EMPTY_NAMESPACE_FORM })
setShowCreateNs(false)
loadNamespaces()
} catch (err) {
setNsError(err instanceof Error ? err.message : 'Fehler beim Erstellen')
} finally {
setNsCreating(false)
}
}
if (!tenant) return null
const statusInfo = getStatusBadge(tenant.status)
const scoreColor = getScoreColor(tenant.compliance_score)
return (
<Modal open={open} onClose={onClose} title={`Mandant: ${tenant.name}`} maxWidth="max-w-2xl">
<div className="space-y-6">
{/* Overview Section */}
<div className="flex items-start gap-5">
<ComplianceRing score={tenant.compliance_score} size={80} />
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<h3 className="text-lg font-semibold text-slate-900">{tenant.name}</h3>
<span className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full ${statusInfo.bg} ${statusInfo.text}`}>
{statusInfo.icon}
{statusInfo.label}
</span>
</div>
<p className="text-sm text-slate-400 font-mono mb-2">{tenant.slug}</p>
<div className="flex items-center gap-3 text-xs text-slate-500">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 font-semibold rounded-full ${getRiskBadgeClasses(tenant.risk_level)}`}>
{tenant.risk_level}
</span>
<span>Erstellt: {formatDate(tenant.created_at)}</span>
</div>
</div>
</div>
{/* Compliance Breakdown */}
<div>
<h4 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-indigo-500" />
Compliance-Uebersicht
</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div className="bg-slate-50 rounded-lg p-3">
<div className="flex items-center gap-1.5 text-xs text-slate-500 mb-1">
<AlertTriangle className="w-3.5 h-3.5 text-orange-500" />
Offene Vorfaelle
</div>
<p className="text-lg font-bold text-slate-900">{tenant.open_incidents}</p>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="flex items-center gap-1.5 text-xs text-slate-500 mb-1">
<Shield className="w-3.5 h-3.5 text-indigo-500" />
Hinweisgebermeldungen
</div>
<p className="text-lg font-bold text-slate-900">{tenant.open_reports}</p>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="flex items-center gap-1.5 text-xs text-slate-500 mb-1">
<Users className="w-3.5 h-3.5 text-blue-500" />
Ausstehende DSRs
</div>
<p className="text-lg font-bold text-slate-900">{tenant.pending_dsrs}</p>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="flex items-center gap-1.5 text-xs text-slate-500 mb-1">
<GraduationCap className="w-3.5 h-3.5 text-green-500" />
Schulungsquote
</div>
<p className="text-lg font-bold text-slate-900">{tenant.training_completion_rate}%</p>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="flex items-center gap-1.5 text-xs text-slate-500 mb-1">
<Truck className="w-3.5 h-3.5 text-orange-500" />
Hochrisiko-Dienstleister
</div>
<p className="text-lg font-bold text-slate-900">{tenant.vendor_risk_high}</p>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<div className="flex items-center gap-1.5 text-xs text-slate-500 mb-1">
<Activity className="w-3.5 h-3.5" style={{ color: scoreColor }} />
Compliance-Score
</div>
<p className="text-lg font-bold" style={{ color: scoreColor }}>
{tenant.compliance_score}/100
</p>
</div>
</div>
</div>
{/* Settings */}
<div>
<h4 className="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
<Settings className="w-4 h-4 text-indigo-500" />
Mandanten-Einstellungen
</h4>
<div className="grid grid-cols-2 gap-3">
<div className="bg-slate-50 rounded-lg p-3">
<span className="text-xs text-slate-400">Max. Benutzer</span>
<p className="text-base font-semibold text-slate-800">{tenant.max_users.toLocaleString('de-DE')}</p>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<span className="text-xs text-slate-400">LLM Kontingent / Monat</span>
<p className="text-base font-semibold text-slate-800">{tenant.llm_quota_monthly.toLocaleString('de-DE')}</p>
</div>
</div>
</div>
{/* Namespaces */}
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-slate-700 flex items-center gap-2">
<Globe className="w-4 h-4 text-indigo-500" />
Namespaces ({namespaces.length})
</h4>
<button
onClick={() => setShowCreateNs(!showCreateNs)}
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-indigo-600 bg-indigo-50 hover:bg-indigo-100 rounded-lg transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Neuer Namespace
</button>
</div>
{/* Create Namespace Form */}
{showCreateNs && (
<form onSubmit={handleCreateNamespace} className="mb-4 p-4 bg-indigo-50/50 rounded-lg border border-indigo-100 space-y-3">
{nsError && (
<div className="flex items-center gap-2 p-2 text-xs text-red-700 bg-red-50 rounded-lg border border-red-200">
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
{nsError}
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">Name</label>
<input
type="text"
value={nsForm.name}
onChange={(e) => handleNsNameChange(e.target.value)}
placeholder="z.B. Produktion"
className="w-full px-2.5 py-1.5 border border-slate-300 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
autoFocus
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">Slug</label>
<input
type="text"
value={nsForm.slug}
readOnly
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs bg-slate-50 font-mono"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">Isolationsebene</label>
<select
value={nsForm.isolation_level}
onChange={(e) => setNsForm((prev) => ({ ...prev, isolation_level: e.target.value }))}
className="w-full px-2.5 py-1.5 border border-slate-300 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
>
{ISOLATION_LEVELS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 mb-1">Datenklassifikation</label>
<select
value={nsForm.data_classification}
onChange={(e) => setNsForm((prev) => ({ ...prev, data_classification: e.target.value }))}
className="w-full px-2.5 py-1.5 border border-slate-300 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
>
{DATA_CLASSIFICATIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => { setShowCreateNs(false); setNsError(null) }}
className="px-3 py-1.5 text-xs font-medium text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
type="submit"
disabled={nsForm.name.trim().length < 2 || nsCreating}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed rounded-lg transition-colors"
>
{nsCreating && <Loader2 className="w-3 h-3 animate-spin" />}
Erstellen
</button>
</div>
</form>
)}
{/* Namespaces List */}
{namespacesLoading ? (
<div className="space-y-2 animate-pulse">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-12 bg-slate-100 rounded-lg" />
))}
</div>
) : namespacesError ? (
<div className="flex items-center gap-2 p-3 text-sm text-red-700 bg-red-50 rounded-lg border border-red-200">
<AlertTriangle className="w-4 h-4 shrink-0" />
{namespacesError}
</div>
) : namespaces.length === 0 ? (
<div className="text-center py-6 text-sm text-slate-400">
Noch keine Namespaces vorhanden.
</div>
) : (
<div className="space-y-2">
{namespaces.map((ns) => (
<div
key={ns.id}
className="flex items-center gap-3 px-3 py-2.5 bg-white rounded-lg border border-slate-200 text-sm"
>
<Globe className="w-4 h-4 text-indigo-400 shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium text-slate-800 truncate">{ns.name}</p>
<p className="text-xs text-slate-400 font-mono">{ns.slug}</p>
</div>
<span className="px-2 py-0.5 text-xs font-medium bg-slate-100 text-slate-600 rounded-full">
{ns.isolation_level}
</span>
<span className="px-2 py-0.5 text-xs font-medium bg-blue-50 text-blue-600 rounded-full">
{ns.data_classification}
</span>
</div>
))}
</div>
)}
</div>
{/* Switch Tenant Action */}
<div className="pt-4 border-t border-slate-200">
<button
onClick={() => {
onSwitchTenant(tenant)
onClose()
}}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors"
>
<Globe className="w-4 h-4" />
Zu diesem Mandanten wechseln
</button>
</div>
</div>
</Modal>
)
}

View File

@@ -0,0 +1,58 @@
import type { CreateNamespaceForm, CreateTenantForm, SortField, StatusFilter } from '../_types'
export const FALLBACK_TENANT_ID = '00000000-0000-0000-0000-000000000001'
export const FALLBACK_USER_ID = '00000000-0000-0000-0000-000000000001'
export const API_BASE = '/api/sdk/v1/multi-tenant'
export const STATUS_OPTIONS = [
{ value: 'active', label: 'Aktiv' },
{ value: 'suspended', label: 'Suspendiert' },
{ value: 'inactive', label: 'Inaktiv' },
]
export const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
{ value: 'all', label: 'Alle' },
{ value: 'active', label: 'Aktiv' },
{ value: 'suspended', label: 'Suspendiert' },
{ value: 'inactive', label: 'Inaktiv' },
]
export const SORT_OPTIONS: { value: SortField; label: string }[] = [
{ value: 'name', label: 'Name' },
{ value: 'score', label: 'Score' },
{ value: 'risk', label: 'Risiko' },
]
export const ISOLATION_LEVELS = [
{ value: 'shared', label: 'Shared' },
{ value: 'isolated', label: 'Isoliert' },
{ value: 'dedicated', label: 'Dediziert' },
]
export const DATA_CLASSIFICATIONS = [
{ value: 'public', label: 'Oeffentlich' },
{ value: 'internal', label: 'Intern' },
{ value: 'confidential', label: 'Vertraulich' },
{ value: 'restricted', label: 'Eingeschraenkt' },
]
export const RISK_ORDER: Record<string, number> = {
LOW: 0,
MEDIUM: 1,
HIGH: 2,
CRITICAL: 3,
}
export const EMPTY_CREATE_FORM: CreateTenantForm = {
name: '',
slug: '',
max_users: 100,
llm_quota_monthly: 10000,
}
export const EMPTY_NAMESPACE_FORM: CreateNamespaceForm = {
name: '',
slug: '',
isolation_level: 'shared',
data_classification: 'internal',
}

View File

@@ -0,0 +1,123 @@
import React from 'react'
import { AlertTriangle, CheckCircle2, XCircle } from 'lucide-react'
import { API_BASE, FALLBACK_TENANT_ID, FALLBACK_USER_ID } from './constants'
export function getTenantId(): string {
if (typeof window !== 'undefined') {
return localStorage.getItem('sdk-tenant-id') || FALLBACK_TENANT_ID
}
return FALLBACK_TENANT_ID
}
export function getUserId(): string {
if (typeof window !== 'undefined') {
return localStorage.getItem('sdk-user-id') || FALLBACK_USER_ID
}
return FALLBACK_USER_ID
}
export function formatDate(dateStr: string | null): string {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
export function formatDateTime(dateStr: string | null): string {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
export function getScoreColor(score: number): string {
if (score >= 80) return '#22c55e'
if (score >= 60) return '#eab308'
if (score >= 40) return '#f97316'
return '#ef4444'
}
export function getScoreBgClasses(score: number): string {
if (score >= 80) return 'bg-green-50 text-green-700 border-green-200'
if (score >= 60) return 'bg-amber-50 text-amber-700 border-amber-200'
if (score >= 40) return 'bg-orange-50 text-orange-700 border-orange-200'
return 'bg-red-50 text-red-700 border-red-200'
}
export function getRiskBadgeClasses(level: string): string {
switch (level.toUpperCase()) {
case 'LOW':
return 'bg-green-100 text-green-800'
case 'MEDIUM':
return 'bg-yellow-100 text-yellow-800'
case 'HIGH':
return 'bg-orange-100 text-orange-800'
case 'CRITICAL':
return 'bg-red-100 text-red-800'
default:
return 'bg-gray-100 text-gray-800'
}
}
export function getStatusBadge(status: string): { bg: string; text: string; label: string; icon: React.ReactNode } {
switch (status) {
case 'active':
return {
bg: 'bg-green-100',
text: 'text-green-700',
label: 'Aktiv',
icon: <CheckCircle2 className="w-3 h-3" />,
}
case 'suspended':
return {
bg: 'bg-yellow-100',
text: 'text-yellow-700',
label: 'Suspendiert',
icon: <AlertTriangle className="w-3 h-3" />,
}
case 'inactive':
return {
bg: 'bg-red-100',
text: 'text-red-700',
label: 'Inaktiv',
icon: <XCircle className="w-3 h-3" />,
}
default:
return {
bg: 'bg-gray-100',
text: 'text-gray-700',
label: status,
icon: null,
}
}
}
export async function apiFetch<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': getTenantId(),
'X-User-ID': getUserId(),
...options.headers,
},
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body.error || body.message || `HTTP ${res.status}`)
}
return res.json()
}