From dca0c96f2a49d11da9a4b708ee32efc01fbf4c0b Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Sat, 11 Apr 2026 22:47:59 +0200 Subject: [PATCH] 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) --- .../_components/ComplianceRing.tsx | 42 + .../_components/CreateTenantModal.tsx | 160 +++ .../_components/EditTenantModal.tsx | 155 ++ .../multi-tenant/_components/EmptyState.tsx | 26 + .../_components/LoadingSkeleton.tsx | 42 + .../sdk/multi-tenant/_components/Modal.tsx | 38 + .../sdk/multi-tenant/_components/StatCard.tsx | 51 + .../multi-tenant/_components/TenantCard.tsx | 151 ++ .../_components/TenantDetailModal.tsx | 348 +++++ .../sdk/multi-tenant/_components/constants.ts | 58 + .../sdk/multi-tenant/_components/helpers.tsx | 123 ++ .../app/sdk/multi-tenant/_types.ts | 59 + .../app/sdk/multi-tenant/page.tsx | 1261 +---------------- 13 files changed, 1268 insertions(+), 1246 deletions(-) create mode 100644 admin-compliance/app/sdk/multi-tenant/_components/ComplianceRing.tsx create mode 100644 admin-compliance/app/sdk/multi-tenant/_components/CreateTenantModal.tsx create mode 100644 admin-compliance/app/sdk/multi-tenant/_components/EditTenantModal.tsx create mode 100644 admin-compliance/app/sdk/multi-tenant/_components/EmptyState.tsx create mode 100644 admin-compliance/app/sdk/multi-tenant/_components/LoadingSkeleton.tsx create mode 100644 admin-compliance/app/sdk/multi-tenant/_components/Modal.tsx create mode 100644 admin-compliance/app/sdk/multi-tenant/_components/StatCard.tsx create mode 100644 admin-compliance/app/sdk/multi-tenant/_components/TenantCard.tsx create mode 100644 admin-compliance/app/sdk/multi-tenant/_components/TenantDetailModal.tsx create mode 100644 admin-compliance/app/sdk/multi-tenant/_components/constants.ts create mode 100644 admin-compliance/app/sdk/multi-tenant/_components/helpers.tsx create mode 100644 admin-compliance/app/sdk/multi-tenant/_types.ts diff --git a/admin-compliance/app/sdk/multi-tenant/_components/ComplianceRing.tsx b/admin-compliance/app/sdk/multi-tenant/_components/ComplianceRing.tsx new file mode 100644 index 0000000..db6b22f --- /dev/null +++ b/admin-compliance/app/sdk/multi-tenant/_components/ComplianceRing.tsx @@ -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 ( +
+ + + + +
+ {score} +
+
+ ) +} diff --git a/admin-compliance/app/sdk/multi-tenant/_components/CreateTenantModal.tsx b/admin-compliance/app/sdk/multi-tenant/_components/CreateTenantModal.tsx new file mode 100644 index 0000000..3887d84 --- /dev/null +++ b/admin-compliance/app/sdk/multi-tenant/_components/CreateTenantModal.tsx @@ -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({ ...EMPTY_CREATE_FORM }) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(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 ( + +
+ {error && ( +
+ + {error} +
+ )} + +
+ + 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 + /> +
+ +
+ + 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" + /> +

+ Kleinbuchstaben, keine Leerzeichen. Wird automatisch aus dem Namen generiert. +

+
+ +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+ + +
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/multi-tenant/_components/EditTenantModal.tsx b/admin-compliance/app/sdk/multi-tenant/_components/EditTenantModal.tsx new file mode 100644 index 0000000..9acc806 --- /dev/null +++ b/admin-compliance/app/sdk/multi-tenant/_components/EditTenantModal.tsx @@ -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({ + name: '', + max_users: 100, + llm_quota_monthly: 10000, + status: 'active', + }) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(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 ( + +
+ {error && ( +
+ + {error} +
+ )} + +
+ + 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" + /> +
+ +
+ + +
+ +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ +
+ + +
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/multi-tenant/_components/EmptyState.tsx b/admin-compliance/app/sdk/multi-tenant/_components/EmptyState.tsx new file mode 100644 index 0000000..668efb2 --- /dev/null +++ b/admin-compliance/app/sdk/multi-tenant/_components/EmptyState.tsx @@ -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 ( +
+
+ {icon} +
+

{title}

+

{description}

+ {action &&
{action}
} +
+ ) +} diff --git a/admin-compliance/app/sdk/multi-tenant/_components/LoadingSkeleton.tsx b/admin-compliance/app/sdk/multi-tenant/_components/LoadingSkeleton.tsx new file mode 100644 index 0000000..bf04ac9 --- /dev/null +++ b/admin-compliance/app/sdk/multi-tenant/_components/LoadingSkeleton.tsx @@ -0,0 +1,42 @@ +'use client' + +export function LoadingSkeleton() { + return ( +
+ {/* Header Skeleton */} +
+
+
+
+
+
+
+
+
+
+ {/* Stats Skeleton */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+ ))} +
+ {/* Cards Skeleton */} +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+
+ ) +} diff --git a/admin-compliance/app/sdk/multi-tenant/_components/Modal.tsx b/admin-compliance/app/sdk/multi-tenant/_components/Modal.tsx new file mode 100644 index 0000000..656659f --- /dev/null +++ b/admin-compliance/app/sdk/multi-tenant/_components/Modal.tsx @@ -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 ( +
+
+
+
+

{title}

+ +
+
{children}
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/multi-tenant/_components/StatCard.tsx b/admin-compliance/app/sdk/multi-tenant/_components/StatCard.tsx new file mode 100644 index 0000000..08ef312 --- /dev/null +++ b/admin-compliance/app/sdk/multi-tenant/_components/StatCard.tsx @@ -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 ( +
+
+
+ {icon} +
+ {label} +
+
{value}
+
+ ) +} diff --git a/admin-compliance/app/sdk/multi-tenant/_components/TenantCard.tsx b/admin-compliance/app/sdk/multi-tenant/_components/TenantCard.tsx new file mode 100644 index 0000000..cb61001 --- /dev/null +++ b/admin-compliance/app/sdk/multi-tenant/_components/TenantCard.tsx @@ -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 ( +
+ {/* Card Header */} +
+
+
+
+

{tenant.name}

+ + {statusInfo.icon} + {statusInfo.label} + +
+

{tenant.slug}

+
+ +
+ + {/* Risk Level */} +
+ + + {tenant.risk_level} + + + {tenant.namespace_count} Namespace{tenant.namespace_count !== 1 ? 's' : ''} + + + {formatDate(tenant.created_at)} + +
+ + {/* Quick Metrics */} +
+
+ + {tenant.open_incidents} Vorfaelle +
+
+ + {tenant.open_reports} Meldungen +
+
+ + {tenant.pending_dsrs} DSRs +
+
+ + {tenant.training_completion_rate}% Training +
+
+ + {/* Vendor Risk Info */} + {tenant.vendor_risk_high > 0 && ( +
+ + {tenant.vendor_risk_high} Dienstleister mit hohem Risiko +
+ )} + + {/* Actions */} +
+ + + + +
+
+ + {/* Expanded Section */} + {expanded && ( +
+
+
+ Max. Benutzer +

{tenant.max_users.toLocaleString('de-DE')}

+
+
+ LLM Kontingent / Monat +

{tenant.llm_quota_monthly.toLocaleString('de-DE')}

+
+
+ Compliance Score +

{tenant.compliance_score} / 100

+
+
+ Aktualisiert +

{formatDateTime(tenant.updated_at)}

+
+
+
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/multi-tenant/_components/TenantDetailModal.tsx b/admin-compliance/app/sdk/multi-tenant/_components/TenantDetailModal.tsx new file mode 100644 index 0000000..6fd4f28 --- /dev/null +++ b/admin-compliance/app/sdk/multi-tenant/_components/TenantDetailModal.tsx @@ -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([]) + const [namespacesLoading, setNamespacesLoading] = useState(false) + const [namespacesError, setNamespacesError] = useState(null) + const [showCreateNs, setShowCreateNs] = useState(false) + const [nsForm, setNsForm] = useState({ ...EMPTY_NAMESPACE_FORM }) + const [nsCreating, setNsCreating] = useState(false) + const [nsError, setNsError] = useState(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 ( + +
+ {/* Overview Section */} +
+ +
+
+

{tenant.name}

+ + {statusInfo.icon} + {statusInfo.label} + +
+

{tenant.slug}

+
+ + {tenant.risk_level} + + Erstellt: {formatDate(tenant.created_at)} +
+
+
+ + {/* Compliance Breakdown */} +
+

+ + Compliance-Uebersicht +

+
+
+
+ + Offene Vorfaelle +
+

{tenant.open_incidents}

+
+
+
+ + Hinweisgebermeldungen +
+

{tenant.open_reports}

+
+
+
+ + Ausstehende DSRs +
+

{tenant.pending_dsrs}

+
+
+
+ + Schulungsquote +
+

{tenant.training_completion_rate}%

+
+
+
+ + Hochrisiko-Dienstleister +
+

{tenant.vendor_risk_high}

+
+
+
+ + Compliance-Score +
+

+ {tenant.compliance_score}/100 +

+
+
+
+ + {/* Settings */} +
+

+ + Mandanten-Einstellungen +

+
+
+ Max. Benutzer +

{tenant.max_users.toLocaleString('de-DE')}

+
+
+ LLM Kontingent / Monat +

{tenant.llm_quota_monthly.toLocaleString('de-DE')}

+
+
+
+ + {/* Namespaces */} +
+
+

+ + Namespaces ({namespaces.length}) +

+ +
+ + {/* Create Namespace Form */} + {showCreateNs && ( +
+ {nsError && ( +
+ + {nsError} +
+ )} +
+
+ + 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 + /> +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ )} + + {/* Namespaces List */} + {namespacesLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+ ) : namespacesError ? ( +
+ + {namespacesError} +
+ ) : namespaces.length === 0 ? ( +
+ Noch keine Namespaces vorhanden. +
+ ) : ( +
+ {namespaces.map((ns) => ( +
+ +
+

{ns.name}

+

{ns.slug}

+
+ + {ns.isolation_level} + + + {ns.data_classification} + +
+ ))} +
+ )} +
+ + {/* Switch Tenant Action */} +
+ +
+
+ + ) +} diff --git a/admin-compliance/app/sdk/multi-tenant/_components/constants.ts b/admin-compliance/app/sdk/multi-tenant/_components/constants.ts new file mode 100644 index 0000000..9fa273e --- /dev/null +++ b/admin-compliance/app/sdk/multi-tenant/_components/constants.ts @@ -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 = { + 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', +} diff --git a/admin-compliance/app/sdk/multi-tenant/_components/helpers.tsx b/admin-compliance/app/sdk/multi-tenant/_components/helpers.tsx new file mode 100644 index 0000000..3626f1a --- /dev/null +++ b/admin-compliance/app/sdk/multi-tenant/_components/helpers.tsx @@ -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: , + } + case 'suspended': + return { + bg: 'bg-yellow-100', + text: 'text-yellow-700', + label: 'Suspendiert', + icon: , + } + case 'inactive': + return { + bg: 'bg-red-100', + text: 'text-red-700', + label: 'Inaktiv', + icon: , + } + default: + return { + bg: 'bg-gray-100', + text: 'text-gray-700', + label: status, + icon: null, + } + } +} + +export async function apiFetch(endpoint: string, options: RequestInit = {}): Promise { + 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() +} diff --git a/admin-compliance/app/sdk/multi-tenant/_types.ts b/admin-compliance/app/sdk/multi-tenant/_types.ts new file mode 100644 index 0000000..8782793 --- /dev/null +++ b/admin-compliance/app/sdk/multi-tenant/_types.ts @@ -0,0 +1,59 @@ +export interface TenantOverview { + id: string + name: string + slug: string + status: string + max_users: number + llm_quota_monthly: number + compliance_score: number + risk_level: string + namespace_count: number + open_incidents: number + open_reports: number + pending_dsrs: number + training_completion_rate: number + vendor_risk_high: number + created_at: string + updated_at: string +} + +export interface TenantNamespace { + id: string + tenant_id: string + name: string + slug: string + isolation_level: string + data_classification: string + created_at: string +} + +export interface OverviewResponse { + tenants: TenantOverview[] + total: number + average_score: number + generated_at: string +} + +export interface CreateTenantForm { + name: string + slug: string + max_users: number + llm_quota_monthly: number +} + +export interface EditTenantForm { + name: string + max_users: number + llm_quota_monthly: number + status: string +} + +export interface CreateNamespaceForm { + name: string + slug: string + isolation_level: string + data_classification: string +} + +export type SortField = 'name' | 'score' | 'risk' +export type StatusFilter = 'all' | 'active' | 'suspended' | 'inactive' diff --git a/admin-compliance/app/sdk/multi-tenant/page.tsx b/admin-compliance/app/sdk/multi-tenant/page.tsx index 99a43a1..bfc40fa 100644 --- a/admin-compliance/app/sdk/multi-tenant/page.tsx +++ b/admin-compliance/app/sdk/multi-tenant/page.tsx @@ -1,1246 +1,27 @@ 'use client' -import React, { useState, useEffect, useCallback } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { - Building2, - Plus, - Users, - Shield, AlertTriangle, - Activity, - GraduationCap, - Truck, - ChevronDown, - ChevronUp, - Pencil, - Eye, - RefreshCw, - Loader2, - Globe, - CheckCircle2, - XCircle, BarChart3, + Building2, + CheckCircle2, + Plus, + RefreshCw, Search, X, - Settings, } from 'lucide-react' -// ============================================================================= -// TYPES -// ============================================================================= - -interface TenantOverview { - id: string - name: string - slug: string - status: string - max_users: number - llm_quota_monthly: number - compliance_score: number - risk_level: string - namespace_count: number - open_incidents: number - open_reports: number - pending_dsrs: number - training_completion_rate: number - vendor_risk_high: number - created_at: string - updated_at: string -} - -interface TenantNamespace { - id: string - tenant_id: string - name: string - slug: string - isolation_level: string - data_classification: string - created_at: string -} - -interface OverviewResponse { - tenants: TenantOverview[] - total: number - average_score: number - generated_at: string -} - -interface CreateTenantForm { - name: string - slug: string - max_users: number - llm_quota_monthly: number -} - -interface EditTenantForm { - name: string - max_users: number - llm_quota_monthly: number - status: string -} - -interface CreateNamespaceForm { - name: string - slug: string - isolation_level: string - data_classification: string -} - -type SortField = 'name' | 'score' | 'risk' -type StatusFilter = 'all' | 'active' | 'suspended' | 'inactive' - -// ============================================================================= -// CONSTANTS -// ============================================================================= - -const FALLBACK_TENANT_ID = '00000000-0000-0000-0000-000000000001' -const FALLBACK_USER_ID = '00000000-0000-0000-0000-000000000001' -const API_BASE = '/api/sdk/v1/multi-tenant' - -const STATUS_OPTIONS = [ - { value: 'active', label: 'Aktiv' }, - { value: 'suspended', label: 'Suspendiert' }, - { value: 'inactive', label: 'Inaktiv' }, -] - -const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [ - { value: 'all', label: 'Alle' }, - { value: 'active', label: 'Aktiv' }, - { value: 'suspended', label: 'Suspendiert' }, - { value: 'inactive', label: 'Inaktiv' }, -] - -const SORT_OPTIONS: { value: SortField; label: string }[] = [ - { value: 'name', label: 'Name' }, - { value: 'score', label: 'Score' }, - { value: 'risk', label: 'Risiko' }, -] - -const ISOLATION_LEVELS = [ - { value: 'shared', label: 'Shared' }, - { value: 'isolated', label: 'Isoliert' }, - { value: 'dedicated', label: 'Dediziert' }, -] - -const DATA_CLASSIFICATIONS = [ - { value: 'public', label: 'Oeffentlich' }, - { value: 'internal', label: 'Intern' }, - { value: 'confidential', label: 'Vertraulich' }, - { value: 'restricted', label: 'Eingeschraenkt' }, -] - -const RISK_ORDER: Record = { - LOW: 0, - MEDIUM: 1, - HIGH: 2, - CRITICAL: 3, -} - -const EMPTY_CREATE_FORM: CreateTenantForm = { - name: '', - slug: '', - max_users: 100, - llm_quota_monthly: 10000, -} - -const EMPTY_NAMESPACE_FORM: CreateNamespaceForm = { - name: '', - slug: '', - isolation_level: 'shared', - data_classification: 'internal', -} - -// ============================================================================= -// HELPERS -// ============================================================================= - -function getTenantId(): string { - if (typeof window !== 'undefined') { - return localStorage.getItem('sdk-tenant-id') || FALLBACK_TENANT_ID - } - return FALLBACK_TENANT_ID -} - -function getUserId(): string { - if (typeof window !== 'undefined') { - return localStorage.getItem('sdk-user-id') || FALLBACK_USER_ID - } - return FALLBACK_USER_ID -} - -function formatDate(dateStr: string | null): string { - if (!dateStr) return '-' - return new Date(dateStr).toLocaleDateString('de-DE', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - }) -} - -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', - }) -} - -function slugify(text: string): string { - return text - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') -} - -function getScoreColor(score: number): string { - if (score >= 80) return '#22c55e' - if (score >= 60) return '#eab308' - if (score >= 40) return '#f97316' - return '#ef4444' -} - -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' -} - -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' - } -} - -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: , - } - case 'suspended': - return { - bg: 'bg-yellow-100', - text: 'text-yellow-700', - label: 'Suspendiert', - icon: , - } - case 'inactive': - return { - bg: 'bg-red-100', - text: 'text-red-700', - label: 'Inaktiv', - icon: , - } - default: - return { - bg: 'bg-gray-100', - text: 'text-gray-700', - label: status, - icon: null, - } - } -} - -async function apiFetch(endpoint: string, options: RequestInit = {}): Promise { - 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() -} - -// ============================================================================= -// SUB-COMPONENTS -// ============================================================================= - -function LoadingSkeleton() { - return ( -
- {/* Header Skeleton */} -
-
-
-
-
-
-
-
-
-
- {/* Stats Skeleton */} -
- {Array.from({ length: 4 }).map((_, i) => ( -
-
-
-
- ))} -
- {/* Cards Skeleton */} -
- {Array.from({ length: 6 }).map((_, i) => ( -
-
-
-
-
-
-
-
-
- ))} -
-
- ) -} - -function EmptyState({ - icon, - title, - description, - action, -}: { - icon: React.ReactNode - title: string - description: string - action?: React.ReactNode -}) { - return ( -
-
- {icon} -
-

{title}

-

{description}

- {action &&
{action}
} -
- ) -} - -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 ( -
-
-
- {icon} -
- {label} -
-
{value}
-
- ) -} - -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 ( -
- - - - -
- {score} -
-
- ) -} - -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 ( -
-
-
-
-

{title}

- -
-
{children}
-
-
- ) -} - -// ============================================================================= -// TENANT CARD -// ============================================================================= - -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 ( -
- {/* Card Header */} -
-
-
-
-

