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>
433 lines
15 KiB
TypeScript
433 lines
15 KiB
TypeScript
'use client'
|
|
|
|
import React, { useCallback, useEffect, useState } from 'react'
|
|
import {
|
|
AlertTriangle,
|
|
BarChart3,
|
|
Building2,
|
|
CheckCircle2,
|
|
Plus,
|
|
RefreshCw,
|
|
Search,
|
|
X,
|
|
} from 'lucide-react'
|
|
|
|
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
|
|
const [tenants, setTenants] = useState<TenantOverview[]>([])
|
|
const [totalTenants, setTotalTenants] = useState(0)
|
|
const [averageScore, setAverageScore] = useState(0)
|
|
const [generatedAt, setGeneratedAt] = useState<string | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// UI state
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
|
const [sortField, setSortField] = useState<SortField>('name')
|
|
const [refreshing, setRefreshing] = useState(false)
|
|
|
|
// Modal state
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
const [editTenant, setEditTenant] = useState<TenantOverview | null>(null)
|
|
const [detailTenant, setDetailTenant] = useState<TenantOverview | null>(null)
|
|
|
|
// Switch tenant notification
|
|
const [switchNotification, setSwitchNotification] = useState<string | null>(null)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DATA LOADING
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const loadOverview = useCallback(async (showRefresh = false) => {
|
|
if (showRefresh) setRefreshing(true)
|
|
else setLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const data = await apiFetch<OverviewResponse>('/overview')
|
|
setTenants(data.tenants || [])
|
|
setTotalTenants(data.total || 0)
|
|
setAverageScore(data.average_score || 0)
|
|
setGeneratedAt(data.generated_at || null)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Fehler beim Laden der Mandanten')
|
|
} finally {
|
|
setLoading(false)
|
|
setRefreshing(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadOverview()
|
|
}, [loadOverview])
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// FILTERING, SEARCHING, SORTING
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const filteredTenants = React.useMemo(() => {
|
|
let result = [...tenants]
|
|
|
|
// Filter by status
|
|
if (statusFilter !== 'all') {
|
|
result = result.filter((t) => t.status === statusFilter)
|
|
}
|
|
|
|
// Filter by search query
|
|
if (searchQuery.trim()) {
|
|
const q = searchQuery.toLowerCase().trim()
|
|
result = result.filter(
|
|
(t) =>
|
|
t.name.toLowerCase().includes(q) ||
|
|
t.slug.toLowerCase().includes(q)
|
|
)
|
|
}
|
|
|
|
// Sort
|
|
result.sort((a, b) => {
|
|
switch (sortField) {
|
|
case 'name':
|
|
return a.name.localeCompare(b.name, 'de-DE')
|
|
case 'score':
|
|
return b.compliance_score - a.compliance_score
|
|
case 'risk':
|
|
return (RISK_ORDER[b.risk_level] || 0) - (RISK_ORDER[a.risk_level] || 0)
|
|
default:
|
|
return 0
|
|
}
|
|
})
|
|
|
|
return result
|
|
}, [tenants, statusFilter, searchQuery, sortField])
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DERIVED STATS
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const activeTenants = React.useMemo(
|
|
() => tenants.filter((t) => t.status === 'active').length,
|
|
[tenants]
|
|
)
|
|
|
|
const criticalRisks = React.useMemo(
|
|
() => tenants.filter((t) => t.risk_level === 'HIGH' || t.risk_level === 'CRITICAL').length,
|
|
[tenants]
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HANDLERS
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const handleRefresh = () => loadOverview(true)
|
|
|
|
const handleSwitchTenant = async (tenant: TenantOverview) => {
|
|
try {
|
|
await apiFetch<{ tenant: { id: string; name: string } }>('/switch', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ tenant_id: tenant.id }),
|
|
})
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('sdk-tenant-id', tenant.id)
|
|
}
|
|
setSwitchNotification(`Mandant gewechselt zu: ${tenant.name}`)
|
|
setTimeout(() => setSwitchNotification(null), 4000)
|
|
} catch (err) {
|
|
// Fallback: just set in localStorage even if API call fails
|
|
if (typeof window !== 'undefined') {
|
|
localStorage.setItem('sdk-tenant-id', tenant.id)
|
|
}
|
|
setSwitchNotification(`Mandant gewechselt zu: ${tenant.name}`)
|
|
setTimeout(() => setSwitchNotification(null), 4000)
|
|
}
|
|
}
|
|
|
|
const handleCreated = () => {
|
|
loadOverview(true)
|
|
}
|
|
|
|
const handleUpdated = () => {
|
|
loadOverview(true)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RENDER: LOADING STATE
|
|
// ---------------------------------------------------------------------------
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
|
<LoadingSkeleton />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RENDER: ERROR STATE
|
|
// ---------------------------------------------------------------------------
|
|
|
|
if (error && tenants.length === 0) {
|
|
return (
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
|
<div className="text-center py-20">
|
|
<div className="w-16 h-16 mx-auto bg-red-50 rounded-full flex items-center justify-center mb-4">
|
|
<AlertTriangle className="w-8 h-8 text-red-500" />
|
|
</div>
|
|
<h2 className="text-xl font-semibold text-slate-900 mb-2">Fehler beim Laden</h2>
|
|
<p className="text-sm text-slate-500 mb-6 max-w-md mx-auto">{error}</p>
|
|
<button
|
|
onClick={() => loadOverview()}
|
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
Erneut versuchen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// RENDER: MAIN PAGE
|
|
// ---------------------------------------------------------------------------
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
|
{/* Switch Notification */}
|
|
{switchNotification && (
|
|
<div className="fixed top-4 right-4 z-50 flex items-center gap-2 px-4 py-3 bg-indigo-600 text-white text-sm font-medium rounded-lg shadow-lg animate-in slide-in-from-top-2">
|
|
<CheckCircle2 className="w-4 h-4" />
|
|
{switchNotification}
|
|
</div>
|
|
)}
|
|
|
|
{/* HEADER */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-indigo-100 rounded-xl flex items-center justify-center">
|
|
<Building2 className="w-5 h-5 text-indigo-600" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-900">Multi-Tenant Verwaltung</h1>
|
|
<p className="text-sm text-slate-500">
|
|
Mandanten verwalten und Compliance-Status ueberwachen
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleRefresh}
|
|
disabled={refreshing}
|
|
className="p-2.5 text-slate-500 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors disabled:opacity-50"
|
|
title="Aktualisieren"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
|
|
</button>
|
|
<button
|
|
onClick={() => setShowCreate(true)}
|
|
className="flex items-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 shadow-sm"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Neuen Mandanten erstellen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Generated At */}
|
|
{generatedAt && (
|
|
<p className="text-xs text-slate-400 mb-6 -mt-4">
|
|
Zuletzt aktualisiert: {formatDateTime(generatedAt)}
|
|
</p>
|
|
)}
|
|
|
|
{/* STATS OVERVIEW */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
|
<StatCard
|
|
label="Gesamt Mandanten"
|
|
value={totalTenants}
|
|
icon={<Building2 className="w-5 h-5" />}
|
|
color="indigo"
|
|
/>
|
|
<StatCard
|
|
label="Aktive Mandanten"
|
|
value={activeTenants}
|
|
icon={<CheckCircle2 className="w-5 h-5" />}
|
|
color="green"
|
|
/>
|
|
<StatCard
|
|
label="Durchschn. Compliance-Score"
|
|
value={`${Math.round(averageScore)}%`}
|
|
icon={<BarChart3 className="w-5 h-5" />}
|
|
color="blue"
|
|
/>
|
|
<StatCard
|
|
label="Kritische Risiken"
|
|
value={criticalRisks}
|
|
icon={<AlertTriangle className="w-5 h-5" />}
|
|
color="red"
|
|
/>
|
|
</div>
|
|
|
|
{/* SEARCH & FILTER BAR */}
|
|
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6">
|
|
<div className="flex flex-col sm:flex-row gap-3">
|
|
{/* Search Input */}
|
|
<div className="relative flex-1">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
|
<input
|
|
type="text"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="Mandant suchen (Name oder Slug)..."
|
|
className="w-full pl-9 pr-9 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
onClick={() => setSearchQuery('')}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 p-0.5 text-slate-400 hover:text-slate-600"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Status Filter */}
|
|
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-1">
|
|
{FILTER_OPTIONS.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => setStatusFilter(opt.value)}
|
|
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${
|
|
statusFilter === opt.value
|
|
? 'bg-white text-indigo-600 shadow-sm'
|
|
: 'text-slate-500 hover:text-slate-700'
|
|
}`}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Sort */}
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-slate-400 whitespace-nowrap">Sortierung:</span>
|
|
<select
|
|
value={sortField}
|
|
onChange={(e) => setSortField(e.target.value as SortField)}
|
|
className="px-3 py-2 border border-slate-300 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
|
|
>
|
|
{SORT_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* REFRESH ERROR BANNER */}
|
|
{error && tenants.length > 0 && (
|
|
<div className="flex items-center gap-2 p-3 mb-6 text-sm text-amber-700 bg-amber-50 rounded-lg border border-amber-200">
|
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
|
<span>Fehler beim Aktualisieren: {error}</span>
|
|
<button
|
|
onClick={handleRefresh}
|
|
className="ml-auto text-xs font-medium text-amber-700 hover:text-amber-800 underline"
|
|
>
|
|
Erneut versuchen
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* TENANT CARDS GRID */}
|
|
{filteredTenants.length === 0 ? (
|
|
tenants.length === 0 ? (
|
|
<EmptyState
|
|
icon={<Building2 className="w-8 h-8 text-indigo-400" />}
|
|
title="Keine Mandanten vorhanden"
|
|
description="Erstellen Sie Ihren ersten Mandanten, um die Multi-Tenant-Verwaltung zu nutzen."
|
|
action={
|
|
<button
|
|
onClick={() => setShowCreate(true)}
|
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Neuen Mandanten erstellen
|
|
</button>
|
|
}
|
|
/>
|
|
) : (
|
|
<EmptyState
|
|
icon={<Search className="w-8 h-8 text-slate-400" />}
|
|
title="Keine Ergebnisse"
|
|
description={`Kein Mandant gefunden fuer "${searchQuery}"${statusFilter !== 'all' ? ` mit Status "${FILTER_OPTIONS.find((f) => f.value === statusFilter)?.label}"` : ''}.`}
|
|
action={
|
|
<button
|
|
onClick={() => {
|
|
setSearchQuery('')
|
|
setStatusFilter('all')
|
|
}}
|
|
className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 hover:bg-slate-200 rounded-lg transition-colors"
|
|
>
|
|
Filter zuruecksetzen
|
|
</button>
|
|
}
|
|
/>
|
|
)
|
|
) : (
|
|
<>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<p className="text-sm text-slate-500">
|
|
{filteredTenants.length} von {tenants.length} Mandant{tenants.length !== 1 ? 'en' : ''}
|
|
</p>
|
|
</div>
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
{filteredTenants.map((tenant) => (
|
|
<TenantCard
|
|
key={tenant.id}
|
|
tenant={tenant}
|
|
onEdit={setEditTenant}
|
|
onViewDetails={setDetailTenant}
|
|
onSwitchTenant={handleSwitchTenant}
|
|
/>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* MODALS */}
|
|
<CreateTenantModal
|
|
open={showCreate}
|
|
onClose={() => setShowCreate(false)}
|
|
onCreated={handleCreated}
|
|
/>
|
|
|
|
<EditTenantModal
|
|
open={editTenant !== null}
|
|
onClose={() => setEditTenant(null)}
|
|
tenant={editTenant}
|
|
onUpdated={handleUpdated}
|
|
/>
|
|
|
|
<TenantDetailModal
|
|
open={detailTenant !== null}
|
|
onClose={() => setDetailTenant(null)}
|
|
tenant={detailTenant}
|
|
onSwitchTenant={handleSwitchTenant}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|