refactor: Admin-Layout komplett entfernt — SDK als einziges Layout
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 19s
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 32s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 19s
Kaputtes (admin) Layout geloescht (Role-Selection, 404-Sidebar, localhost-Dashboard). SDK-Flow nach /sdk/sdk-flow verschoben. Route-Gruppe (sdk) aufgeloest. Root-Seite redirected auf /sdk. ~25 ungenutzte Dateien/Verzeichnisse entfernt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,191 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
export interface UploadedFile {
|
||||
id: string
|
||||
sessionId: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
uploadedAt: string
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
interface QRCodeUploadProps {
|
||||
sessionId?: string
|
||||
onClose?: () => void
|
||||
onFileUploaded?: (file: UploadedFile) => void
|
||||
onFilesChanged?: (files: UploadedFile[]) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function QRCodeUpload({
|
||||
sessionId,
|
||||
onClose,
|
||||
onFileUploaded,
|
||||
onFilesChanged,
|
||||
className = ''
|
||||
}: QRCodeUploadProps) {
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null)
|
||||
const [uploadUrl, setUploadUrl] = useState<string>('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
const [isPolling, setIsPolling] = useState(false)
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const fetchUploads = useCallback(async () => {
|
||||
if (!sessionId) return
|
||||
try {
|
||||
const response = await fetch(`/api/uploads?sessionId=${sessionId}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const newFiles = data.uploads || []
|
||||
if (newFiles.length > uploadedFiles.length) {
|
||||
const newlyAdded = newFiles.slice(uploadedFiles.length)
|
||||
newlyAdded.forEach((file: UploadedFile) => {
|
||||
if (onFileUploaded) onFileUploaded(file)
|
||||
})
|
||||
}
|
||||
setUploadedFiles(newFiles)
|
||||
if (onFilesChanged) onFilesChanged(newFiles)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch uploads:', error)
|
||||
}
|
||||
}, [sessionId, uploadedFiles.length, onFileUploaded, onFilesChanged])
|
||||
|
||||
useEffect(() => {
|
||||
let baseUrl = typeof window !== 'undefined' ? window.location.origin : ''
|
||||
const hostnameToIP: Record<string, string> = {
|
||||
'macmini': '192.168.178.100',
|
||||
'macmini.local': '192.168.178.100',
|
||||
}
|
||||
Object.entries(hostnameToIP).forEach(([hostname, ip]) => {
|
||||
if (baseUrl.includes(hostname)) baseUrl = baseUrl.replace(hostname, ip)
|
||||
})
|
||||
const uploadPath = `/upload/${sessionId || 'new'}`
|
||||
const fullUrl = `${baseUrl}${uploadPath}`
|
||||
setUploadUrl(fullUrl)
|
||||
const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(fullUrl)}`
|
||||
setQrCodeUrl(qrApiUrl)
|
||||
setIsLoading(false)
|
||||
fetchUploads()
|
||||
setIsPolling(true)
|
||||
const pollInterval = setInterval(() => fetchUploads(), 3000)
|
||||
return () => { clearInterval(pollInterval); setIsPolling(false) }
|
||||
}, [sessionId])
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(uploadUrl)
|
||||
alert('Link kopiert!')
|
||||
} catch (err) {
|
||||
console.error('Kopieren fehlgeschlagen:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteUpload = async (id: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/uploads?id=${id}`, { method: 'DELETE' })
|
||||
if (response.ok) {
|
||||
const newFiles = uploadedFiles.filter(f => f.id !== id)
|
||||
setUploadedFiles(newFiles)
|
||||
if (onFilesChanged) onFilesChanged(newFiles)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete upload:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="rounded-3xl border bg-white border-slate-200 shadow-lg p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-purple-100">
|
||||
<span className="text-xl">📱</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Mit Mobiltelefon hochladen</h3>
|
||||
<p className="text-sm text-slate-500">QR-Code scannen oder Link teilen</p>
|
||||
</div>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button onClick={onClose} className="p-2 rounded-lg transition-colors hover:bg-slate-100 text-slate-400">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="p-4 rounded-2xl bg-slate-50">
|
||||
{isLoading ? (
|
||||
<div className="w-[200px] h-[200px] flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : qrCodeUrl ? (
|
||||
<img src={qrCodeUrl} alt="QR Code zum Hochladen" className="w-[200px] h-[200px]" />
|
||||
) : (
|
||||
<div className="w-[200px] h-[200px] flex items-center justify-center text-slate-400">
|
||||
QR-Code nicht verfuegbar
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-4 text-center text-sm text-slate-600">
|
||||
Scannen Sie diesen Code mit Ihrem Handy,<br />um Dokumente direkt hochzuladen.
|
||||
</p>
|
||||
{isPolling && (
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-slate-400">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
Warte auf Uploads...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="mt-6 p-4 rounded-xl bg-green-50 border border-green-200">
|
||||
<p className="text-sm font-medium text-green-700 mb-3">
|
||||
{uploadedFiles.length} Datei{uploadedFiles.length !== 1 ? 'en' : ''} empfangen
|
||||
</p>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{uploadedFiles.map((file) => (
|
||||
<div key={file.id} className="flex items-center gap-3 p-2 rounded-lg bg-white">
|
||||
<span className="text-lg">{file.type.startsWith('image/') ? '🖼️' : '📄'}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate text-slate-900">{file.name}</p>
|
||||
<p className="text-xs text-slate-500">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
<button onClick={() => deleteUpload(file.id)} className="p-1 rounded transition-colors hover:bg-red-100 text-red-500">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<p className="text-xs mb-2 text-slate-400">Oder Link teilen:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="text" value={uploadUrl} readOnly className="flex-1 px-3 py-2 rounded-xl text-sm border bg-slate-50 border-slate-200 text-slate-700" />
|
||||
<button onClick={copyToClipboard} className="px-4 py-2 rounded-xl text-sm font-medium transition-colors bg-slate-200 text-slate-700 hover:bg-slate-300">
|
||||
Kopieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ArchitectureView - Shows backend modules and their connection status
|
||||
*
|
||||
* This component helps track which backend modules are connected to the frontend
|
||||
* during migration and ensures no modules get lost.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
MODULE_REGISTRY,
|
||||
getModulesByCategory,
|
||||
getModuleStats,
|
||||
getCategoryStats,
|
||||
type BackendModule
|
||||
} from '@/lib/module-registry'
|
||||
|
||||
interface ArchitectureViewProps {
|
||||
category?: BackendModule['category']
|
||||
showAllCategories?: boolean
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
connected: {
|
||||
label: 'Verbunden',
|
||||
color: 'bg-green-100 text-green-700 border-green-200',
|
||||
dot: 'bg-green-500'
|
||||
},
|
||||
partial: {
|
||||
label: 'Teilweise',
|
||||
color: 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||
dot: 'bg-yellow-500'
|
||||
},
|
||||
'not-connected': {
|
||||
label: 'Nicht verbunden',
|
||||
color: 'bg-red-100 text-red-700 border-red-200',
|
||||
dot: 'bg-red-500'
|
||||
},
|
||||
deprecated: {
|
||||
label: 'Veraltet',
|
||||
color: 'bg-slate-100 text-slate-700 border-slate-200',
|
||||
dot: 'bg-slate-500'
|
||||
}
|
||||
}
|
||||
|
||||
const PRIORITY_CONFIG = {
|
||||
critical: { label: 'Kritisch', color: 'text-red-600' },
|
||||
high: { label: 'Hoch', color: 'text-orange-600' },
|
||||
medium: { label: 'Mittel', color: 'text-yellow-600' },
|
||||
low: { label: 'Niedrig', color: 'text-slate-600' }
|
||||
}
|
||||
|
||||
const CATEGORY_CONFIG: Record<BackendModule['category'], { name: string; icon: string; color: string }> = {
|
||||
compliance: { name: 'DSGVO & Compliance', icon: 'shield', color: 'purple' },
|
||||
ai: { name: 'KI & Automatisierung', icon: 'brain', color: 'teal' },
|
||||
infrastructure: { name: 'Infrastruktur & DevOps', icon: 'server', color: 'orange' },
|
||||
communication: { name: 'Kommunikation & Alerts', icon: 'mail', color: 'green' },
|
||||
development: { name: 'Entwicklung & Produkte', icon: 'code', color: 'slate' }
|
||||
}
|
||||
|
||||
export function ArchitectureView({ category, showAllCategories = false }: ArchitectureViewProps) {
|
||||
const [expandedModule, setExpandedModule] = useState<string | null>(null)
|
||||
const [filterStatus, setFilterStatus] = useState<string>('all')
|
||||
|
||||
const modules = category && !showAllCategories
|
||||
? getModulesByCategory(category)
|
||||
: MODULE_REGISTRY
|
||||
|
||||
const filteredModules = filterStatus === 'all'
|
||||
? modules
|
||||
: modules.filter(m => m.frontend.status === filterStatus)
|
||||
|
||||
const stats = category && !showAllCategories
|
||||
? getCategoryStats(category)
|
||||
: getModuleStats()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats Overview */}
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900">
|
||||
Migrations-Fortschritt
|
||||
{category && !showAllCategories && (
|
||||
<span className="ml-2 text-slate-500 font-normal">
|
||||
- {CATEGORY_CONFIG[category].name}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<span className="text-2xl font-bold text-purple-600">
|
||||
{stats.percentComplete}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="h-4 bg-slate-200 rounded-full overflow-hidden mb-4">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-green-500 to-emerald-500 transition-all duration-500"
|
||||
style={{ width: `${stats.percentComplete}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Counts */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.connected}</div>
|
||||
<div className="text-sm text-slate-500">Verbunden</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-yellow-600">{stats.partial}</div>
|
||||
<div className="text-sm text-slate-500">Teilweise</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-red-600">{stats.notConnected}</div>
|
||||
<div className="text-sm text-slate-500">Nicht verbunden</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-slate-600">{stats.total}</div>
|
||||
<div className="text-sm text-slate-500">Gesamt</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-500">Filter:</span>
|
||||
<button
|
||||
onClick={() => setFilterStatus('all')}
|
||||
className={`px-3 py-1 rounded-full text-sm ${
|
||||
filterStatus === 'all' ? 'bg-purple-100 text-purple-700' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Alle ({modules.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterStatus('connected')}
|
||||
className={`px-3 py-1 rounded-full text-sm ${
|
||||
filterStatus === 'connected' ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Verbunden
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterStatus('partial')}
|
||||
className={`px-3 py-1 rounded-full text-sm ${
|
||||
filterStatus === 'partial' ? 'bg-yellow-100 text-yellow-700' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Teilweise
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilterStatus('not-connected')}
|
||||
className={`px-3 py-1 rounded-full text-sm ${
|
||||
filterStatus === 'not-connected' ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Nicht verbunden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Module List */}
|
||||
<div className="space-y-3">
|
||||
{filteredModules.map((module) => (
|
||||
<div
|
||||
key={module.id}
|
||||
className="bg-white rounded-xl shadow-sm border overflow-hidden"
|
||||
>
|
||||
{/* Module Header */}
|
||||
<button
|
||||
onClick={() => setExpandedModule(expandedModule === module.id ? null : module.id)}
|
||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-2 h-2 rounded-full ${STATUS_CONFIG[module.frontend.status].dot}`} />
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-slate-900">{module.name}</div>
|
||||
<div className="text-sm text-slate-500">{module.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2 py-1 rounded text-xs border ${STATUS_CONFIG[module.frontend.status].color}`}>
|
||||
{STATUS_CONFIG[module.frontend.status].label}
|
||||
</span>
|
||||
<span className={`text-xs ${PRIORITY_CONFIG[module.priority].color}`}>
|
||||
{PRIORITY_CONFIG[module.priority].label}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-5 h-5 text-slate-400 transition-transform ${expandedModule === module.id ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Module Details */}
|
||||
{expandedModule === module.id && (
|
||||
<div className="px-4 py-4 border-t border-slate-200 bg-slate-50">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Backend Info */}
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-900 mb-2">Backend</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-500">Service:</span>
|
||||
<code className="px-2 py-0.5 bg-slate-200 rounded text-slate-700">
|
||||
{module.backend.service}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-500">Port:</span>
|
||||
<code className="px-2 py-0.5 bg-slate-200 rounded text-slate-700">
|
||||
{module.backend.port}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-500">Base Path:</span>
|
||||
<code className="px-2 py-0.5 bg-slate-200 rounded text-slate-700">
|
||||
{module.backend.basePath}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h5 className="font-medium text-slate-700 mt-4 mb-2">Endpoints</h5>
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto">
|
||||
{module.backend.endpoints.map((ep, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-sm">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-mono ${
|
||||
ep.method === 'GET' ? 'bg-blue-100 text-blue-700' :
|
||||
ep.method === 'POST' ? 'bg-green-100 text-green-700' :
|
||||
ep.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{ep.method}
|
||||
</span>
|
||||
<code className="text-slate-600 text-xs">{ep.path}</code>
|
||||
<span className="text-slate-400 text-xs">- {ep.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Frontend Info */}
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-900 mb-2">Frontend</h4>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<span className="text-slate-500 block mb-1">Admin v2 Seite:</span>
|
||||
{module.frontend.adminV2Page ? (
|
||||
<Link
|
||||
href={module.frontend.adminV2Page}
|
||||
className="text-purple-600 hover:text-purple-800 hover:underline"
|
||||
>
|
||||
{module.frontend.adminV2Page}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-red-500 italic">Noch nicht angelegt</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500 block mb-1">Altes Admin (Referenz):</span>
|
||||
{module.frontend.oldAdminPage ? (
|
||||
<code className="px-2 py-0.5 bg-slate-200 rounded text-slate-700">
|
||||
{module.frontend.oldAdminPage}
|
||||
</code>
|
||||
) : (
|
||||
<span className="text-slate-400 italic">-</span>
|
||||
)}
|
||||
</div>
|
||||
{module.notes && (
|
||||
<div className="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<span className="text-yellow-700 text-sm">{module.notes}</span>
|
||||
</div>
|
||||
)}
|
||||
{module.dependencies && module.dependencies.length > 0 && (
|
||||
<div>
|
||||
<span className="text-slate-500 block mb-1">Abhaengigkeiten:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{module.dependencies.map((dep) => (
|
||||
<span key={dep} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
|
||||
{dep}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Category Summary (if showing all) */}
|
||||
{showAllCategories && (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6 mt-8">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Kategorie-Uebersicht</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{(Object.keys(CATEGORY_CONFIG) as BackendModule['category'][]).map((cat) => {
|
||||
const catStats = getCategoryStats(cat)
|
||||
return (
|
||||
<div key={cat} className="p-4 border border-slate-200 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium text-slate-900">{CATEGORY_CONFIG[cat].name}</span>
|
||||
<span className={`text-sm ${catStats.percentComplete === 100 ? 'text-green-600' : 'text-slate-500'}`}>
|
||||
{catStats.percentComplete}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-500 ${
|
||||
catStats.percentComplete === 100 ? 'bg-green-500' :
|
||||
catStats.percentComplete > 50 ? 'bg-yellow-500' : 'bg-red-500'
|
||||
}`}
|
||||
style={{ width: `${catStats.percentComplete}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-xs text-slate-500">
|
||||
<span>{catStats.connected}/{catStats.total} verbunden</span>
|
||||
{catStats.notConnected > 0 && (
|
||||
<span className="text-red-500">{catStats.notConnected} offen</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { navigation, metaModules, getModuleByHref, getCategoryById, CategoryId } from '@/lib/navigation'
|
||||
|
||||
export function Breadcrumbs() {
|
||||
const pathname = usePathname()
|
||||
|
||||
// Build breadcrumb items from path
|
||||
const items: Array<{ label: string; href: string }> = []
|
||||
|
||||
// Always start with Dashboard (Home)
|
||||
items.push({ label: 'Dashboard', href: '/dashboard' })
|
||||
|
||||
// Parse the path
|
||||
const pathParts = pathname.split('/').filter(Boolean)
|
||||
|
||||
if (pathParts.length > 0) {
|
||||
// Check if it's a category
|
||||
const categoryId = pathParts[0] as CategoryId
|
||||
const category = getCategoryById(categoryId)
|
||||
|
||||
if (category) {
|
||||
// Add category
|
||||
items.push({ label: category.name, href: `/${category.id}` })
|
||||
|
||||
// Check if there's a module
|
||||
if (pathParts.length > 1) {
|
||||
const moduleHref = `/${pathParts[0]}/${pathParts[1]}`
|
||||
const result = getModuleByHref(moduleHref)
|
||||
if (result) {
|
||||
items.push({ label: result.module.name, href: moduleHref })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check meta modules (but skip dashboard as it's already added)
|
||||
const metaModule = metaModules.find(m => m.href === `/${pathParts[0]}`)
|
||||
if (metaModule && metaModule.href !== '/dashboard') {
|
||||
items.push({ label: metaModule.name, href: metaModule.href })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Don't show breadcrumbs for just dashboard
|
||||
if (items.length <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-2 text-sm text-slate-500 mb-4">
|
||||
{items.map((item, index) => (
|
||||
<span key={`${index}-${item.href}`} className="flex items-center gap-2">
|
||||
{index > 0 && (
|
||||
<svg className="w-4 h-4 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
)}
|
||||
{index === items.length - 1 ? (
|
||||
<span className="text-slate-900 font-medium">{item.label}</span>
|
||||
) : (
|
||||
<Link
|
||||
href={item.href}
|
||||
className="hover:text-primary-600 transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,510 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* DataFlowDiagram - Visual representation of module dependencies
|
||||
*
|
||||
* Shows how backend services, modules, and frontend pages are connected.
|
||||
* Uses SVG for rendering connections.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import {
|
||||
MODULE_REGISTRY,
|
||||
type BackendModule
|
||||
} from '@/lib/module-registry'
|
||||
|
||||
interface NodePosition {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
interface ServiceGroup {
|
||||
name: string
|
||||
port: number
|
||||
modules: BackendModule[]
|
||||
}
|
||||
|
||||
const SERVICE_COLORS: Record<string, string> = {
|
||||
'consent-service': '#8b5cf6', // purple
|
||||
'python-backend': '#f59e0b', // amber
|
||||
'klausur-service': '#10b981', // emerald
|
||||
'voice-service': '#3b82f6', // blue
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
connected: '#22c55e',
|
||||
partial: '#eab308',
|
||||
'not-connected': '#ef4444',
|
||||
deprecated: '#6b7280'
|
||||
}
|
||||
|
||||
export function DataFlowDiagram() {
|
||||
const [selectedModule, setSelectedModule] = useState<string | null>(null)
|
||||
const [hoveredModule, setHoveredModule] = useState<string | null>(null)
|
||||
const [showLegend, setShowLegend] = useState(true)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Group modules by backend service
|
||||
const serviceGroups: ServiceGroup[] = []
|
||||
const seenServices = new Set<string>()
|
||||
|
||||
MODULE_REGISTRY.forEach(module => {
|
||||
if (!seenServices.has(module.backend.service)) {
|
||||
seenServices.add(module.backend.service)
|
||||
serviceGroups.push({
|
||||
name: module.backend.service,
|
||||
port: module.backend.port,
|
||||
modules: MODULE_REGISTRY.filter(m => m.backend.service === module.backend.service)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Calculate positions
|
||||
const serviceWidth = 280
|
||||
const serviceSpacing = 40
|
||||
const moduleHeight = 60
|
||||
const moduleSpacing = 10
|
||||
const headerHeight = 50
|
||||
const padding = 20
|
||||
|
||||
const totalWidth = serviceGroups.length * serviceWidth + (serviceGroups.length - 1) * serviceSpacing + padding * 2
|
||||
const maxModulesInService = Math.max(...serviceGroups.map(s => s.modules.length))
|
||||
const totalHeight = headerHeight + maxModulesInService * (moduleHeight + moduleSpacing) + padding * 2 + 100
|
||||
|
||||
// Get connections between modules (dependencies)
|
||||
const connections: { from: string; to: string }[] = []
|
||||
MODULE_REGISTRY.forEach(module => {
|
||||
if (module.dependencies) {
|
||||
module.dependencies.forEach(dep => {
|
||||
connections.push({ from: module.id, to: dep })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Get module position
|
||||
const getModulePosition = (moduleId: string): NodePosition | null => {
|
||||
let serviceIndex = 0
|
||||
for (const service of serviceGroups) {
|
||||
const moduleIndex = service.modules.findIndex(m => m.id === moduleId)
|
||||
if (moduleIndex !== -1) {
|
||||
return {
|
||||
x: padding + serviceIndex * (serviceWidth + serviceSpacing) + serviceWidth / 2,
|
||||
y: headerHeight + moduleIndex * (moduleHeight + moduleSpacing) + moduleHeight / 2 + 40,
|
||||
width: serviceWidth - 40,
|
||||
height: moduleHeight
|
||||
}
|
||||
}
|
||||
serviceIndex++
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if module is related to selected/hovered module
|
||||
const isRelated = (moduleId: string): boolean => {
|
||||
const target = selectedModule || hoveredModule
|
||||
if (!target) return false
|
||||
|
||||
// Direct match
|
||||
if (moduleId === target) return true
|
||||
|
||||
// Check dependencies
|
||||
const targetModule = MODULE_REGISTRY.find(m => m.id === target)
|
||||
if (targetModule?.dependencies?.includes(moduleId)) return true
|
||||
|
||||
// Check reverse dependencies
|
||||
const module = MODULE_REGISTRY.find(m => m.id === moduleId)
|
||||
if (module?.dependencies?.includes(target)) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Datenfluss-Diagramm</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => setShowLegend(!showLegend)}
|
||||
className="text-sm text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
{showLegend ? 'Legende ausblenden' : 'Legende anzeigen'}
|
||||
</button>
|
||||
{selectedModule && (
|
||||
<button
|
||||
onClick={() => setSelectedModule(null)}
|
||||
className="px-3 py-1 bg-purple-100 text-purple-700 rounded-full text-sm"
|
||||
>
|
||||
Auswahl aufheben
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
{showLegend && (
|
||||
<div className="bg-white rounded-xl shadow-sm border p-4 flex flex-wrap items-center gap-6">
|
||||
<span className="text-sm font-medium text-slate-700">Services:</span>
|
||||
{Object.entries(SERVICE_COLORS).map(([service, color]) => (
|
||||
<div key={service} className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: color }} />
|
||||
<span className="text-sm text-slate-600">{service}</span>
|
||||
</div>
|
||||
))}
|
||||
<span className="text-sm font-medium text-slate-700 ml-4">Status:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||
<span className="text-sm text-slate-600">Verbunden</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500" />
|
||||
<span className="text-sm text-slate-600">Teilweise</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
||||
<span className="text-sm text-slate-600">Nicht verbunden</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diagram */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="bg-white rounded-xl shadow-sm border overflow-x-auto"
|
||||
>
|
||||
<svg
|
||||
width={totalWidth}
|
||||
height={totalHeight}
|
||||
className="min-w-full"
|
||||
>
|
||||
<defs>
|
||||
{/* Arrow marker */}
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="9"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#a78bfa" />
|
||||
</marker>
|
||||
<marker
|
||||
id="arrowhead-highlight"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="9"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#8b5cf6" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Background Grid */}
|
||||
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
|
||||
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#f1f5f9" strokeWidth="1"/>
|
||||
</pattern>
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
|
||||
{/* Service Groups */}
|
||||
{serviceGroups.map((service, serviceIdx) => {
|
||||
const x = padding + serviceIdx * (serviceWidth + serviceSpacing)
|
||||
const serviceColor = SERVICE_COLORS[service.name] || '#6b7280'
|
||||
|
||||
return (
|
||||
<g key={service.name}>
|
||||
{/* Service Container */}
|
||||
<rect
|
||||
x={x}
|
||||
y={padding}
|
||||
width={serviceWidth}
|
||||
height={totalHeight - padding * 2}
|
||||
fill={`${serviceColor}10`}
|
||||
stroke={serviceColor}
|
||||
strokeWidth="2"
|
||||
rx="12"
|
||||
/>
|
||||
|
||||
{/* Service Header */}
|
||||
<rect
|
||||
x={x}
|
||||
y={padding}
|
||||
width={serviceWidth}
|
||||
height={headerHeight}
|
||||
fill={serviceColor}
|
||||
rx="12"
|
||||
/>
|
||||
<rect
|
||||
x={x}
|
||||
y={padding + headerHeight - 12}
|
||||
width={serviceWidth}
|
||||
height={12}
|
||||
fill={serviceColor}
|
||||
/>
|
||||
<text
|
||||
x={x + serviceWidth / 2}
|
||||
y={padding + headerHeight / 2 + 5}
|
||||
textAnchor="middle"
|
||||
fill="white"
|
||||
fontSize="14"
|
||||
fontWeight="600"
|
||||
>
|
||||
{service.name}
|
||||
</text>
|
||||
<text
|
||||
x={x + serviceWidth / 2}
|
||||
y={padding + headerHeight - 8}
|
||||
textAnchor="middle"
|
||||
fill="rgba(255,255,255,0.7)"
|
||||
fontSize="11"
|
||||
>
|
||||
Port {service.port}
|
||||
</text>
|
||||
|
||||
{/* Modules */}
|
||||
{service.modules.map((module, moduleIdx) => {
|
||||
const moduleX = x + 20
|
||||
const moduleY = padding + headerHeight + 20 + moduleIdx * (moduleHeight + moduleSpacing)
|
||||
const isSelected = selectedModule === module.id
|
||||
const isHovered = hoveredModule === module.id
|
||||
const related = isRelated(module.id)
|
||||
const statusColor = STATUS_COLORS[module.frontend.status]
|
||||
|
||||
const opacity = (selectedModule || hoveredModule)
|
||||
? (related ? 1 : 0.3)
|
||||
: 1
|
||||
|
||||
return (
|
||||
<g
|
||||
key={module.id}
|
||||
onClick={() => setSelectedModule(isSelected ? null : module.id)}
|
||||
onMouseEnter={() => setHoveredModule(module.id)}
|
||||
onMouseLeave={() => setHoveredModule(null)}
|
||||
style={{ cursor: 'pointer', opacity }}
|
||||
className="transition-opacity duration-200"
|
||||
>
|
||||
{/* Module Box */}
|
||||
<rect
|
||||
x={moduleX}
|
||||
y={moduleY}
|
||||
width={serviceWidth - 40}
|
||||
height={moduleHeight}
|
||||
fill="white"
|
||||
stroke={isSelected || isHovered ? serviceColor : '#e2e8f0'}
|
||||
strokeWidth={isSelected || isHovered ? 2 : 1}
|
||||
rx="8"
|
||||
filter={isSelected ? 'drop-shadow(0 4px 6px rgba(0,0,0,0.1))' : undefined}
|
||||
/>
|
||||
|
||||
{/* Status Indicator */}
|
||||
<circle
|
||||
cx={moduleX + 16}
|
||||
cy={moduleY + moduleHeight / 2}
|
||||
r="5"
|
||||
fill={statusColor}
|
||||
/>
|
||||
|
||||
{/* Module Name */}
|
||||
<text
|
||||
x={moduleX + 30}
|
||||
y={moduleY + 24}
|
||||
fill="#1e293b"
|
||||
fontSize="12"
|
||||
fontWeight="500"
|
||||
>
|
||||
{module.name.length > 25 ? module.name.slice(0, 25) + '...' : module.name}
|
||||
</text>
|
||||
|
||||
{/* Module ID */}
|
||||
<text
|
||||
x={moduleX + 30}
|
||||
y={moduleY + 42}
|
||||
fill="#94a3b8"
|
||||
fontSize="10"
|
||||
>
|
||||
{module.id}
|
||||
</text>
|
||||
|
||||
{/* Priority Badge */}
|
||||
<rect
|
||||
x={moduleX + serviceWidth - 90}
|
||||
y={moduleY + 20}
|
||||
width={40}
|
||||
height={18}
|
||||
fill={
|
||||
module.priority === 'critical' ? '#fef2f2' :
|
||||
module.priority === 'high' ? '#fff7ed' :
|
||||
module.priority === 'medium' ? '#fefce8' :
|
||||
'#f1f5f9'
|
||||
}
|
||||
rx="4"
|
||||
/>
|
||||
<text
|
||||
x={moduleX + serviceWidth - 70}
|
||||
y={moduleY + 33}
|
||||
textAnchor="middle"
|
||||
fill={
|
||||
module.priority === 'critical' ? '#dc2626' :
|
||||
module.priority === 'high' ? '#ea580c' :
|
||||
module.priority === 'medium' ? '#ca8a04' :
|
||||
'#64748b'
|
||||
}
|
||||
fontSize="9"
|
||||
fontWeight="500"
|
||||
>
|
||||
{module.priority.toUpperCase()}
|
||||
</text>
|
||||
|
||||
{/* Dependency indicator */}
|
||||
{module.dependencies && module.dependencies.length > 0 && (
|
||||
<g>
|
||||
<circle
|
||||
cx={moduleX + serviceWidth - 55}
|
||||
cy={moduleY + moduleHeight - 12}
|
||||
r="8"
|
||||
fill="#f3e8ff"
|
||||
/>
|
||||
<text
|
||||
x={moduleX + serviceWidth - 55}
|
||||
y={moduleY + moduleHeight - 8}
|
||||
textAnchor="middle"
|
||||
fill="#8b5cf6"
|
||||
fontSize="9"
|
||||
fontWeight="600"
|
||||
>
|
||||
{module.dependencies.length}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Connections (Dependencies) */}
|
||||
{connections.map((conn, idx) => {
|
||||
const fromPos = getModulePosition(conn.from)
|
||||
const toPos = getModulePosition(conn.to)
|
||||
|
||||
if (!fromPos || !toPos) return null
|
||||
|
||||
const isHighlighted = (selectedModule || hoveredModule) &&
|
||||
(conn.from === (selectedModule || hoveredModule) || conn.to === (selectedModule || hoveredModule))
|
||||
|
||||
const opacity = (selectedModule || hoveredModule)
|
||||
? (isHighlighted ? 1 : 0.1)
|
||||
: 0.4
|
||||
|
||||
// Calculate curved path
|
||||
const startX = fromPos.x
|
||||
const startY = fromPos.y
|
||||
const endX = toPos.x
|
||||
const endY = toPos.y
|
||||
|
||||
const midX = (startX + endX) / 2
|
||||
const controlOffset = Math.abs(startX - endX) * 0.3
|
||||
|
||||
const path = startX < endX
|
||||
? `M ${startX + fromPos.width / 2} ${startY}
|
||||
C ${startX + fromPos.width / 2 + controlOffset} ${startY},
|
||||
${endX - toPos.width / 2 - controlOffset} ${endY},
|
||||
${endX - toPos.width / 2} ${endY}`
|
||||
: `M ${startX - fromPos.width / 2} ${startY}
|
||||
C ${startX - fromPos.width / 2 - controlOffset} ${startY},
|
||||
${endX + toPos.width / 2 + controlOffset} ${endY},
|
||||
${endX + toPos.width / 2} ${endY}`
|
||||
|
||||
return (
|
||||
<path
|
||||
key={idx}
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke={isHighlighted ? '#8b5cf6' : '#a78bfa'}
|
||||
strokeWidth={isHighlighted ? 2 : 1.5}
|
||||
strokeDasharray={isHighlighted ? undefined : '4 2'}
|
||||
markerEnd={isHighlighted ? 'url(#arrowhead-highlight)' : 'url(#arrowhead)'}
|
||||
opacity={opacity}
|
||||
className="transition-opacity duration-200"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Frontend Layer (Bottom) */}
|
||||
<g>
|
||||
<rect
|
||||
x={padding}
|
||||
y={totalHeight - 80}
|
||||
width={totalWidth - padding * 2}
|
||||
height={60}
|
||||
fill="#f8fafc"
|
||||
stroke="#e2e8f0"
|
||||
strokeWidth="1"
|
||||
rx="8"
|
||||
/>
|
||||
<text
|
||||
x={totalWidth / 2}
|
||||
y={totalHeight - 55}
|
||||
textAnchor="middle"
|
||||
fill="#64748b"
|
||||
fontSize="12"
|
||||
fontWeight="500"
|
||||
>
|
||||
Admin v2 Frontend (Next.js - Port 3002)
|
||||
</text>
|
||||
<text
|
||||
x={totalWidth / 2}
|
||||
y={totalHeight - 35}
|
||||
textAnchor="middle"
|
||||
fill="#94a3b8"
|
||||
fontSize="11"
|
||||
>
|
||||
/compliance | /ai | /infrastructure | /communication | /development
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Selected Module Details */}
|
||||
{selectedModule && (
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4">
|
||||
<h4 className="font-medium text-purple-900 mb-2">
|
||||
{MODULE_REGISTRY.find(m => m.id === selectedModule)?.name}
|
||||
</h4>
|
||||
<div className="text-sm text-purple-700 space-y-1">
|
||||
<p>ID: <code className="bg-purple-100 px-1 rounded">{selectedModule}</code></p>
|
||||
{MODULE_REGISTRY.find(m => m.id === selectedModule)?.dependencies && (
|
||||
<p>
|
||||
Abhaengigkeiten:
|
||||
{MODULE_REGISTRY.find(m => m.id === selectedModule)?.dependencies?.map(dep => (
|
||||
<button
|
||||
key={dep}
|
||||
onClick={() => setSelectedModule(dep)}
|
||||
className="ml-2 px-2 py-0.5 bg-purple-200 text-purple-800 rounded hover:bg-purple-300"
|
||||
>
|
||||
{dep}
|
||||
</button>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
{MODULE_REGISTRY.find(m => m.id === selectedModule)?.frontend.adminV2Page && (
|
||||
<p>
|
||||
Frontend:
|
||||
<a
|
||||
href={MODULE_REGISTRY.find(m => m.id === selectedModule)?.frontend.adminV2Page}
|
||||
className="ml-2 text-purple-600 hover:underline"
|
||||
>
|
||||
{MODULE_REGISTRY.find(m => m.id === selectedModule)?.frontend.adminV2Page}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
interface InfoBoxProps {
|
||||
variant: 'info' | 'tip' | 'warning' | 'error'
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const variants = {
|
||||
info: {
|
||||
bg: 'bg-blue-50',
|
||||
border: 'border-blue-200',
|
||||
icon: '💡',
|
||||
titleColor: 'text-blue-800',
|
||||
textColor: 'text-blue-700',
|
||||
},
|
||||
tip: {
|
||||
bg: 'bg-green-50',
|
||||
border: 'border-green-200',
|
||||
icon: '✨',
|
||||
titleColor: 'text-green-800',
|
||||
textColor: 'text-green-700',
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-amber-50',
|
||||
border: 'border-amber-200',
|
||||
icon: '⚠️',
|
||||
titleColor: 'text-amber-800',
|
||||
textColor: 'text-amber-700',
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-red-50',
|
||||
border: 'border-red-200',
|
||||
icon: '❌',
|
||||
titleColor: 'text-red-800',
|
||||
textColor: 'text-red-700',
|
||||
},
|
||||
}
|
||||
|
||||
export function InfoBox({ variant, title, children, className = '' }: InfoBoxProps) {
|
||||
const style = variants[variant]
|
||||
|
||||
return (
|
||||
<div className={`${style.bg} ${style.border} border rounded-xl p-4 ${className}`}>
|
||||
<div className="flex gap-3">
|
||||
<span className="text-xl flex-shrink-0">{style.icon}</span>
|
||||
<div>
|
||||
{title && (
|
||||
<h4 className={`font-semibold ${style.titleColor} mb-1`}>{title}</h4>
|
||||
)}
|
||||
<div className={`text-sm ${style.textColor}`}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Convenience components
|
||||
export function InfoTip({ title, children, className }: Omit<InfoBoxProps, 'variant'>) {
|
||||
return <InfoBox variant="tip" title={title} className={className}>{children}</InfoBox>
|
||||
}
|
||||
|
||||
export function InfoWarning({ title, children, className }: Omit<InfoBoxProps, 'variant'>) {
|
||||
return <InfoBox variant="warning" title={title} className={className}>{children}</InfoBox>
|
||||
}
|
||||
|
||||
export function InfoNote({ title, children, className }: Omit<InfoBoxProps, 'variant'>) {
|
||||
return <InfoBox variant="info" title={title} className={className}>{children}</InfoBox>
|
||||
}
|
||||
|
||||
export function InfoError({ title, children, className }: Omit<InfoBoxProps, 'variant'>) {
|
||||
return <InfoBox variant="error" title={title} className={className}>{children}</InfoBox>
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import Link from 'next/link'
|
||||
import { NavModule, NavCategory } from '@/lib/navigation'
|
||||
|
||||
interface ModuleCardProps {
|
||||
module: NavModule
|
||||
category: NavCategory
|
||||
showDescription?: boolean
|
||||
}
|
||||
|
||||
export function ModuleCard({ module, category, showDescription = true }: ModuleCardProps) {
|
||||
return (
|
||||
<Link
|
||||
href={module.href}
|
||||
className={`block p-4 rounded-xl border-2 transition-all hover:shadow-md bg-${category.colorClass}-50 border-${category.colorClass}-200 hover:border-${category.colorClass}-400`}
|
||||
style={{
|
||||
backgroundColor: `${category.color}10`,
|
||||
borderColor: `${category.color}40`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Color indicator */}
|
||||
<div
|
||||
className="w-1.5 h-12 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: category.color }}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-slate-900 truncate">{module.name}</h3>
|
||||
{showDescription && (
|
||||
<p className="text-sm text-slate-500 mt-1 line-clamp-2">{module.description}</p>
|
||||
)}
|
||||
|
||||
{/* Audience tags */}
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{module.audience.slice(0, 2).map((a) => (
|
||||
<span
|
||||
key={a}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium text-slate-600 bg-slate-100"
|
||||
>
|
||||
{a}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<svg
|
||||
className="w-5 h-5 text-slate-400 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// Category Card for overview pages
|
||||
interface CategoryCardProps {
|
||||
category: NavCategory
|
||||
showModuleCount?: boolean
|
||||
}
|
||||
|
||||
export function CategoryCard({ category, showModuleCount = true }: CategoryCardProps) {
|
||||
return (
|
||||
<Link
|
||||
href={category.id === 'compliance-sdk' ? '/sdk' : `/${category.id}`}
|
||||
className="block p-6 rounded-xl border-2 transition-all hover:shadow-lg bg-white"
|
||||
style={{
|
||||
borderColor: `${category.color}40`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className="w-12 h-12 rounded-xl flex items-center justify-center"
|
||||
style={{ backgroundColor: `${category.color}20` }}
|
||||
>
|
||||
<span style={{ color: category.color }} className="text-2xl">
|
||||
{category.icon === 'shield' && '🛡️'}
|
||||
{category.icon === 'brain' && '🧠'}
|
||||
{category.icon === 'server' && '🖥️'}
|
||||
{category.icon === 'graduation' && '🎓'}
|
||||
{category.icon === 'mail' && '📬'}
|
||||
{category.icon === 'code' && '💻'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-lg text-slate-900">{category.name}</h3>
|
||||
<p className="text-sm text-slate-500 line-clamp-1">{category.description}</p>
|
||||
{showModuleCount && (
|
||||
<span className="text-xs text-slate-400 mt-1">
|
||||
{category.modules.length} Module
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<svg
|
||||
className="w-6 h-6 text-slate-400 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export interface PagePurposeProps {
|
||||
title: string
|
||||
purpose: string
|
||||
audience: string[]
|
||||
gdprArticles?: string[]
|
||||
architecture?: {
|
||||
services: string[]
|
||||
databases: string[]
|
||||
diagram?: string
|
||||
}
|
||||
relatedPages?: Array<{
|
||||
name: string
|
||||
href: string
|
||||
description: string
|
||||
}>
|
||||
collapsible?: boolean
|
||||
defaultCollapsed?: boolean
|
||||
}
|
||||
|
||||
export function PagePurpose({
|
||||
title,
|
||||
purpose,
|
||||
audience,
|
||||
gdprArticles,
|
||||
architecture,
|
||||
relatedPages,
|
||||
collapsible = true,
|
||||
defaultCollapsed = false,
|
||||
}: PagePurposeProps) {
|
||||
const [collapsed, setCollapsed] = useState(defaultCollapsed)
|
||||
const [showArchitecture, setShowArchitecture] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-slate-50 to-slate-100 rounded-xl border border-slate-200 mb-6 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div
|
||||
className={`flex items-center justify-between px-4 py-3 ${
|
||||
collapsible ? 'cursor-pointer hover:bg-slate-100' : ''
|
||||
}`}
|
||||
onClick={collapsible ? () => setCollapsed(!collapsed) : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">🎯</span>
|
||||
<span className="font-semibold text-slate-700">Warum gibt es diese Seite?</span>
|
||||
</div>
|
||||
{collapsible && (
|
||||
<svg
|
||||
className={`w-5 h-5 text-slate-400 transition-transform ${collapsed ? '' : 'rotate-180'}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{!collapsed && (
|
||||
<div className="px-4 pb-4 space-y-4">
|
||||
{/* Purpose */}
|
||||
<p className="text-slate-600">{purpose}</p>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
{/* Audience */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-400">👥</span>
|
||||
<span className="text-slate-500">Zielgruppe:</span>
|
||||
<span className="text-slate-700">{audience.join(', ')}</span>
|
||||
</div>
|
||||
|
||||
{/* GDPR Articles */}
|
||||
{gdprArticles && gdprArticles.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-slate-400">📋</span>
|
||||
<span className="text-slate-500">DSGVO-Bezug:</span>
|
||||
<span className="text-slate-700">{gdprArticles.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Architecture (expandable) */}
|
||||
{architecture && (
|
||||
<div className="border-t border-slate-200 pt-3">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowArchitecture(!showArchitecture)
|
||||
}}
|
||||
className="flex items-center gap-2 text-sm text-slate-500 hover:text-slate-700"
|
||||
>
|
||||
<span>🏗️</span>
|
||||
<span>Architektur</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${showArchitecture ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{showArchitecture && (
|
||||
<div className="mt-2 p-3 bg-white rounded-lg border border-slate-200 text-sm">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="font-medium text-slate-600">Services:</span>
|
||||
<ul className="mt-1 space-y-1 text-slate-500">
|
||||
{architecture.services.map((service) => (
|
||||
<li key={service} className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 bg-primary-400 rounded-full" />
|
||||
{service}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-slate-600">Datenbanken:</span>
|
||||
<ul className="mt-1 space-y-1 text-slate-500">
|
||||
{architecture.databases.map((db) => (
|
||||
<li key={db} className="flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 bg-green-400 rounded-full" />
|
||||
{db}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Related Pages */}
|
||||
{relatedPages && relatedPages.length > 0 && (
|
||||
<div className="border-t border-slate-200 pt-3">
|
||||
<span className="text-sm text-slate-500 flex items-center gap-2 mb-2">
|
||||
<span>🔗</span>
|
||||
<span>Verwandte Seiten</span>
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{relatedPages.map((page) => (
|
||||
<Link
|
||||
key={page.href}
|
||||
href={page.href}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 bg-white border border-slate-200 rounded-lg text-sm text-slate-600 hover:border-primary-300 hover:text-primary-600 transition-colors"
|
||||
title={page.description}
|
||||
>
|
||||
<span>{page.name}</span>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
|
||||
interface ServiceHealth {
|
||||
name: string
|
||||
port: number
|
||||
status: 'online' | 'offline' | 'checking' | 'degraded'
|
||||
responseTime?: number
|
||||
details?: string
|
||||
category: 'core' | 'ai' | 'database' | 'storage'
|
||||
}
|
||||
|
||||
// Initial services list for loading state
|
||||
const INITIAL_SERVICES: Omit<ServiceHealth, 'status' | 'responseTime' | 'details'>[] = [
|
||||
{ name: 'Backend API', port: 8002, category: 'core' },
|
||||
{ name: 'AI Compliance SDK', port: 8093, category: 'core' },
|
||||
{ name: 'Consent Service', port: 8081, category: 'core' },
|
||||
{ name: 'TTS Service', port: 8095, category: 'core' },
|
||||
{ name: 'Ollama/LLM', port: 11434, category: 'ai' },
|
||||
{ name: 'Embedding Service', port: 8087, category: 'ai' },
|
||||
{ name: 'RAG Service', port: 8089, category: 'ai' },
|
||||
{ name: 'Qdrant (Vector DB)', port: 6333, category: 'database' },
|
||||
{ name: 'Valkey (Cache)', port: 6379, category: 'database' },
|
||||
{ name: 'MinIO (S3)', port: 9000, category: 'storage' },
|
||||
]
|
||||
|
||||
export function ServiceStatus() {
|
||||
const [services, setServices] = useState<ServiceHealth[]>(
|
||||
INITIAL_SERVICES.map(s => ({ ...s, status: 'checking' as const }))
|
||||
)
|
||||
const [lastChecked, setLastChecked] = useState<Date | null>(null)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
|
||||
const checkServices = useCallback(async () => {
|
||||
setIsRefreshing(true)
|
||||
|
||||
try {
|
||||
// Use server-side API route to avoid mixed-content issues
|
||||
const response = await fetch('/api/admin/health', {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setServices(data.services.map((s: ServiceHealth) => ({
|
||||
...s,
|
||||
status: s.status as 'online' | 'offline' | 'degraded'
|
||||
})))
|
||||
} else {
|
||||
// If API fails, mark all as offline
|
||||
setServices(prev => prev.map(s => ({
|
||||
...s,
|
||||
status: 'offline' as const,
|
||||
details: 'Health-Check API nicht erreichbar'
|
||||
})))
|
||||
}
|
||||
} catch (error) {
|
||||
// Network error - mark all as offline
|
||||
setServices(prev => prev.map(s => ({
|
||||
...s,
|
||||
status: 'offline' as const,
|
||||
details: error instanceof Error ? error.message : 'Verbindungsfehler'
|
||||
})))
|
||||
}
|
||||
|
||||
setLastChecked(new Date())
|
||||
setIsRefreshing(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
checkServices()
|
||||
// Auto-refresh every 30 seconds
|
||||
const interval = setInterval(checkServices, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [checkServices])
|
||||
|
||||
const getStatusColor = (status: ServiceHealth['status']) => {
|
||||
switch (status) {
|
||||
case 'online': return 'bg-green-500'
|
||||
case 'offline': return 'bg-red-500'
|
||||
case 'degraded': return 'bg-yellow-500'
|
||||
case 'checking': return 'bg-slate-300 animate-pulse'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status: ServiceHealth['status']) => {
|
||||
switch (status) {
|
||||
case 'online': return 'Online'
|
||||
case 'offline': return 'Offline'
|
||||
case 'degraded': return 'Eingeschränkt'
|
||||
case 'checking': return 'Prüfe...'
|
||||
}
|
||||
}
|
||||
|
||||
const getCategoryIcon = (category: ServiceHealth['category']) => {
|
||||
switch (category) {
|
||||
case 'core': return '⚙️'
|
||||
case 'ai': return '🤖'
|
||||
case 'database': return '🗄️'
|
||||
case 'storage': return '📦'
|
||||
}
|
||||
}
|
||||
|
||||
const getCategoryLabel = (category: ServiceHealth['category']) => {
|
||||
switch (category) {
|
||||
case 'core': return 'Core Services'
|
||||
case 'ai': return 'AI / LLM'
|
||||
case 'database': return 'Datenbanken'
|
||||
case 'storage': return 'Storage'
|
||||
}
|
||||
}
|
||||
|
||||
const groupedServices = services.reduce((acc, service) => {
|
||||
if (!acc[service.category]) {
|
||||
acc[service.category] = []
|
||||
}
|
||||
acc[service.category].push(service)
|
||||
return acc
|
||||
}, {} as Record<string, ServiceHealth[]>)
|
||||
|
||||
const onlineCount = services.filter(s => s.status === 'online').length
|
||||
const totalCount = services.length
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
|
||||
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-semibold text-slate-900">System Status</h3>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
onlineCount === totalCount
|
||||
? 'bg-green-100 text-green-700'
|
||||
: onlineCount > totalCount / 2
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{onlineCount}/{totalCount} online
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={checkServices}
|
||||
disabled={isRefreshing}
|
||||
className="text-sm text-primary-600 hover:text-primary-700 disabled:opacity-50 flex items-center gap-1"
|
||||
>
|
||||
<svg className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{(['ai', 'core', 'database', 'storage'] as const).map(category => (
|
||||
<div key={category}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span>{getCategoryIcon(category)}</span>
|
||||
<span className="text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||||
{getCategoryLabel(category)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{groupedServices[category]?.map((service) => (
|
||||
<div key={service.name} className="flex items-center justify-between py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${getStatusColor(service.status)}`}></span>
|
||||
<span className="text-sm text-slate-700">{service.name}</span>
|
||||
<span className="text-xs text-slate-400">:{service.port}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{service.details && (
|
||||
<span className="text-xs text-slate-500">{service.details}</span>
|
||||
)}
|
||||
{service.responseTime !== undefined && service.status === 'online' && (
|
||||
<span className="text-xs text-slate-400">{service.responseTime}ms</span>
|
||||
)}
|
||||
<span className={`text-xs ${
|
||||
service.status === 'online' ? 'text-green-600' :
|
||||
service.status === 'offline' ? 'text-red-600' :
|
||||
service.status === 'degraded' ? 'text-yellow-600' :
|
||||
'text-slate-400'
|
||||
}`}>
|
||||
{getStatusText(service.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{lastChecked && (
|
||||
<div className="px-4 py-2 border-t border-slate-100 text-xs text-slate-400">
|
||||
Zuletzt geprüft: {lastChecked.toLocaleTimeString('de-DE')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
/**
|
||||
* Skeleton Loading Components
|
||||
*
|
||||
* Animated placeholder components for loading states.
|
||||
* Used throughout the app for smoother UX during async operations.
|
||||
*/
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface SkeletonTextProps {
|
||||
/** Number of lines to display */
|
||||
lines?: number
|
||||
/** Width variants for varied line lengths */
|
||||
variant?: 'uniform' | 'varied' | 'paragraph'
|
||||
/** Custom class names */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated skeleton text placeholder
|
||||
*/
|
||||
export function SkeletonText({ lines = 3, variant = 'varied', className = '' }: SkeletonTextProps) {
|
||||
const getLineWidth = (index: number) => {
|
||||
if (variant === 'uniform') return 'w-full'
|
||||
if (variant === 'paragraph') {
|
||||
// Last line is shorter for paragraph effect
|
||||
if (index === lines - 1) return 'w-3/5'
|
||||
return 'w-full'
|
||||
}
|
||||
// Varied widths
|
||||
const widths = ['w-full', 'w-4/5', 'w-3/4', 'w-5/6', 'w-2/3']
|
||||
return widths[index % widths.length]
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-4 bg-slate-200 rounded animate-pulse ${getLineWidth(i)}`}
|
||||
style={{ animationDelay: `${i * 100}ms` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SkeletonBoxProps {
|
||||
/** Width (Tailwind class or px) */
|
||||
width?: string
|
||||
/** Height (Tailwind class or px) */
|
||||
height?: string
|
||||
/** Custom class names */
|
||||
className?: string
|
||||
/** Border radius */
|
||||
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated skeleton box for images, avatars, cards
|
||||
*/
|
||||
export function SkeletonBox({
|
||||
width = 'w-full',
|
||||
height = 'h-32',
|
||||
className = '',
|
||||
rounded = 'lg'
|
||||
}: SkeletonBoxProps) {
|
||||
const roundedClass = {
|
||||
none: '',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
xl: 'rounded-xl',
|
||||
full: 'rounded-full'
|
||||
}[rounded]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-slate-200 animate-pulse ${width} ${height} ${roundedClass} ${className}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface SkeletonCardProps {
|
||||
/** Show image placeholder */
|
||||
showImage?: boolean
|
||||
/** Number of text lines */
|
||||
lines?: number
|
||||
/** Custom class names */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton card with optional image and text lines
|
||||
*/
|
||||
export function SkeletonCard({ showImage = true, lines = 3, className = '' }: SkeletonCardProps) {
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-sm border p-4 ${className}`}>
|
||||
{showImage && (
|
||||
<SkeletonBox height="h-32" className="mb-4" />
|
||||
)}
|
||||
<SkeletonBox width="w-2/3" height="h-5" className="mb-3" />
|
||||
<SkeletonText lines={lines} variant="paragraph" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SkeletonOCRResultProps {
|
||||
/** Custom class names */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton specifically for OCR results display
|
||||
* Shows loading state while OCR is processing
|
||||
*/
|
||||
export function SkeletonOCRResult({ className = '' }: SkeletonOCRResultProps) {
|
||||
return (
|
||||
<div className={`bg-slate-50 rounded-lg p-4 ${className}`}>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="w-4 h-4 rounded-full bg-purple-200 animate-pulse" />
|
||||
<SkeletonBox width="w-32" height="h-4" rounded="md" />
|
||||
</div>
|
||||
|
||||
{/* Text area skeleton */}
|
||||
<div className="bg-white border p-3 rounded space-y-2 mb-4">
|
||||
<SkeletonText lines={4} variant="paragraph" />
|
||||
</div>
|
||||
|
||||
{/* Metrics grid skeleton */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="bg-white border rounded p-2">
|
||||
<SkeletonBox width="w-16" height="h-3" className="mb-2" rounded="sm" />
|
||||
<SkeletonBox width="w-12" height="h-5" rounded="sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SkeletonWrapperProps {
|
||||
/** Whether to show skeleton or children */
|
||||
loading: boolean
|
||||
/** Content to show when not loading */
|
||||
children: ReactNode
|
||||
/** Skeleton component or elements to show */
|
||||
skeleton?: ReactNode
|
||||
/** Fallback skeleton lines (if skeleton prop not provided) */
|
||||
lines?: number
|
||||
/** Custom class names */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper component that toggles between skeleton and content
|
||||
*/
|
||||
export function SkeletonWrapper({
|
||||
loading,
|
||||
children,
|
||||
skeleton,
|
||||
lines = 3,
|
||||
className = ''
|
||||
}: SkeletonWrapperProps) {
|
||||
if (loading) {
|
||||
return skeleton ? <>{skeleton}</> : <SkeletonText lines={lines} className={className} />
|
||||
}
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
interface SkeletonProgressProps {
|
||||
/** Animation speed in seconds */
|
||||
speed?: number
|
||||
/** Custom class names */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated progress skeleton with shimmer effect
|
||||
*/
|
||||
export function SkeletonProgress({ speed = 1.5, className = '' }: SkeletonProgressProps) {
|
||||
return (
|
||||
<div className={`relative overflow-hidden bg-slate-200 rounded-full h-2 ${className}`}>
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-r from-slate-200 via-slate-300 to-slate-200 animate-shimmer"
|
||||
style={{
|
||||
backgroundSize: '200% 100%',
|
||||
animation: `shimmer ${speed}s infinite linear`
|
||||
}}
|
||||
/>
|
||||
<style jsx>{`
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulsing dot indicator for inline loading
|
||||
*/
|
||||
export function SkeletonDots({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 ${className}`}>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="w-1.5 h-1.5 rounded-full bg-purple-500 animate-pulse"
|
||||
style={{ animationDelay: `${i * 200}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface NightModeConfig {
|
||||
enabled: boolean
|
||||
shutdown_time: string
|
||||
startup_time: string
|
||||
last_action: string | null
|
||||
last_action_time: string | null
|
||||
}
|
||||
|
||||
interface NightModeStatus {
|
||||
config: NightModeConfig
|
||||
current_time: string
|
||||
next_action: string | null
|
||||
next_action_time: string | null
|
||||
time_until_next_action: string | null
|
||||
services_status: Record<string, string>
|
||||
}
|
||||
|
||||
export function NightModeWidget() {
|
||||
const [status, setStatus] = useState<NightModeStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/night-mode')
|
||||
if (response.ok) {
|
||||
setStatus(await response.json())
|
||||
setError(null)
|
||||
} else {
|
||||
setError('Nicht erreichbar')
|
||||
}
|
||||
} catch {
|
||||
setError('Verbindung fehlgeschlagen')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
const interval = setInterval(fetchData, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchData])
|
||||
|
||||
const toggleEnabled = async () => {
|
||||
if (!status) return
|
||||
setActionLoading('toggle')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/night-mode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...status.config, enabled: !status.config.enabled }),
|
||||
})
|
||||
if (response.ok) {
|
||||
fetchData()
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const executeAction = async (action: 'start' | 'stop') => {
|
||||
setActionLoading(action)
|
||||
try {
|
||||
const response = await fetch('/api/admin/night-mode/execute', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action }),
|
||||
})
|
||||
if (response.ok) {
|
||||
fetchData()
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const runningCount = Object.values(status?.services_status || {}).filter(
|
||||
s => s.toLowerCase() === 'running' || s.toLowerCase().includes('up')
|
||||
).length
|
||||
const totalCount = Object.keys(status?.services_status || {}).length
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-4">
|
||||
<div className="animate-pulse flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-slate-200 rounded-full" />
|
||||
<div className="flex-1">
|
||||
<div className="h-4 bg-slate-200 rounded w-24 mb-2" />
|
||||
<div className="h-3 bg-slate-200 rounded w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900">Nachtabschaltung</h3>
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/infrastructure/night-mode" className="text-sm text-primary-600 hover:text-primary-700">
|
||||
Details
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
status?.config.enabled ? 'bg-orange-100' : 'bg-slate-100'
|
||||
}`}>
|
||||
<svg className={`w-5 h-5 ${status?.config.enabled ? 'text-orange-600' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-900">Nachtabschaltung</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
{status?.config.enabled
|
||||
? `${status.config.shutdown_time} - ${status.config.startup_time}`
|
||||
: 'Deaktiviert'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/infrastructure/night-mode" className="text-sm text-primary-600 hover:text-primary-700">
|
||||
Einstellungen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
{/* Status Row */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Toggle */}
|
||||
<button
|
||||
onClick={toggleEnabled}
|
||||
disabled={actionLoading !== null}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
status?.config.enabled ? 'bg-orange-600' : 'bg-slate-300'
|
||||
} ${actionLoading === 'toggle' ? 'opacity-50' : ''}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
|
||||
status?.config.enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`} />
|
||||
</button>
|
||||
|
||||
{/* Countdown */}
|
||||
{status?.config.enabled && status.time_until_next_action && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm font-medium ${
|
||||
status.next_action === 'shutdown' ? 'text-red-600' : 'text-green-600'
|
||||
}`}>
|
||||
{status.next_action === 'shutdown' ? '⏸' : '▶'} {status.time_until_next_action}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Service Count */}
|
||||
<div className="text-sm text-slate-500">
|
||||
<span className="font-semibold text-green-600">{runningCount}</span>
|
||||
<span className="text-slate-400">/{totalCount}</span>
|
||||
<span className="ml-1">aktiv</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => executeAction('stop')}
|
||||
disabled={actionLoading !== null}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-red-50 text-red-700 rounded-lg text-sm font-medium hover:bg-red-100 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{actionLoading === 'stop' ? (
|
||||
<span className="animate-spin text-xs">⟳</span>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 10h6v4H9z" />
|
||||
</svg>
|
||||
)}
|
||||
Stoppen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => executeAction('start')}
|
||||
disabled={actionLoading !== null}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-green-50 text-green-700 rounded-lg text-sm font-medium hover:bg-green-100 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{actionLoading === 'start' ? (
|
||||
<span className="animate-spin text-xs">⟳</span>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
Starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { navigation, metaModules, getModuleByHref } from '@/lib/navigation'
|
||||
|
||||
interface HeaderProps {
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function Header({ title, description }: HeaderProps) {
|
||||
const pathname = usePathname()
|
||||
|
||||
// Auto-detect title and description from navigation
|
||||
let pageTitle = title
|
||||
let pageDescription = description
|
||||
|
||||
if (!pageTitle) {
|
||||
// Check meta modules first
|
||||
const metaModule = metaModules.find(m => pathname === m.href || pathname.startsWith(m.href + '/'))
|
||||
if (metaModule) {
|
||||
pageTitle = metaModule.name
|
||||
pageDescription = metaModule.description
|
||||
} else {
|
||||
// Check navigation modules
|
||||
const result = getModuleByHref(pathname)
|
||||
if (result) {
|
||||
pageTitle = result.module.name
|
||||
pageDescription = result.module.description
|
||||
} else {
|
||||
// Check category pages
|
||||
const category = navigation.find(cat => pathname === `/${cat.id}`)
|
||||
if (category) {
|
||||
pageTitle = category.name
|
||||
pageDescription = category.description
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="h-16 bg-white border-b border-slate-200 flex items-center px-6 sticky top-0 z-10">
|
||||
<div className="flex-1">
|
||||
{pageTitle && <h1 className="text-xl font-semibold text-slate-900">{pageTitle}</h1>}
|
||||
{pageDescription && <p className="text-sm text-slate-500">{pageDescription}</p>}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suchen... (Ctrl+K)"
|
||||
className="w-64 pl-10 pr-4 py-2 bg-slate-100 border border-transparent rounded-lg text-sm focus:bg-white focus:border-primary-300 focus:outline-none transition-colors"
|
||||
/>
|
||||
<svg
|
||||
className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* User Area */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-slate-500">Admin Compliance</span>
|
||||
<div className="w-8 h-8 rounded-full bg-primary-600 flex items-center justify-center text-white text-sm font-medium">
|
||||
A
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { roles, getRoleById, getStoredRole, storeRole, RoleId } from '@/lib/roles'
|
||||
|
||||
interface RoleIndicatorProps {
|
||||
collapsed?: boolean
|
||||
onRoleChange?: () => void
|
||||
}
|
||||
|
||||
export function RoleIndicator({ collapsed, onRoleChange }: RoleIndicatorProps) {
|
||||
const router = useRouter()
|
||||
const [currentRole, setCurrentRole] = useState<RoleId | null>(null)
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const role = getStoredRole()
|
||||
setCurrentRole(role)
|
||||
}, [])
|
||||
|
||||
const handleRoleChange = (roleId: RoleId) => {
|
||||
storeRole(roleId)
|
||||
setCurrentRole(roleId)
|
||||
setShowDropdown(false)
|
||||
onRoleChange?.()
|
||||
// Refresh the page to update navigation
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const role = currentRole ? getRoleById(currentRole) : null
|
||||
|
||||
if (!role) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Role icons
|
||||
const roleIcons: Record<RoleId, React.ReactNode> = {
|
||||
developer: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
),
|
||||
manager: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
auditor: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
),
|
||||
dsb: (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-slate-300 hover:bg-slate-800 transition-colors ${
|
||||
collapsed ? 'justify-center' : ''
|
||||
}`}
|
||||
title={collapsed ? `Rolle: ${role.name}` : undefined}
|
||||
>
|
||||
{currentRole && roleIcons[currentRole]}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="flex-1 text-left">Rolle: {role.name}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${showDropdown ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{showDropdown && (
|
||||
<div className={`absolute ${collapsed ? 'left-full ml-2' : 'left-0 right-0'} bottom-full mb-2 bg-slate-800 rounded-lg shadow-lg border border-slate-700 overflow-hidden`}>
|
||||
{roles.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => handleRoleChange(r.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors ${
|
||||
r.id === currentRole
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'text-slate-300 hover:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{roleIcons[r.id]}
|
||||
<span>{r.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { navigation, metaModules, NavCategory, CategoryId } from '@/lib/navigation'
|
||||
import { RoleId, getStoredRole, isCategoryVisibleForRole } from '@/lib/roles'
|
||||
import { RoleIndicator } from './RoleIndicator'
|
||||
|
||||
// Icons mapping
|
||||
const categoryIcons: Record<string, React.ReactNode> = {
|
||||
'shield-check': (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
),
|
||||
'clipboard-check': (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
),
|
||||
shield: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
),
|
||||
brain: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
),
|
||||
server: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
|
||||
</svg>
|
||||
),
|
||||
graduation: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l9-5-9-5-9 5 9 5z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l9-5-9-5-9 5 9 5zm0 0l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998a12.078 12.078 0 01.665-6.479L12 14zm-4 6v-7.5l4-2.222" />
|
||||
</svg>
|
||||
),
|
||||
mail: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
code: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
),
|
||||
globe: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
),
|
||||
'code-2': (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
|
||||
const metaIcons: Record<string, React.ReactNode> = {
|
||||
dashboard: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
),
|
||||
architecture: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
onboarding: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
),
|
||||
backlog: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
),
|
||||
rbac: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
onRoleChange?: () => void
|
||||
}
|
||||
|
||||
export function Sidebar({ onRoleChange }: SidebarProps) {
|
||||
const pathname = usePathname()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<CategoryId>>(new Set())
|
||||
const [currentRole, setCurrentRole] = useState<RoleId | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const role = getStoredRole()
|
||||
setCurrentRole(role)
|
||||
// Auto-expand category based on current path
|
||||
if (role) {
|
||||
const category = navigation.find(cat =>
|
||||
cat.modules.some(m => pathname.startsWith(m.href.split('/')[1] ? `/${m.href.split('/')[1]}` : m.href))
|
||||
)
|
||||
if (category) {
|
||||
setExpandedCategories(new Set([category.id]))
|
||||
}
|
||||
}
|
||||
}, [pathname])
|
||||
|
||||
const toggleCategory = (categoryId: CategoryId) => {
|
||||
setExpandedCategories(prev => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(categoryId)) {
|
||||
newSet.delete(categoryId)
|
||||
} else {
|
||||
newSet.add(categoryId)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
const isModuleActive = (href: string) => {
|
||||
if (href === '/dashboard') {
|
||||
return pathname === '/dashboard'
|
||||
}
|
||||
return pathname.startsWith(href)
|
||||
}
|
||||
|
||||
const visibleCategories = currentRole
|
||||
? navigation.filter(cat => isCategoryVisibleForRole(cat.id, currentRole))
|
||||
: navigation
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`${
|
||||
collapsed ? 'w-16' : 'w-64'
|
||||
} bg-slate-900 text-white flex flex-col transition-all duration-300 fixed h-full z-20`}
|
||||
>
|
||||
{/* Logo/Header */}
|
||||
<div className="h-16 flex items-center justify-between px-4 border-b border-slate-700">
|
||||
{!collapsed && (
|
||||
<Link href="/dashboard" className="font-bold text-lg">
|
||||
Compliance Admin
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="p-2 rounded-lg hover:bg-slate-800 transition-colors"
|
||||
title={collapsed ? 'Sidebar erweitern' : 'Sidebar einklappen'}
|
||||
>
|
||||
<svg
|
||||
className={`w-5 h-5 transition-transform ${collapsed ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 py-4 overflow-y-auto">
|
||||
{/* Meta Modules */}
|
||||
<div className="px-2 mb-4">
|
||||
{metaModules.map((module) => (
|
||||
<Link
|
||||
key={module.id}
|
||||
href={module.href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${
|
||||
isModuleActive(module.href)
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'text-slate-300 hover:bg-slate-800 hover:text-white'
|
||||
}`}
|
||||
title={collapsed ? module.name : undefined}
|
||||
>
|
||||
<span className="flex-shrink-0">{metaIcons[module.id]}</span>
|
||||
{!collapsed && <span className="truncate">{module.name}</span>}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mx-4 border-t border-slate-700 my-2" />
|
||||
|
||||
{/* Categories */}
|
||||
<div className="px-2 space-y-1">
|
||||
{visibleCategories.map((category) => {
|
||||
const categoryHref = `/${category.id}`
|
||||
const isCategoryActive = pathname.startsWith(categoryHref)
|
||||
|
||||
return (
|
||||
<div key={category.id}>
|
||||
<div
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${
|
||||
isCategoryActive
|
||||
? 'bg-slate-800 text-white'
|
||||
: 'text-slate-300 hover:bg-slate-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Link
|
||||
href={categoryHref}
|
||||
className="flex items-center gap-3 flex-1 min-w-0"
|
||||
title={collapsed ? category.name : undefined}
|
||||
>
|
||||
<span
|
||||
className="flex-shrink-0"
|
||||
style={{ color: category.color }}
|
||||
>
|
||||
{categoryIcons[category.icon]}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<span className="flex-1 text-left truncate">{category.name}</span>
|
||||
)}
|
||||
</Link>
|
||||
{!collapsed && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
toggleCategory(category.id)
|
||||
}}
|
||||
className="p-1 rounded hover:bg-slate-700 transition-colors"
|
||||
title={expandedCategories.has(category.id) ? 'Einklappen' : 'Aufklappen'}
|
||||
>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${
|
||||
expandedCategories.has(category.id) ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category Modules */}
|
||||
{!collapsed && expandedCategories.has(category.id) && (
|
||||
<div className="ml-4 mt-1 space-y-1">
|
||||
{(() => {
|
||||
// Group modules by subgroup
|
||||
const subgroups = new Map<string | undefined, typeof category.modules>()
|
||||
category.modules.forEach((module) => {
|
||||
const key = module.subgroup
|
||||
if (!subgroups.has(key)) {
|
||||
subgroups.set(key, [])
|
||||
}
|
||||
subgroups.get(key)!.push(module)
|
||||
})
|
||||
|
||||
return Array.from(subgroups.entries()).map(([subgroupName, modules]) => (
|
||||
<div key={subgroupName || 'default'}>
|
||||
{subgroupName && (
|
||||
<div className="px-3 py-1.5 text-xs font-medium text-slate-500 uppercase tracking-wider mt-2 first:mt-0">
|
||||
{subgroupName}
|
||||
</div>
|
||||
)}
|
||||
{modules.map((module) => (
|
||||
<Link
|
||||
key={module.id}
|
||||
href={module.href}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
isModuleActive(module.href)
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: category.color }}
|
||||
/>
|
||||
<span className="truncate">{module.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Footer with Role Indicator */}
|
||||
<div className="p-4 border-t border-slate-700">
|
||||
<RoleIndicator collapsed={collapsed} onRoleChange={onRoleChange} />
|
||||
|
||||
<a
|
||||
href="https://macmini:3000/admin"
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors mt-2 ${
|
||||
collapsed ? 'justify-center' : ''
|
||||
}`}
|
||||
title="Altes Admin (Port 3000)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
{!collapsed && <span>Altes Admin</span>}
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -552,7 +552,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/development/sdk-flow"
|
||||
href="/sdk/sdk-flow"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
@@ -560,7 +560,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
|
||||
</svg>
|
||||
}
|
||||
label="SDK Flow"
|
||||
isActive={pathname === '/development/sdk-flow'}
|
||||
isActive={pathname === '/sdk/sdk-flow'}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
{state.companyProfile?.machineBuilder?.ceMarkingRequired && (
|
||||
|
||||
Reference in New Issue
Block a user