{tenant.name}

- - {statusInfo.icon} - {statusInfo.label} - -
-

{tenant.slug}

-
- -
- - {/* Risk Level */} -
- - - {tenant.risk_level} - - - {tenant.namespace_count} Namespace{tenant.namespace_count !== 1 ? 's' : ''} - - - {formatDate(tenant.created_at)} - -
- - {/* Quick Metrics */} -
-
- - {tenant.open_incidents} Vorfaelle -
-
- - {tenant.open_reports} Meldungen -
-
- - {tenant.pending_dsrs} DSRs -
-
- - {tenant.training_completion_rate}% Training -
-
- - {/* Vendor Risk Info */} - {tenant.vendor_risk_high > 0 && ( -
- - {tenant.vendor_risk_high} Dienstleister mit hohem Risiko -
- )} - - {/* Actions */} -
- - - - -
-
- - {/* Expanded Section */} - {expanded && ( -
-
-
- Max. Benutzer -

{tenant.max_users.toLocaleString('de-DE')}

-
-
- LLM Kontingent / Monat -

{tenant.llm_quota_monthly.toLocaleString('de-DE')}

-
-
- Compliance Score -

{tenant.compliance_score} / 100

-
-
- Aktualisiert -

{formatDateTime(tenant.updated_at)}

-
-
-
- )} -
- ) -} - -// ============================================================================= -// CREATE TENANT MODAL -// ============================================================================= - -function CreateTenantModal({ - open, - onClose, - onCreated, -}: { - open: boolean - onClose: () => void - onCreated: () => void -}) { - const [form, setForm] = useState({ ...EMPTY_CREATE_FORM }) - const [saving, setSaving] = useState(false) - const [error, setError] = useState(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 ( - -
- {error && ( -
- - {error} -
- )} - -
- - 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 - /> -
- -
- - 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" - /> -

- Kleinbuchstaben, keine Leerzeichen. Wird automatisch aus dem Namen generiert. -

-
- -
-
- - 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" - /> -
-
- - 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" - /> -
-
- -
- - -
-
-
- ) -} - -// ============================================================================= -// EDIT TENANT MODAL -// ============================================================================= - -function EditTenantModal({ - open, - onClose, - tenant, - onUpdated, -}: { - open: boolean - onClose: () => void - tenant: TenantOverview | null - onUpdated: () => void -}) { - const [form, setForm] = useState({ - name: '', - max_users: 100, - llm_quota_monthly: 10000, - status: 'active', - }) - const [saving, setSaving] = useState(false) - const [error, setError] = useState(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 ( - -
- {error && ( -
- - {error} -
- )} - -
- - 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" - /> -
- -
- - -
- -
-
- - 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" - /> -
-
- - 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" - /> -
-
- -
- - -
-
-
- ) -} - -// ============================================================================= -// TENANT DETAIL MODAL -// ============================================================================= - -function TenantDetailModal({ - open, - onClose, - tenant, - onSwitchTenant, -}: { - open: boolean - onClose: () => void - tenant: TenantOverview | null - onSwitchTenant: (t: TenantOverview) => void -}) { - const [namespaces, setNamespaces] = useState([]) - const [namespacesLoading, setNamespacesLoading] = useState(false) - const [namespacesError, setNamespacesError] = useState(null) - const [showCreateNs, setShowCreateNs] = useState(false) - const [nsForm, setNsForm] = useState({ ...EMPTY_NAMESPACE_FORM }) - const [nsCreating, setNsCreating] = useState(false) - const [nsError, setNsError] = useState(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 ( - -
- {/* Overview Section */} -
- -
-
-

{tenant.name}

- - {statusInfo.icon} - {statusInfo.label} - -
-

{tenant.slug}

-
- - {tenant.risk_level} - - Erstellt: {formatDate(tenant.created_at)} -
-
-
- - {/* Compliance Breakdown */} -
-

- - Compliance-Uebersicht -

-
-
-
- - Offene Vorfaelle -
-

{tenant.open_incidents}

-
-
-
- - Hinweisgebermeldungen -
-

{tenant.open_reports}

-
-
-
- - Ausstehende DSRs -
-

{tenant.pending_dsrs}

-
-
-
- - Schulungsquote -
-

{tenant.training_completion_rate}%

-
-
-
- - Hochrisiko-Dienstleister -
-

{tenant.vendor_risk_high}

-
-
-
- - Compliance-Score -
-

- {tenant.compliance_score}/100 -

-
-
-
- - {/* Settings */} -
-

- - Mandanten-Einstellungen -

-
-
- Max. Benutzer -

{tenant.max_users.toLocaleString('de-DE')}

-
-
- LLM Kontingent / Monat -

{tenant.llm_quota_monthly.toLocaleString('de-DE')}

-
-
-
- - {/* Namespaces */} -
-
-

- - Namespaces ({namespaces.length}) -

- -
- - {/* Create Namespace Form */} - {showCreateNs && ( -
- {nsError && ( -
- - {nsError} -
- )} -
-
- - 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 - /> -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
- )} - - {/* Namespaces List */} - {namespacesLoading ? ( -
- {Array.from({ length: 3 }).map((_, i) => ( -
- ))} -
- ) : namespacesError ? ( -
- - {namespacesError} -
- ) : namespaces.length === 0 ? ( -
- Noch keine Namespaces vorhanden. -
- ) : ( -
- {namespaces.map((ns) => ( -
- -
-

{ns.name}

-

{ns.slug}

-
- - {ns.isolation_level} - - - {ns.data_classification} - -
- ))} -
- )} -
- - {/* Switch Tenant Action */} -
- -
-
- - ) -} - -// ============================================================================= -// MAIN PAGE COMPONENT -// ============================================================================= +import type { OverviewResponse, SortField, StatusFilter, TenantOverview } from './_types' +import { FILTER_OPTIONS, RISK_ORDER, SORT_OPTIONS } from './_components/constants' +import { apiFetch, formatDateTime } from './_components/helpers' +import { LoadingSkeleton } from './_components/LoadingSkeleton' +import { EmptyState } from './_components/EmptyState' +import { StatCard } from './_components/StatCard' +import { TenantCard } from './_components/TenantCard' +import { CreateTenantModal } from './_components/CreateTenantModal' +import { EditTenantModal } from './_components/EditTenantModal' +import { TenantDetailModal } from './_components/TenantDetailModal' export default function MultiTenantPage() { // Data state @@ -1431,9 +212,7 @@ export default function MultiTenantPage() {
)} - {/* ================================================================= */} {/* HEADER */} - {/* ================================================================= */}
@@ -1472,9 +251,7 @@ export default function MultiTenantPage() {

)} - {/* ================================================================= */} {/* STATS OVERVIEW */} - {/* ================================================================= */}
- {/* ================================================================= */} {/* SEARCH & FILTER BAR */} - {/* ================================================================= */}
{/* Search Input */} @@ -1562,9 +337,7 @@ export default function MultiTenantPage() {
- {/* ================================================================= */} {/* REFRESH ERROR BANNER */} - {/* ================================================================= */} {error && tenants.length > 0 && (
@@ -1578,9 +351,7 @@ export default function MultiTenantPage() {
)} - {/* ================================================================= */} {/* TENANT CARDS GRID */} - {/* ================================================================= */} {filteredTenants.length === 0 ? ( tenants.length === 0 ? ( )} - {/* ================================================================= */} {/* MODALS */} - {/* ================================================================= */} setShowCreate(false)}