Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AI Module Sidebar
|
||||
*
|
||||
* Kompakte Sidebar-Komponente für Cross-Navigation zwischen AI-Modulen.
|
||||
* Zeigt den Datenfluss und ermöglicht schnelle Navigation.
|
||||
*
|
||||
* Features:
|
||||
* - Desktop: Fixierte Sidebar rechts
|
||||
* - Mobile: Floating Action Button mit Slide-In Drawer
|
||||
*/
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { AI_MODULES, DATA_FLOW_CONNECTIONS, type AIModuleLink } from '@/types/ai-modules'
|
||||
|
||||
export interface AIModuleSidebarProps {
|
||||
/** ID des aktuell aktiven Moduls */
|
||||
currentModule: 'magic-help' | 'ocr-labeling' | 'rag-pipeline' | 'rag'
|
||||
/** Optional: Kompakter Modus (nur Icons) */
|
||||
compact?: boolean
|
||||
/** Optional: Zusätzliche CSS-Klassen */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export interface AIModuleSidebarResponsiveProps extends AIModuleSidebarProps {
|
||||
/** Position des FAB auf Mobile */
|
||||
fabPosition?: 'bottom-right' | 'bottom-left'
|
||||
}
|
||||
|
||||
// Icons für die Module
|
||||
const ModuleIcon = ({ id }: { id: string }) => {
|
||||
switch (id) {
|
||||
case 'magic-help':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||
</svg>
|
||||
)
|
||||
case 'ocr-labeling':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
)
|
||||
case 'rag-pipeline':
|
||||
return (
|
||||
<svg className="w-5 h-5" 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>
|
||||
)
|
||||
case 'rag':
|
||||
return (
|
||||
<svg className="w-5 h-5" 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>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Pfeil-Icon für Datenfluss
|
||||
const ArrowIcon = () => (
|
||||
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export function AIModuleSidebar({
|
||||
currentModule,
|
||||
compact = false,
|
||||
className = '',
|
||||
}: AIModuleSidebarProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(!compact)
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-slate-200 dark:border-gray-700 overflow-hidden ${className}`}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-4 py-3 bg-gradient-to-r from-teal-50 to-cyan-50 dark:from-teal-900/20 dark:to-cyan-900/20 border-b border-slate-200 dark:border-gray-700 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-teal-600 dark:text-teal-400">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="font-semibold text-slate-700 dark:text-slate-200 text-sm">
|
||||
KI-Daten-Pipeline
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-slate-400 transition-transform ${isExpanded ? '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>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Module Links */}
|
||||
<div className="space-y-1">
|
||||
{AI_MODULES.map((module) => (
|
||||
<Link
|
||||
key={module.id}
|
||||
href={module.href}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentModule === module.id
|
||||
? 'bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300 font-medium'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<ModuleIcon id={module.id} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{module.name}</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-500 truncate">
|
||||
{module.description}
|
||||
</div>
|
||||
</div>
|
||||
{currentModule === module.id && (
|
||||
<span className="flex-shrink-0 w-2 h-2 bg-teal-500 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Datenfluss-Visualisierung */}
|
||||
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2 px-1">
|
||||
Datenfluss
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-2 py-2 bg-slate-50 dark:bg-gray-900 rounded-lg">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span title="Magic Help">✨</span>
|
||||
<span className="text-slate-400" title="Bidirektional">⟷</span>
|
||||
<span title="OCR-Labeling">🏷️</span>
|
||||
<ArrowIcon />
|
||||
<span title="RAG Pipeline">🔄</span>
|
||||
<ArrowIcon />
|
||||
<span title="Daten & RAG">🔍</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Info zum aktuellen Modul */}
|
||||
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 px-1">
|
||||
{currentModule === 'magic-help' && (
|
||||
<span>Testen und verbessern Sie die TrOCR-Handschrifterkennung</span>
|
||||
)}
|
||||
{currentModule === 'ocr-labeling' && (
|
||||
<span>Erstellen Sie Ground Truth Daten für das OCR-Training</span>
|
||||
)}
|
||||
{currentModule === 'rag-pipeline' && (
|
||||
<span>Indexieren Sie Dokumente für die semantische Suche</span>
|
||||
)}
|
||||
{currentModule === 'rag' && (
|
||||
<span>Verwalten und durchsuchen Sie indexierte Dokumente</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kompakte Inline-Version für mobile Ansichten
|
||||
*/
|
||||
export function AIModuleNav({ currentModule }: { currentModule: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 p-1 bg-slate-100 dark:bg-gray-800 rounded-lg">
|
||||
{AI_MODULES.map((module) => (
|
||||
<Link
|
||||
key={module.id}
|
||||
href={module.href}
|
||||
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
|
||||
currentModule === module.id
|
||||
? 'bg-white dark:bg-gray-700 text-teal-600 dark:text-teal-400 font-medium shadow-sm'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
|
||||
}`}
|
||||
title={module.description}
|
||||
>
|
||||
{module.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsive Sidebar mit Mobile FAB + Drawer
|
||||
*
|
||||
* Desktop (xl+): Fixierte Sidebar rechts
|
||||
* Mobile/Tablet: Floating Action Button unten rechts, öffnet Drawer
|
||||
*/
|
||||
export function AIModuleSidebarResponsive({
|
||||
currentModule,
|
||||
compact = false,
|
||||
className = '',
|
||||
fabPosition = 'bottom-right',
|
||||
}: AIModuleSidebarResponsiveProps) {
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false)
|
||||
|
||||
// Close drawer on route change or escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setIsMobileOpen(false)
|
||||
}
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [])
|
||||
|
||||
// Prevent body scroll when drawer is open
|
||||
useEffect(() => {
|
||||
if (isMobileOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [isMobileOpen])
|
||||
|
||||
const fabPositionClasses = fabPosition === 'bottom-right'
|
||||
? 'right-4 bottom-20'
|
||||
: 'left-4 bottom-20'
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop: Fixed Sidebar */}
|
||||
<div className={`hidden xl:block fixed right-6 top-24 w-64 z-10 ${className}`}>
|
||||
<AIModuleSidebar currentModule={currentModule} compact={compact} />
|
||||
</div>
|
||||
|
||||
{/* Mobile/Tablet: FAB */}
|
||||
<button
|
||||
onClick={() => setIsMobileOpen(true)}
|
||||
className={`xl:hidden fixed ${fabPositionClasses} z-40 w-14 h-14 bg-gradient-to-r from-teal-500 to-cyan-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center group`}
|
||||
aria-label="KI-Daten-Pipeline Navigation öffnen"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6 group-hover:scale-110 transition-transform"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
{/* Pulse indicator */}
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-orange-400 rounded-full animate-pulse" />
|
||||
</button>
|
||||
|
||||
{/* Mobile/Tablet: Drawer Overlay */}
|
||||
{isMobileOpen && (
|
||||
<div className="xl:hidden fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-80 max-w-[85vw] bg-white dark:bg-gray-900 shadow-2xl transform transition-transform animate-slide-in-right">
|
||||
{/* Drawer Header */}
|
||||
<div className="flex items-center justify-between px-4 py-4 border-b border-slate-200 dark:border-gray-700 bg-gradient-to-r from-teal-50 to-cyan-50 dark:from-teal-900/20 dark:to-cyan-900/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-teal-600 dark:text-teal-400">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="font-semibold text-slate-700 dark:text-slate-200">
|
||||
KI-Daten-Pipeline
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Drawer Content */}
|
||||
<div className="p-4 space-y-4 overflow-y-auto max-h-[calc(100vh-80px)]">
|
||||
{/* Module Links */}
|
||||
<div className="space-y-2">
|
||||
{AI_MODULES.map((module) => (
|
||||
<Link
|
||||
key={module.id}
|
||||
href={module.href}
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all ${
|
||||
currentModule === module.id
|
||||
? 'bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300 font-medium shadow-sm'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<ModuleIcon id={module.id} />
|
||||
<div className="flex-1">
|
||||
<div className="text-base font-medium">{module.name}</div>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-500">
|
||||
{module.description}
|
||||
</div>
|
||||
</div>
|
||||
{currentModule === module.id && (
|
||||
<span className="flex-shrink-0 w-2.5 h-2.5 bg-teal-500 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Datenfluss-Visualisierung */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">
|
||||
Datenfluss
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 p-4 bg-slate-50 dark:bg-gray-800 rounded-xl">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">✨</span>
|
||||
<span className="text-xs text-slate-500 mt-1">Magic</span>
|
||||
</div>
|
||||
<span className="text-slate-400 text-lg" title="Bidirektional">⟷</span>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">🏷️</span>
|
||||
<span className="text-xs text-slate-500 mt-1">OCR</span>
|
||||
</div>
|
||||
<ArrowIcon />
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">🔄</span>
|
||||
<span className="text-xs text-slate-500 mt-1">Pipeline</span>
|
||||
</div>
|
||||
<ArrowIcon />
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">🔍</span>
|
||||
<span className="text-xs text-slate-500 mt-1">RAG</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Info */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-sm text-slate-600 dark:text-slate-400 p-3 bg-slate-50 dark:bg-gray-800 rounded-xl">
|
||||
{currentModule === 'magic-help' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> TrOCR-Handschrifterkennung testen und verbessern
|
||||
</>
|
||||
)}
|
||||
{currentModule === 'ocr-labeling' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Ground Truth Daten für OCR-Training erstellen
|
||||
</>
|
||||
)}
|
||||
{currentModule === 'rag-pipeline' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Dokumente für semantische Suche indexieren
|
||||
</>
|
||||
)}
|
||||
{currentModule === 'rag' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Indexierte Dokumente verwalten und durchsuchen
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSS for slide-in animation */}
|
||||
<style jsx>{`
|
||||
@keyframes slide-in-right {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 0.2s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AIModuleSidebar
|
||||
@@ -0,0 +1,462 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AI Tools Sidebar
|
||||
*
|
||||
* Kompakte Sidebar-Komponente für Cross-Navigation zwischen KI-Werkzeugen.
|
||||
* Zeigt Shared Resources und ermöglicht schnelle Navigation.
|
||||
*
|
||||
* Features:
|
||||
* - Desktop: Fixierte Sidebar rechts
|
||||
* - Mobile: Floating Action Button mit Slide-In Drawer
|
||||
*/
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export type AIToolId = 'llm-compare' | 'test-quality' | 'gpu' | 'ocr-compare' | 'ocr-labeling' | 'rag-pipeline' | 'magic-help'
|
||||
|
||||
export interface AIToolModule {
|
||||
id: AIToolId
|
||||
name: string
|
||||
href: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export const AI_TOOLS_MODULES: AIToolModule[] = [
|
||||
{
|
||||
id: 'llm-compare',
|
||||
name: 'LLM Vergleich',
|
||||
href: '/ai/llm-compare',
|
||||
description: 'KI-Provider vergleichen',
|
||||
icon: '⚖️',
|
||||
},
|
||||
{
|
||||
id: 'test-quality',
|
||||
name: 'Test Quality (BQAS)',
|
||||
href: '/ai/test-quality',
|
||||
description: 'Golden Suite & Tests',
|
||||
icon: '🧪',
|
||||
},
|
||||
{
|
||||
id: 'gpu',
|
||||
name: 'GPU Infrastruktur',
|
||||
href: '/ai/gpu',
|
||||
description: 'vast.ai Management',
|
||||
icon: '🖥️',
|
||||
},
|
||||
{
|
||||
id: 'ocr-compare',
|
||||
name: 'OCR Vergleich',
|
||||
href: '/ai/ocr-compare',
|
||||
description: 'OCR-Methoden & Vokabel-Extraktion',
|
||||
icon: '🔍',
|
||||
},
|
||||
{
|
||||
id: 'ocr-labeling',
|
||||
name: 'OCR Labeling',
|
||||
href: '/ai/ocr-labeling',
|
||||
description: 'Trainingsdaten erstellen',
|
||||
icon: '🏷️',
|
||||
},
|
||||
{
|
||||
id: 'rag-pipeline',
|
||||
name: 'RAG Pipeline',
|
||||
href: '/ai/rag-pipeline',
|
||||
description: 'Retrieval Augmented Generation',
|
||||
icon: '🔗',
|
||||
},
|
||||
{
|
||||
id: 'magic-help',
|
||||
name: 'Magic Help',
|
||||
href: '/ai/magic-help',
|
||||
description: 'KI-Assistent',
|
||||
icon: '✨',
|
||||
},
|
||||
]
|
||||
|
||||
export interface AIToolsSidebarProps {
|
||||
/** ID des aktuell aktiven Tools */
|
||||
currentTool: AIToolId
|
||||
/** Optional: Kompakter Modus (nur Icons) */
|
||||
compact?: boolean
|
||||
/** Optional: Zusätzliche CSS-Klassen */
|
||||
className?: string
|
||||
}
|
||||
|
||||
export interface AIToolsSidebarResponsiveProps extends AIToolsSidebarProps {
|
||||
/** Position des FAB auf Mobile */
|
||||
fabPosition?: 'bottom-right' | 'bottom-left'
|
||||
}
|
||||
|
||||
// Icons für die Tools
|
||||
const ToolIcon = ({ id }: { id: string }) => {
|
||||
switch (id) {
|
||||
case 'llm-compare':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
|
||||
</svg>
|
||||
)
|
||||
case 'test-quality':
|
||||
return (
|
||||
<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-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)
|
||||
case 'gpu':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
)
|
||||
case 'ocr-compare':
|
||||
return (
|
||||
<svg className="w-5 h-5" 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>
|
||||
)
|
||||
case 'ocr-labeling':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
)
|
||||
case 'rag-pipeline':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
)
|
||||
case 'magic-help':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||
</svg>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Werkzeug-Icon für Header
|
||||
const WrenchIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export function AIToolsSidebar({
|
||||
currentTool,
|
||||
compact = false,
|
||||
className = '',
|
||||
}: AIToolsSidebarProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(!compact)
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-slate-200 dark:border-gray-700 overflow-hidden ${className}`}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-4 py-3 bg-gradient-to-r from-violet-50 to-purple-50 dark:from-violet-900/20 dark:to-purple-900/20 border-b border-slate-200 dark:border-gray-700 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-violet-600 dark:text-violet-400">
|
||||
<WrenchIcon />
|
||||
</span>
|
||||
<span className="font-semibold text-slate-700 dark:text-slate-200 text-sm">
|
||||
KI-Werkzeuge
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-slate-400 transition-transform ${isExpanded ? '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>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Tool Links */}
|
||||
<div className="space-y-1">
|
||||
{AI_TOOLS_MODULES.map((tool) => (
|
||||
<Link
|
||||
key={tool.id}
|
||||
href={tool.href}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentTool === tool.id
|
||||
? 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 font-medium'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<ToolIcon id={tool.id} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{tool.name}</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-500 truncate">
|
||||
{tool.description}
|
||||
</div>
|
||||
</div>
|
||||
{currentTool === tool.id && (
|
||||
<span className="flex-shrink-0 w-2 h-2 bg-violet-500 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Shared Resources Visualisierung */}
|
||||
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2 px-1">
|
||||
Shared Resources
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-2 py-2 bg-slate-50 dark:bg-gray-900 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span title="GPU Infrastruktur">🖥️</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span title="LLM Vergleich">⚖️</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span title="Test Quality">🧪</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 mt-1 px-1">
|
||||
GPU-Ressourcen fuer Modelle & Tests
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Info zum aktuellen Tool */}
|
||||
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 px-1">
|
||||
{currentTool === 'llm-compare' && (
|
||||
<span>Vergleichen Sie LLM-Antworten verschiedener Provider</span>
|
||||
)}
|
||||
{currentTool === 'test-quality' && (
|
||||
<span>Ueberwachen Sie die Qualitaet der KI-Ausgaben</span>
|
||||
)}
|
||||
{currentTool === 'gpu' && (
|
||||
<span>Verwalten Sie GPU-Instanzen fuer ML-Training</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsive Tools Sidebar mit Mobile FAB + Drawer
|
||||
*
|
||||
* Desktop (xl+): Fixierte Sidebar rechts
|
||||
* Mobile/Tablet: Floating Action Button unten rechts, öffnet Drawer
|
||||
*/
|
||||
export function AIToolsSidebarResponsive({
|
||||
currentTool,
|
||||
compact = false,
|
||||
className = '',
|
||||
fabPosition = 'bottom-right',
|
||||
}: AIToolsSidebarResponsiveProps) {
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false)
|
||||
|
||||
// Close drawer on escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setIsMobileOpen(false)
|
||||
}
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [])
|
||||
|
||||
// Prevent body scroll when drawer is open
|
||||
useEffect(() => {
|
||||
if (isMobileOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [isMobileOpen])
|
||||
|
||||
const fabPositionClasses = fabPosition === 'bottom-right'
|
||||
? 'right-4 bottom-20'
|
||||
: 'left-4 bottom-20'
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop: Fixed Sidebar */}
|
||||
<div className={`hidden xl:block fixed right-6 top-24 w-64 z-10 ${className}`}>
|
||||
<AIToolsSidebar currentTool={currentTool} compact={compact} />
|
||||
</div>
|
||||
|
||||
{/* Mobile/Tablet: FAB */}
|
||||
<button
|
||||
onClick={() => setIsMobileOpen(true)}
|
||||
className={`xl:hidden fixed ${fabPositionClasses} z-40 w-14 h-14 bg-gradient-to-r from-violet-500 to-purple-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center group`}
|
||||
aria-label="KI-Werkzeuge Navigation oeffnen"
|
||||
>
|
||||
<WrenchIcon />
|
||||
{/* Pulse indicator */}
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-amber-400 rounded-full animate-pulse" />
|
||||
</button>
|
||||
|
||||
{/* Mobile/Tablet: Drawer Overlay */}
|
||||
{isMobileOpen && (
|
||||
<div className="xl:hidden fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-80 max-w-[85vw] bg-white dark:bg-gray-900 shadow-2xl transform transition-transform animate-slide-in-right">
|
||||
{/* Drawer Header */}
|
||||
<div className="flex items-center justify-between px-4 py-4 border-b border-slate-200 dark:border-gray-700 bg-gradient-to-r from-violet-50 to-purple-50 dark:from-violet-900/20 dark:to-purple-900/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-violet-600 dark:text-violet-400">
|
||||
<WrenchIcon />
|
||||
</span>
|
||||
<span className="font-semibold text-slate-700 dark:text-slate-200">
|
||||
KI-Werkzeuge
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label="Schliessen"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Drawer Content */}
|
||||
<div className="p-4 space-y-4 overflow-y-auto max-h-[calc(100vh-80px)]">
|
||||
{/* Tool Links */}
|
||||
<div className="space-y-2">
|
||||
{AI_TOOLS_MODULES.map((tool) => (
|
||||
<Link
|
||||
key={tool.id}
|
||||
href={tool.href}
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all ${
|
||||
currentTool === tool.id
|
||||
? 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 font-medium shadow-sm'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<ToolIcon id={tool.id} />
|
||||
<div className="flex-1">
|
||||
<div className="text-base font-medium">{tool.name}</div>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-500">
|
||||
{tool.description}
|
||||
</div>
|
||||
</div>
|
||||
{currentTool === tool.id && (
|
||||
<span className="flex-shrink-0 w-2.5 h-2.5 bg-violet-500 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Shared Resources Visualisierung */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">
|
||||
Shared Resources
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 p-4 bg-slate-50 dark:bg-gray-800 rounded-xl">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">🖥️</span>
|
||||
<span className="text-xs text-slate-500 mt-1">GPU</span>
|
||||
</div>
|
||||
<span className="text-slate-400">→</span>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">⚖️</span>
|
||||
<span className="text-xs text-slate-500 mt-1">LLM</span>
|
||||
</div>
|
||||
<span className="text-slate-400">→</span>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">🧪</span>
|
||||
<span className="text-xs text-slate-500 mt-1">BQAS</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 mt-2 text-center">
|
||||
GPU-Ressourcen fuer Modelle & Tests
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Info */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-sm text-slate-600 dark:text-slate-400 p-3 bg-slate-50 dark:bg-gray-800 rounded-xl">
|
||||
{currentTool === 'llm-compare' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> LLM-Antworten verschiedener Provider vergleichen
|
||||
</>
|
||||
)}
|
||||
{currentTool === 'test-quality' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Qualitaet der KI-Ausgaben ueberwachen
|
||||
</>
|
||||
)}
|
||||
{currentTool === 'gpu' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> GPU-Instanzen fuer ML-Training verwalten
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Link zur Pipeline */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
|
||||
<Link
|
||||
href="/ai/magic-help"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/20 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span>Zur KI-Daten-Pipeline</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSS for slide-in animation */}
|
||||
<style jsx>{`
|
||||
@keyframes slide-in-right {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 0.2s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AIToolsSidebar
|
||||
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* Batch Uploader Component
|
||||
*
|
||||
* Multi-file upload with drag & drop, progress tracking, and SSE integration.
|
||||
* Supports batch OCR processing for multiple images.
|
||||
*
|
||||
* Phase 2.1: Batch Processing UI
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
|
||||
interface UploadedFile {
|
||||
id: string
|
||||
file: File
|
||||
preview: string
|
||||
status: 'pending' | 'processing' | 'completed' | 'error'
|
||||
result?: {
|
||||
text: string
|
||||
confidence: number
|
||||
processing_time_ms: number
|
||||
}
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface BatchUploaderProps {
|
||||
/** API base URL */
|
||||
apiBase: string
|
||||
/** Maximum files allowed */
|
||||
maxFiles?: number
|
||||
/** Whether to auto-start OCR after upload */
|
||||
autoProcess?: boolean
|
||||
/** Callback when all files are processed */
|
||||
onComplete?: (results: UploadedFile[]) => void
|
||||
/** Callback when a single file is processed */
|
||||
onFileProcessed?: (file: UploadedFile) => void
|
||||
/** Custom class names */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique ID
|
||||
*/
|
||||
function generateId(): string {
|
||||
return Math.random().toString(36).substring(2, 11)
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch Uploader Component
|
||||
*/
|
||||
export function BatchUploader({
|
||||
apiBase,
|
||||
maxFiles = 20,
|
||||
autoProcess = false,
|
||||
onComplete,
|
||||
onFileProcessed,
|
||||
className = ''
|
||||
}: BatchUploaderProps) {
|
||||
const [files, setFiles] = useState<UploadedFile[]>([])
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [isDragActive, setIsDragActive] = useState(false)
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0 })
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
|
||||
// Add files to the list
|
||||
const addFiles = useCallback((newFiles: FileList | File[]) => {
|
||||
const fileArray = Array.from(newFiles)
|
||||
const imageFiles = fileArray.filter(f => f.type.startsWith('image/'))
|
||||
|
||||
if (imageFiles.length === 0) return
|
||||
|
||||
const uploadedFiles: UploadedFile[] = imageFiles
|
||||
.slice(0, maxFiles - files.length)
|
||||
.map(file => ({
|
||||
id: generateId(),
|
||||
file,
|
||||
preview: URL.createObjectURL(file),
|
||||
status: 'pending' as const
|
||||
}))
|
||||
|
||||
setFiles(prev => [...prev, ...uploadedFiles].slice(0, maxFiles))
|
||||
|
||||
// Auto-process if enabled
|
||||
if (autoProcess && uploadedFiles.length > 0) {
|
||||
setTimeout(() => startProcessing(), 100)
|
||||
}
|
||||
}, [files.length, maxFiles, autoProcess])
|
||||
|
||||
// Remove a file
|
||||
const removeFile = useCallback((id: string) => {
|
||||
setFiles(prev => {
|
||||
const file = prev.find(f => f.id === id)
|
||||
if (file) {
|
||||
URL.revokeObjectURL(file.preview)
|
||||
}
|
||||
return prev.filter(f => f.id !== id)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Clear all files
|
||||
const clearAll = useCallback(() => {
|
||||
files.forEach(f => URL.revokeObjectURL(f.preview))
|
||||
setFiles([])
|
||||
setProgress({ current: 0, total: 0 })
|
||||
}, [files])
|
||||
|
||||
// Start batch processing with SSE
|
||||
const startProcessing = useCallback(async () => {
|
||||
if (isProcessing || files.length === 0) return
|
||||
|
||||
const pendingFiles = files.filter(f => f.status === 'pending')
|
||||
if (pendingFiles.length === 0) return
|
||||
|
||||
setIsProcessing(true)
|
||||
setProgress({ current: 0, total: pendingFiles.length })
|
||||
|
||||
// Close any existing connection
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
}
|
||||
|
||||
// Use SSE for progress updates
|
||||
const eventSource = new EventSource(
|
||||
`${apiBase}/api/v1/admin/training/ocr/stream?images_count=${pendingFiles.length}`
|
||||
)
|
||||
eventSourceRef.current = eventSource
|
||||
|
||||
let processedIndex = 0
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
if (data.type === 'progress') {
|
||||
setProgress({ current: data.current, total: data.total })
|
||||
|
||||
// Update file status
|
||||
if (processedIndex < pendingFiles.length) {
|
||||
const currentFile = pendingFiles[processedIndex]
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.id === currentFile.id
|
||||
? {
|
||||
...f,
|
||||
status: 'completed' as const,
|
||||
result: data.result
|
||||
}
|
||||
: f
|
||||
))
|
||||
|
||||
onFileProcessed?.({
|
||||
...currentFile,
|
||||
status: 'completed',
|
||||
result: data.result
|
||||
})
|
||||
|
||||
processedIndex++
|
||||
}
|
||||
} else if (data.type === 'error') {
|
||||
if (processedIndex < pendingFiles.length) {
|
||||
const currentFile = pendingFiles[processedIndex]
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.id === currentFile.id
|
||||
? { ...f, status: 'error' as const, error: data.error }
|
||||
: f
|
||||
))
|
||||
processedIndex++
|
||||
}
|
||||
} else if (data.type === 'complete') {
|
||||
eventSource.close()
|
||||
setIsProcessing(false)
|
||||
onComplete?.(files)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('SSE parse error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource.close()
|
||||
setIsProcessing(false)
|
||||
// Mark remaining as error
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.status === 'pending' || f.status === 'processing'
|
||||
? { ...f, status: 'error' as const, error: 'Verbindung unterbrochen' }
|
||||
: f
|
||||
))
|
||||
}
|
||||
|
||||
// Mark files as processing
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.status === 'pending' ? { ...f, status: 'processing' as const } : f
|
||||
))
|
||||
}, [apiBase, files, isProcessing, onComplete, onFileProcessed])
|
||||
|
||||
// Stop processing
|
||||
const stopProcessing = useCallback(() => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
setIsProcessing(false)
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.status === 'processing' ? { ...f, status: 'pending' as const } : f
|
||||
))
|
||||
}, [])
|
||||
|
||||
// Drag handlers
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragActive(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragActive(false)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragActive(false)
|
||||
addFiles(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
files.forEach(f => URL.revokeObjectURL(f.preview))
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const completedCount = files.filter(f => f.status === 'completed').length
|
||||
const errorCount = files.filter(f => f.status === 'error').length
|
||||
const avgConfidence = files
|
||||
.filter(f => f.result)
|
||||
.reduce((sum, f) => sum + (f.result?.confidence || 0), 0) /
|
||||
Math.max(1, files.filter(f => f.result).length)
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* Upload area */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
|
||||
isDragActive
|
||||
? 'border-purple-500 bg-purple-50'
|
||||
: 'border-slate-300 hover:border-purple-400 hover:bg-purple-50/50'
|
||||
}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<div className="text-4xl mb-3">📁</div>
|
||||
<div className="text-slate-700 font-medium">
|
||||
{isDragActive
|
||||
? 'Loslassen zum Hochladen'
|
||||
: 'Bilder hierher ziehen oder klicken'}
|
||||
</div>
|
||||
<div className="text-sm text-slate-400 mt-1">
|
||||
Bis zu {maxFiles} Bilder (PNG, JPG)
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => e.target.files && addFiles(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File list */}
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{/* Header with stats */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-slate-600">
|
||||
{files.length} Bilder ausgewaehlt
|
||||
{completedCount > 0 && (
|
||||
<span className="text-green-600 ml-2">
|
||||
({completedCount} verarbeitet, {(avgConfidence * 100).toFixed(0)}% Konfidenz)
|
||||
</span>
|
||||
)}
|
||||
{errorCount > 0 && (
|
||||
<span className="text-red-600 ml-2">({errorCount} Fehler)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="px-3 py-1 text-sm text-slate-600 hover:text-slate-900 transition-colors"
|
||||
>
|
||||
Alle entfernen
|
||||
</button>
|
||||
{isProcessing ? (
|
||||
<button
|
||||
onClick={stopProcessing}
|
||||
className="px-4 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Stoppen
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={startProcessing}
|
||||
disabled={files.filter(f => f.status === 'pending').length === 0}
|
||||
className="px-4 py-1.5 bg-purple-600 hover:bg-purple-700 disabled:bg-slate-300 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
OCR starten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{isProcessing && (
|
||||
<div className="bg-slate-100 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="text-slate-600">
|
||||
Verarbeite Bild {progress.current} von {progress.total}
|
||||
</span>
|
||||
<span className="text-purple-600 font-medium">
|
||||
{((progress.current / progress.total) * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-600 transition-all duration-300"
|
||||
style={{ width: `${(progress.current / progress.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File thumbnails */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{files.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className={`relative group rounded-lg overflow-hidden border-2 ${
|
||||
file.status === 'completed'
|
||||
? 'border-green-300'
|
||||
: file.status === 'error'
|
||||
? 'border-red-300'
|
||||
: file.status === 'processing'
|
||||
? 'border-purple-300'
|
||||
: 'border-slate-200'
|
||||
}`}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<img
|
||||
src={file.preview}
|
||||
alt={file.file.name}
|
||||
className="w-full h-24 object-cover"
|
||||
/>
|
||||
|
||||
{/* Overlay for status */}
|
||||
{file.status === 'processing' && (
|
||||
<div className="absolute inset-0 bg-purple-900/50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{file.status === 'completed' && file.result && (
|
||||
<div className="absolute inset-0 bg-green-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="text-center text-white text-xs p-2">
|
||||
<div className="font-medium">
|
||||
{(file.result.confidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-green-200 truncate max-w-full">
|
||||
{file.result.text.substring(0, 30)}...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{file.status === 'error' && (
|
||||
<div className="absolute inset-0 bg-red-900/70 flex items-center justify-center">
|
||||
<div className="text-white text-xs text-center px-2">
|
||||
Fehler
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status badge */}
|
||||
<div className={`absolute top-1 right-1 w-3 h-3 rounded-full ${
|
||||
file.status === 'completed'
|
||||
? 'bg-green-500'
|
||||
: file.status === 'error'
|
||||
? 'bg-red-500'
|
||||
: file.status === 'processing'
|
||||
? 'bg-purple-500 animate-pulse'
|
||||
: 'bg-slate-400'
|
||||
}`} />
|
||||
|
||||
{/* Remove button */}
|
||||
{!isProcessing && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
removeFile(file.id)
|
||||
}}
|
||||
className="absolute top-1 left-1 w-5 h-5 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-xs"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* File name */}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs px-2 py-1 truncate">
|
||||
{file.file.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results summary */}
|
||||
{completedCount > 0 && !isProcessing && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<h4 className="font-medium text-green-800 mb-2">
|
||||
Verarbeitung abgeschlossen
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-4 text-center text-sm">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-green-700">{completedCount}</div>
|
||||
<div className="text-green-600">Erfolgreich</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-700">
|
||||
{(avgConfidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
<div className="text-slate-600">Durchschnittl. Konfidenz</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-red-700">{errorCount}</div>
|
||||
<div className="text-red-600">Fehler</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BatchUploader
|
||||
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Confidence Heatmap Component
|
||||
*
|
||||
* Displays an OCR result with visual confidence overlay on the original image.
|
||||
* Shows word-level or character-level confidence using color gradients.
|
||||
*
|
||||
* Phase 3.1: Wow-Feature for Magic Help
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||||
|
||||
interface WordBox {
|
||||
text: string
|
||||
confidence: number
|
||||
bbox: [number, number, number, number] // [x, y, width, height] as percentages (0-100)
|
||||
}
|
||||
|
||||
interface ConfidenceHeatmapProps {
|
||||
/** Image source URL or data URL */
|
||||
imageSrc: string
|
||||
/** Detected text result */
|
||||
text: string
|
||||
/** Overall confidence score (0-1) */
|
||||
confidence: number
|
||||
/** Word-level boxes with confidence */
|
||||
wordBoxes?: WordBox[]
|
||||
/** Character-level confidences (aligned with text) */
|
||||
charConfidences?: number[]
|
||||
/** Custom class names */
|
||||
className?: string
|
||||
/** Show legend */
|
||||
showLegend?: boolean
|
||||
/** Allow toggling overlay visibility */
|
||||
toggleable?: boolean
|
||||
/** Callback when a word box is clicked */
|
||||
onWordClick?: (word: WordBox) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color based on confidence value
|
||||
* Green (high) -> Yellow (medium) -> Red (low)
|
||||
*/
|
||||
function getConfidenceColor(confidence: number, opacity = 0.5): string {
|
||||
if (confidence >= 0.9) {
|
||||
return `rgba(34, 197, 94, ${opacity})` // green-500
|
||||
} else if (confidence >= 0.7) {
|
||||
return `rgba(234, 179, 8, ${opacity})` // yellow-500
|
||||
} else if (confidence >= 0.5) {
|
||||
return `rgba(249, 115, 22, ${opacity})` // orange-500
|
||||
} else {
|
||||
return `rgba(239, 68, 68, ${opacity})` // red-500
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get border color (more saturated version)
|
||||
*/
|
||||
function getConfidenceBorderColor(confidence: number): string {
|
||||
if (confidence >= 0.9) return '#22c55e' // green-500
|
||||
if (confidence >= 0.7) return '#eab308' // yellow-500
|
||||
if (confidence >= 0.5) return '#f97316' // orange-500
|
||||
return '#ef4444' // red-500
|
||||
}
|
||||
|
||||
/**
|
||||
* Confidence Heatmap Component
|
||||
*/
|
||||
export function ConfidenceHeatmap({
|
||||
imageSrc,
|
||||
text,
|
||||
confidence,
|
||||
wordBoxes = [],
|
||||
charConfidences = [],
|
||||
className = '',
|
||||
showLegend = true,
|
||||
toggleable = true,
|
||||
onWordClick
|
||||
}: ConfidenceHeatmapProps) {
|
||||
const [showOverlay, setShowOverlay] = useState(true)
|
||||
const [hoveredWord, setHoveredWord] = useState<WordBox | null>(null)
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 })
|
||||
const [isPanning, setIsPanning] = useState(false)
|
||||
const [lastPanPoint, setLastPanPoint] = useState({ x: 0, y: 0 })
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Generate simulated word boxes if not provided
|
||||
const displayBoxes = useMemo(() => {
|
||||
if (wordBoxes.length > 0) return wordBoxes
|
||||
|
||||
// Simulate word boxes from text and char confidences
|
||||
if (!text || charConfidences.length === 0) return []
|
||||
|
||||
const words = text.split(/\s+/).filter(w => w.length > 0)
|
||||
const boxes: WordBox[] = []
|
||||
let charIndex = 0
|
||||
|
||||
words.forEach((word, idx) => {
|
||||
// Calculate average confidence for this word
|
||||
const wordConfidences = charConfidences.slice(charIndex, charIndex + word.length)
|
||||
const avgConfidence = wordConfidences.length > 0
|
||||
? wordConfidences.reduce((a, b) => a + b, 0) / wordConfidences.length
|
||||
: confidence
|
||||
|
||||
// Simulate bbox positions (simple grid layout for demo)
|
||||
const wordsPerRow = 5
|
||||
const row = Math.floor(idx / wordsPerRow)
|
||||
const col = idx % wordsPerRow
|
||||
const wordWidth = Math.min(18, 5 + word.length * 1.5)
|
||||
|
||||
boxes.push({
|
||||
text: word,
|
||||
confidence: avgConfidence,
|
||||
bbox: [
|
||||
5 + col * 19, // x
|
||||
10 + row * 12, // y
|
||||
wordWidth, // width
|
||||
8 // height
|
||||
]
|
||||
})
|
||||
|
||||
charIndex += word.length + 1 // +1 for space
|
||||
})
|
||||
|
||||
return boxes
|
||||
}, [text, wordBoxes, charConfidences, confidence])
|
||||
|
||||
// Handle mouse wheel zoom
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
const delta = e.deltaY > 0 ? -0.1 : 0.1
|
||||
setZoom(prev => Math.max(1, Math.min(3, prev + delta)))
|
||||
}
|
||||
}
|
||||
|
||||
// Handle panning
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (zoom > 1) {
|
||||
setIsPanning(true)
|
||||
setLastPanPoint({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (isPanning && zoom > 1) {
|
||||
const dx = e.clientX - lastPanPoint.x
|
||||
const dy = e.clientY - lastPanPoint.y
|
||||
setPan(prev => ({
|
||||
x: Math.max(-100, Math.min(100, prev.x + dx / zoom)),
|
||||
y: Math.max(-100, Math.min(100, prev.y + dy / zoom))
|
||||
}))
|
||||
setLastPanPoint({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsPanning(false)
|
||||
}
|
||||
|
||||
// Reset zoom and pan
|
||||
const resetView = () => {
|
||||
setZoom(1)
|
||||
setPan({ x: 0, y: 0 })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{/* Controls */}
|
||||
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{toggleable && (
|
||||
<button
|
||||
onClick={() => setShowOverlay(prev => !prev)}
|
||||
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
|
||||
showOverlay
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
{showOverlay ? 'Overlay An' : 'Overlay Aus'}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-1 bg-slate-100 rounded p-1">
|
||||
<button
|
||||
onClick={() => setZoom(prev => Math.max(1, prev - 0.25))}
|
||||
className="p-1 hover:bg-slate-200 rounded transition-colors"
|
||||
title="Verkleinern"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-xs font-mono w-12 text-center">{(zoom * 100).toFixed(0)}%</span>
|
||||
<button
|
||||
onClick={() => setZoom(prev => Math.min(3, prev + 0.25))}
|
||||
className="p-1 hover:bg-slate-200 rounded transition-colors"
|
||||
title="Vergroessern"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
{zoom > 1 && (
|
||||
<button
|
||||
onClick={resetView}
|
||||
className="p-1 hover:bg-slate-200 rounded transition-colors ml-1"
|
||||
title="Zuruecksetzen"
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overall confidence badge */}
|
||||
<div className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
confidence >= 0.9 ? 'bg-green-100 text-green-700' :
|
||||
confidence >= 0.7 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
Gesamt: {(confidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Image container with overlay */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative overflow-hidden rounded-lg border border-slate-200 bg-slate-100"
|
||||
style={{ cursor: zoom > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default' }}
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
<div
|
||||
className="relative transition-transform duration-200"
|
||||
style={{
|
||||
transform: `scale(${zoom}) translate(${pan.x}px, ${pan.y}px)`,
|
||||
transformOrigin: 'center center'
|
||||
}}
|
||||
>
|
||||
{/* Original image */}
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt="OCR Dokument"
|
||||
className="w-full h-auto"
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* SVG Overlay */}
|
||||
{showOverlay && displayBoxes.length > 0 && (
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{displayBoxes.map((box, idx) => (
|
||||
<g
|
||||
key={idx}
|
||||
className="pointer-events-auto cursor-pointer"
|
||||
onMouseEnter={() => setHoveredWord(box)}
|
||||
onMouseLeave={() => setHoveredWord(null)}
|
||||
onClick={() => onWordClick?.(box)}
|
||||
>
|
||||
{/* Background fill */}
|
||||
<rect
|
||||
x={box.bbox[0]}
|
||||
y={box.bbox[1]}
|
||||
width={box.bbox[2]}
|
||||
height={box.bbox[3]}
|
||||
fill={getConfidenceColor(box.confidence, 0.3)}
|
||||
stroke={getConfidenceBorderColor(box.confidence)}
|
||||
strokeWidth="0.3"
|
||||
rx="0.5"
|
||||
className="transition-all duration-150"
|
||||
style={{
|
||||
filter: hoveredWord === box ? 'brightness(1.2)' : 'none'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Hover highlight */}
|
||||
{hoveredWord === box && (
|
||||
<rect
|
||||
x={box.bbox[0] - 0.5}
|
||||
y={box.bbox[1] - 0.5}
|
||||
width={box.bbox[2] + 1}
|
||||
height={box.bbox[3] + 1}
|
||||
fill="none"
|
||||
stroke="#7c3aed"
|
||||
strokeWidth="0.5"
|
||||
rx="0.5"
|
||||
className="animate-pulse"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hovered word tooltip */}
|
||||
{hoveredWord && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-slate-900 text-white text-sm rounded-lg shadow-lg z-10">
|
||||
<div className="font-mono">"{hoveredWord.text}"</div>
|
||||
<div className="text-slate-300 text-xs">
|
||||
Konfidenz: {(hoveredWord.confidence * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div
|
||||
className="absolute top-full left-1/2 -translate-x-1/2 w-2 h-2 bg-slate-900 rotate-45 -mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
{showLegend && (
|
||||
<div className="mt-3 flex items-center justify-center gap-4 text-xs text-slate-600">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(34, 197, 94, 0.5)' }} />
|
||||
<span>>90%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(234, 179, 8, 0.5)' }} />
|
||||
<span>70-90%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(249, 115, 22, 0.5)' }} />
|
||||
<span>50-70%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(239, 68, 68, 0.5)' }} />
|
||||
<span><50%</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<p className="mt-2 text-xs text-slate-400 text-center">
|
||||
Fahre ueber markierte Bereiche fuer Details. Strg+Scroll zum Zoomen.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline character confidence display
|
||||
* Shows text with color-coded background for each character
|
||||
*/
|
||||
interface InlineConfidenceTextProps {
|
||||
text: string
|
||||
charConfidences: number[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function InlineConfidenceText({
|
||||
text,
|
||||
charConfidences,
|
||||
className = ''
|
||||
}: InlineConfidenceTextProps) {
|
||||
if (charConfidences.length === 0) {
|
||||
return <span className={className}>{text}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`font-mono ${className}`}>
|
||||
{text.split('').map((char, idx) => {
|
||||
const conf = charConfidences[idx] ?? 0.5
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className="relative group"
|
||||
style={{ backgroundColor: getConfidenceColor(conf, 0.3) }}
|
||||
title={`'${char}': ${(conf * 100).toFixed(0)}%`}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Confidence Statistics Summary
|
||||
*/
|
||||
interface ConfidenceStatsProps {
|
||||
wordBoxes: WordBox[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ConfidenceStats({ wordBoxes, className = '' }: ConfidenceStatsProps) {
|
||||
const stats = useMemo(() => {
|
||||
if (wordBoxes.length === 0) return null
|
||||
|
||||
const confidences = wordBoxes.map(w => w.confidence)
|
||||
const avg = confidences.reduce((a, b) => a + b, 0) / confidences.length
|
||||
const min = Math.min(...confidences)
|
||||
const max = Math.max(...confidences)
|
||||
const highConf = confidences.filter(c => c >= 0.9).length
|
||||
const lowConf = confidences.filter(c => c < 0.7).length
|
||||
|
||||
return { avg, min, max, highConf, lowConf, total: wordBoxes.length }
|
||||
}, [wordBoxes])
|
||||
|
||||
if (!stats) return null
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-2 md:grid-cols-5 gap-3 ${className}`}>
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-slate-900">{(stats.avg * 100).toFixed(0)}%</div>
|
||||
<div className="text-xs text-slate-500">Durchschnitt</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-slate-900">{(stats.min * 100).toFixed(0)}%</div>
|
||||
<div className="text-xs text-slate-500">Minimum</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-slate-900">{(stats.max * 100).toFixed(0)}%</div>
|
||||
<div className="text-xs text-slate-500">Maximum</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-green-700">{stats.highConf}</div>
|
||||
<div className="text-xs text-slate-500">Sicher (>90%)</div>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-red-700">{stats.lowConf}</div>
|
||||
<div className="text-xs text-slate-500">Unsicher (<70%)</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfidenceHeatmap
|
||||
@@ -0,0 +1,613 @@
|
||||
/**
|
||||
* Training Metrics Component
|
||||
*
|
||||
* Real-time visualization of training progress with loss curves and metrics.
|
||||
* Supports SSE (Server-Sent Events) for live updates during LoRA fine-tuning.
|
||||
*
|
||||
* Phase 3.3: Training Dashboard with Live Metrics
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
// Training data point for charts
|
||||
interface TrainingDataPoint {
|
||||
epoch: number
|
||||
step: number
|
||||
loss: number
|
||||
val_loss?: number
|
||||
learning_rate: number
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
// Training job status
|
||||
interface TrainingStatus {
|
||||
job_id: string
|
||||
status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
|
||||
progress: number // 0-100
|
||||
current_epoch: number
|
||||
total_epochs: number
|
||||
current_step: number
|
||||
total_steps: number
|
||||
elapsed_time_ms: number
|
||||
estimated_remaining_ms: number
|
||||
metrics: {
|
||||
loss: number
|
||||
val_loss?: number
|
||||
accuracy?: number
|
||||
learning_rate: number
|
||||
}
|
||||
history: TrainingDataPoint[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface TrainingMetricsProps {
|
||||
/** API base URL */
|
||||
apiBase: string
|
||||
/** Job ID to track (null for simulation mode) */
|
||||
jobId?: string | null
|
||||
/** Simulate training progress for demo */
|
||||
simulateMode?: boolean
|
||||
/** Custom class names */
|
||||
className?: string
|
||||
/** Callback when training completes */
|
||||
onComplete?: (status: TrainingStatus) => void
|
||||
/** Callback on error */
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for SSE-based training metrics
|
||||
*/
|
||||
export function useTrainingMetricsSSE(
|
||||
apiBase: string,
|
||||
jobId: string | null,
|
||||
onUpdate?: (status: TrainingStatus) => void
|
||||
) {
|
||||
const [status, setStatus] = useState<TrainingStatus | null>(null)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobId) return
|
||||
|
||||
const url = `${apiBase}/api/klausur/trocr/training/metrics/stream?job_id=${jobId}`
|
||||
const eventSource = new EventSource(url)
|
||||
eventSourceRef.current = eventSource
|
||||
|
||||
eventSource.onopen = () => {
|
||||
setConnected(true)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as TrainingStatus
|
||||
setStatus(data)
|
||||
onUpdate?.(data)
|
||||
|
||||
// Close connection when training is done
|
||||
if (data.status === 'completed' || data.status === 'failed' || data.status === 'cancelled') {
|
||||
eventSource.close()
|
||||
setConnected(false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE message:', e)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
setError('Verbindung zum Server verloren')
|
||||
setConnected(false)
|
||||
eventSource.close()
|
||||
}
|
||||
|
||||
return () => {
|
||||
eventSource.close()
|
||||
setConnected(false)
|
||||
}
|
||||
}, [apiBase, jobId, onUpdate])
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
eventSourceRef.current?.close()
|
||||
setConnected(false)
|
||||
}, [])
|
||||
|
||||
return { status, connected, error, disconnect }
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple line chart component for loss visualization
|
||||
*/
|
||||
function LossChart({
|
||||
data,
|
||||
height = 200,
|
||||
className = ''
|
||||
}: {
|
||||
data: TrainingDataPoint[]
|
||||
height?: number
|
||||
className?: string
|
||||
}) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center bg-slate-50 rounded-lg ${className}`} style={{ height }}>
|
||||
<span className="text-slate-400 text-sm">Keine Daten</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate bounds
|
||||
const losses = data.map(d => d.loss)
|
||||
const valLosses = data.filter(d => d.val_loss !== undefined).map(d => d.val_loss!)
|
||||
const allLosses = [...losses, ...valLosses]
|
||||
const minLoss = Math.min(...allLosses) * 0.9
|
||||
const maxLoss = Math.max(...allLosses) * 1.1
|
||||
const lossRange = maxLoss - minLoss || 1
|
||||
|
||||
// SVG dimensions
|
||||
const width = 400
|
||||
const padding = { top: 20, right: 20, bottom: 30, left: 50 }
|
||||
const chartWidth = width - padding.left - padding.right
|
||||
const chartHeight = height - padding.top - padding.bottom
|
||||
|
||||
// Generate path for loss line
|
||||
const generatePath = (values: number[]) => {
|
||||
if (values.length === 0) return ''
|
||||
return values
|
||||
.map((loss, idx) => {
|
||||
const x = padding.left + (idx / (values.length - 1 || 1)) * chartWidth
|
||||
const y = padding.top + chartHeight - ((loss - minLoss) / lossRange) * chartHeight
|
||||
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`
|
||||
})
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const lossPath = generatePath(losses)
|
||||
const valLossPath = valLosses.length > 0 ? generatePath(valLosses) : ''
|
||||
|
||||
// Y-axis labels
|
||||
const yLabels = [maxLoss, (maxLoss + minLoss) / 2, minLoss].map(v => v.toFixed(3))
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className={`w-full ${className}`} preserveAspectRatio="xMidYMid meet">
|
||||
{/* Grid lines */}
|
||||
{[0, 0.5, 1].map((ratio, idx) => (
|
||||
<line
|
||||
key={idx}
|
||||
x1={padding.left}
|
||||
y1={padding.top + chartHeight * ratio}
|
||||
x2={width - padding.right}
|
||||
y2={padding.top + chartHeight * ratio}
|
||||
stroke="#e2e8f0"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Y-axis labels */}
|
||||
{yLabels.map((label, idx) => (
|
||||
<text
|
||||
key={idx}
|
||||
x={padding.left - 5}
|
||||
y={padding.top + (idx / 2) * chartHeight + 4}
|
||||
textAnchor="end"
|
||||
className="fill-slate-500 text-xs"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* X-axis label */}
|
||||
<text
|
||||
x={width / 2}
|
||||
y={height - 5}
|
||||
textAnchor="middle"
|
||||
className="fill-slate-500 text-xs"
|
||||
>
|
||||
Epoche / Schritt
|
||||
</text>
|
||||
|
||||
{/* Y-axis label */}
|
||||
<text
|
||||
x={12}
|
||||
y={height / 2}
|
||||
textAnchor="middle"
|
||||
className="fill-slate-500 text-xs"
|
||||
transform={`rotate(-90, 12, ${height / 2})`}
|
||||
>
|
||||
Loss
|
||||
</text>
|
||||
|
||||
{/* Training loss line */}
|
||||
<path
|
||||
d={lossPath}
|
||||
fill="none"
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* Validation loss line (dashed) */}
|
||||
{valLossPath && (
|
||||
<path
|
||||
d={valLossPath}
|
||||
fill="none"
|
||||
stroke="#22c55e"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="5,5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Data points */}
|
||||
{data.map((point, idx) => {
|
||||
const x = padding.left + (idx / (data.length - 1 || 1)) * chartWidth
|
||||
const y = padding.top + chartHeight - ((point.loss - minLoss) / lossRange) * chartHeight
|
||||
return (
|
||||
<circle
|
||||
key={idx}
|
||||
cx={x}
|
||||
cy={y}
|
||||
r="3"
|
||||
fill="#8b5cf6"
|
||||
className="hover:r-4 transition-all"
|
||||
>
|
||||
<title>Epoch {point.epoch}, Step {point.step}: {point.loss.toFixed(4)}</title>
|
||||
</circle>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Legend */}
|
||||
<g transform={`translate(${padding.left}, ${height - 25})`}>
|
||||
<line x1="0" y1="0" x2="20" y2="0" stroke="#8b5cf6" strokeWidth="2" />
|
||||
<text x="25" y="4" className="fill-slate-600 text-xs">Training Loss</text>
|
||||
|
||||
{valLossPath && (
|
||||
<>
|
||||
<line x1="120" y1="0" x2="140" y2="0" stroke="#22c55e" strokeWidth="2" strokeDasharray="5,5" />
|
||||
<text x="145" y="4" className="fill-slate-600 text-xs">Val Loss</text>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress ring component
|
||||
*/
|
||||
function ProgressRing({
|
||||
progress,
|
||||
size = 80,
|
||||
strokeWidth = 6,
|
||||
className = ''
|
||||
}: {
|
||||
progress: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
className?: string
|
||||
}) {
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const offset = circumference - (progress / 100) * circumference
|
||||
|
||||
return (
|
||||
<svg width={size} height={size} className={className}>
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#e2e8f0"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="#8b5cf6"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
className="transition-all duration-300"
|
||||
/>
|
||||
{/* Center text */}
|
||||
<text
|
||||
x={size / 2}
|
||||
y={size / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
className="fill-slate-900 font-bold text-lg"
|
||||
>
|
||||
{progress.toFixed(0)}%
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time duration
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remainingMinutes = minutes % 60
|
||||
return `${hours}h ${remainingMinutes}m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Training Metrics Component - Full Dashboard
|
||||
*/
|
||||
export function TrainingMetrics({
|
||||
apiBase,
|
||||
jobId = null,
|
||||
simulateMode = false,
|
||||
className = '',
|
||||
onComplete,
|
||||
onError
|
||||
}: TrainingMetricsProps) {
|
||||
const [status, setStatus] = useState<TrainingStatus | null>(null)
|
||||
const [simulationInterval, setSimulationInterval] = useState<NodeJS.Timeout | null>(null)
|
||||
|
||||
// SSE hook for real connection
|
||||
const { status: sseStatus, connected, error } = useTrainingMetricsSSE(
|
||||
apiBase,
|
||||
simulateMode ? null : jobId,
|
||||
(newStatus) => {
|
||||
if (newStatus.status === 'completed') {
|
||||
onComplete?.(newStatus)
|
||||
} else if (newStatus.status === 'failed' && newStatus.error) {
|
||||
onError?.(newStatus.error)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Use SSE status if available
|
||||
useEffect(() => {
|
||||
if (sseStatus) {
|
||||
setStatus(sseStatus)
|
||||
}
|
||||
}, [sseStatus])
|
||||
|
||||
// Simulation mode for demo
|
||||
useEffect(() => {
|
||||
if (!simulateMode) return
|
||||
|
||||
let step = 0
|
||||
const totalSteps = 100
|
||||
const history: TrainingDataPoint[] = []
|
||||
|
||||
const interval = setInterval(() => {
|
||||
step++
|
||||
const epoch = Math.floor((step / totalSteps) * 3) + 1
|
||||
const progress = (step / totalSteps) * 100
|
||||
|
||||
// Simulate decreasing loss with noise
|
||||
const baseLoss = 2.5 * Math.exp(-step / 30) + 0.1
|
||||
const noise = (Math.random() - 0.5) * 0.1
|
||||
const loss = Math.max(0.05, baseLoss + noise)
|
||||
|
||||
const dataPoint: TrainingDataPoint = {
|
||||
epoch,
|
||||
step,
|
||||
loss,
|
||||
val_loss: step % 10 === 0 ? loss * (1 + Math.random() * 0.2) : undefined,
|
||||
learning_rate: 0.00005 * Math.pow(0.95, epoch - 1),
|
||||
timestamp: Date.now()
|
||||
}
|
||||
history.push(dataPoint)
|
||||
|
||||
const newStatus: TrainingStatus = {
|
||||
job_id: 'simulation',
|
||||
status: step >= totalSteps ? 'completed' : 'running',
|
||||
progress,
|
||||
current_epoch: epoch,
|
||||
total_epochs: 3,
|
||||
current_step: step,
|
||||
total_steps: totalSteps,
|
||||
elapsed_time_ms: step * 500,
|
||||
estimated_remaining_ms: (totalSteps - step) * 500,
|
||||
metrics: {
|
||||
loss,
|
||||
val_loss: dataPoint.val_loss,
|
||||
accuracy: 0.7 + (step / totalSteps) * 0.25,
|
||||
learning_rate: dataPoint.learning_rate
|
||||
},
|
||||
history: [...history]
|
||||
}
|
||||
|
||||
setStatus(newStatus)
|
||||
|
||||
if (step >= totalSteps) {
|
||||
clearInterval(interval)
|
||||
onComplete?.(newStatus)
|
||||
}
|
||||
}, 500)
|
||||
|
||||
setSimulationInterval(interval)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [simulateMode, onComplete])
|
||||
|
||||
// Stop simulation
|
||||
const stopSimulation = () => {
|
||||
if (simulationInterval) {
|
||||
clearInterval(simulationInterval)
|
||||
setSimulationInterval(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`bg-red-50 border border-red-200 rounded-xl p-6 ${className}`}>
|
||||
<div className="flex items-center gap-3 text-red-700">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<div className={`bg-slate-50 rounded-xl p-6 ${className}`}>
|
||||
<div className="flex items-center justify-center gap-3 text-slate-500">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-purple-600"></div>
|
||||
<span>Warte auf Training-Daten...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl shadow-sm border ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Training Dashboard</h3>
|
||||
<div className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
|
||||
status.status === 'running' ? 'bg-blue-100 text-blue-700' :
|
||||
status.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||
status.status === 'failed' ? 'bg-red-100 text-red-700' :
|
||||
'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{status.status === 'running' && (
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
|
||||
)}
|
||||
{status.status === 'running' ? 'Laeuft' :
|
||||
status.status === 'completed' ? 'Abgeschlossen' :
|
||||
status.status === 'failed' ? 'Fehlgeschlagen' :
|
||||
status.status}
|
||||
</div>
|
||||
{connected && (
|
||||
<span className="text-xs text-green-600 flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500"></span>
|
||||
Live
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{simulateMode && status.status === 'running' && (
|
||||
<button
|
||||
onClick={stopSimulation}
|
||||
className="px-3 py-1 text-sm bg-slate-200 hover:bg-slate-300 text-slate-700 rounded transition-colors"
|
||||
>
|
||||
Stoppen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="p-4 grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Progress section */}
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
<ProgressRing progress={status.progress} size={120} strokeWidth={8} />
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-slate-500">
|
||||
Epoche {status.current_epoch} / {status.total_epochs}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
Schritt {status.current_step} / {status.total_steps}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loss chart */}
|
||||
<div className="lg:col-span-2">
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-2">Loss-Verlauf</h4>
|
||||
<LossChart data={status.history} height={180} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics grid */}
|
||||
<div className="px-4 pb-4 grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-slate-900">{status.metrics.loss.toFixed(4)}</div>
|
||||
<div className="text-xs text-slate-500">Aktueller Loss</div>
|
||||
</div>
|
||||
{status.metrics.val_loss !== undefined && (
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-slate-900">{status.metrics.val_loss.toFixed(4)}</div>
|
||||
<div className="text-xs text-slate-500">Validation Loss</div>
|
||||
</div>
|
||||
)}
|
||||
{status.metrics.accuracy !== undefined && (
|
||||
<div className="bg-green-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-green-700">{(status.metrics.accuracy * 100).toFixed(1)}%</div>
|
||||
<div className="text-xs text-slate-500">Genauigkeit</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-slate-50 rounded-lg p-3 text-center">
|
||||
<div className="text-lg font-bold text-slate-900">{status.metrics.learning_rate.toExponential(1)}</div>
|
||||
<div className="text-xs text-slate-500">Learning Rate</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time info */}
|
||||
<div className="px-4 pb-4 flex items-center justify-between text-sm text-slate-500">
|
||||
<div>Vergangen: {formatDuration(status.elapsed_time_ms)}</div>
|
||||
{status.status === 'running' && (
|
||||
<div>Geschaetzt: {formatDuration(status.estimated_remaining_ms)} verbleibend</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact Training Metrics for inline display
|
||||
*/
|
||||
export function TrainingMetricsCompact({
|
||||
progress,
|
||||
currentEpoch,
|
||||
totalEpochs,
|
||||
loss,
|
||||
status,
|
||||
className = ''
|
||||
}: {
|
||||
progress: number
|
||||
currentEpoch: number
|
||||
totalEpochs: number
|
||||
loss: number
|
||||
status: 'running' | 'completed' | 'failed'
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={`flex items-center gap-4 ${className}`}>
|
||||
<ProgressRing progress={progress} size={48} strokeWidth={4} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-slate-900">Epoche {currentEpoch}/{totalEpochs}</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
status === 'running' ? 'bg-blue-100 text-blue-700' :
|
||||
status === 'completed' ? 'bg-green-100 text-green-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{status === 'running' ? 'Laeuft' : status === 'completed' ? 'Fertig' : 'Fehler'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
Loss: {loss.toFixed(4)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TrainingMetrics
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* AI Components Index
|
||||
*
|
||||
* Exports für alle AI-spezifischen Komponenten
|
||||
*/
|
||||
|
||||
export { AIModuleSidebar, AIModuleNav, AIModuleSidebarResponsive } from './AIModuleSidebar'
|
||||
export type { AIModuleSidebarProps, AIModuleSidebarResponsiveProps } from './AIModuleSidebar'
|
||||
|
||||
// Magic Help Components
|
||||
export { ConfidenceHeatmap, InlineConfidenceText, ConfidenceStats } from './ConfidenceHeatmap'
|
||||
export { TrainingMetrics, TrainingMetricsCompact, useTrainingMetricsSSE } from './TrainingMetrics'
|
||||
export { BatchUploader } from './BatchUploader'
|
||||
@@ -0,0 +1,392 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import type {
|
||||
CatalogMeta,
|
||||
CatalogEntry,
|
||||
CatalogFieldSchema,
|
||||
} from '@/lib/sdk/catalog-manager/types'
|
||||
|
||||
interface CatalogEntryFormProps {
|
||||
catalog: CatalogMeta
|
||||
entry?: CatalogEntry | null
|
||||
onSave: (data: Record<string, unknown>) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export default function CatalogEntryForm({
|
||||
catalog,
|
||||
entry,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: CatalogEntryFormProps) {
|
||||
const isEditMode = entry !== null && entry !== undefined
|
||||
const isSystemEntry = isEditMode && entry?.source === 'system'
|
||||
const isCreateMode = !isEditMode
|
||||
|
||||
const title = isSystemEntry
|
||||
? 'Details'
|
||||
: isEditMode
|
||||
? 'Eintrag bearbeiten'
|
||||
: 'Neuer Eintrag'
|
||||
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>({})
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
// Initialize form data
|
||||
useEffect(() => {
|
||||
if (isEditMode && entry) {
|
||||
const initialData: Record<string, unknown> = {}
|
||||
for (const field of catalog.fields) {
|
||||
initialData[field.key] = entry.data?.[field.key] ?? getDefaultValue(field)
|
||||
}
|
||||
setFormData(initialData)
|
||||
} else {
|
||||
const initialData: Record<string, unknown> = {}
|
||||
for (const field of catalog.fields) {
|
||||
initialData[field.key] = getDefaultValue(field)
|
||||
}
|
||||
setFormData(initialData)
|
||||
}
|
||||
}, [entry, catalog.fields, isEditMode])
|
||||
|
||||
function getDefaultValue(field: CatalogFieldSchema): unknown {
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
return ''
|
||||
case 'number':
|
||||
return field.min ?? 0
|
||||
case 'select':
|
||||
return ''
|
||||
case 'multiselect':
|
||||
return []
|
||||
case 'boolean':
|
||||
return false
|
||||
case 'tags':
|
||||
return ''
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
(key: string, value: unknown) => {
|
||||
setFormData(prev => ({ ...prev, [key]: value }))
|
||||
// Clear error on change
|
||||
if (errors[key]) {
|
||||
setErrors(prev => {
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
})
|
||||
}
|
||||
},
|
||||
[errors]
|
||||
)
|
||||
|
||||
const handleMultiselectToggle = useCallback(
|
||||
(key: string, option: string) => {
|
||||
setFormData(prev => {
|
||||
const current = (prev[key] as string[]) || []
|
||||
const updated = current.includes(option)
|
||||
? current.filter(v => v !== option)
|
||||
: [...current, option]
|
||||
return { ...prev, [key]: updated }
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const validate = (): boolean => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
for (const field of catalog.fields) {
|
||||
if (field.required) {
|
||||
const value = formData[field.key]
|
||||
if (value === undefined || value === null || value === '') {
|
||||
newErrors[field.key] = `${field.label} ist ein Pflichtfeld`
|
||||
} else if (Array.isArray(value) && value.length === 0) {
|
||||
newErrors[field.key] = `Mindestens ein Wert erforderlich`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (isSystemEntry) return
|
||||
|
||||
if (validate()) {
|
||||
// Convert tags string to array before saving
|
||||
const processedData = { ...formData }
|
||||
for (const field of catalog.fields) {
|
||||
if (field.type === 'tags' && typeof processedData[field.key] === 'string') {
|
||||
processedData[field.key] = (processedData[field.key] as string)
|
||||
.split(',')
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
onSave(processedData)
|
||||
}
|
||||
}
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel()
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onCancel])
|
||||
|
||||
const renderField = (field: CatalogFieldSchema) => {
|
||||
const value = formData[field.key]
|
||||
const hasError = !!errors[field.key]
|
||||
const isDisabled = isSystemEntry
|
||||
|
||||
const baseInputClasses = `w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent transition-colors ${
|
||||
hasError
|
||||
? 'border-red-400 dark:border-red-500'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
} ${isDisabled ? 'opacity-60 cursor-not-allowed bg-gray-50 dark:bg-gray-800' : ''}`
|
||||
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={(value as string) || ''}
|
||||
onChange={e => handleFieldChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder || ''}
|
||||
disabled={isDisabled}
|
||||
className={baseInputClasses}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<textarea
|
||||
value={(value as string) || ''}
|
||||
onChange={e => handleFieldChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder || ''}
|
||||
disabled={isDisabled}
|
||||
rows={3}
|
||||
className={`${baseInputClasses} resize-y`}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={(value as number) ?? ''}
|
||||
onChange={e =>
|
||||
handleFieldChange(
|
||||
field.key,
|
||||
e.target.value === '' ? '' : Number(e.target.value)
|
||||
)
|
||||
}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step ?? 1}
|
||||
disabled={isDisabled}
|
||||
className={baseInputClasses}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<select
|
||||
value={(value as string) || ''}
|
||||
onChange={e => handleFieldChange(field.key, e.target.value)}
|
||||
disabled={isDisabled}
|
||||
className={baseInputClasses}
|
||||
>
|
||||
<option value="">-- Auswaehlen --</option>
|
||||
{(field.options || []).map(opt => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
|
||||
case 'multiselect':
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{(field.options || []).map(opt => {
|
||||
const checked = ((value as string[]) || []).includes(opt.value)
|
||||
return (
|
||||
<label
|
||||
key={opt.value}
|
||||
className={`flex items-center gap-2 text-sm cursor-pointer ${
|
||||
isDisabled ? 'opacity-60 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => handleMultiselectToggle(field.key, opt.value)}
|
||||
disabled={isDisabled}
|
||||
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-violet-600 focus:ring-violet-500"
|
||||
/>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{opt.label}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<label
|
||||
className={`flex items-center gap-3 cursor-pointer ${
|
||||
isDisabled ? 'opacity-60 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={!!value}
|
||||
disabled={isDisabled}
|
||||
onClick={() => !isDisabled && handleFieldChange(field.key, !value)}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 ${
|
||||
value
|
||||
? 'bg-violet-600'
|
||||
: 'bg-gray-200 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition-transform ${
|
||||
value ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||||
{value ? 'Ja' : 'Nein'}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
|
||||
case 'tags':
|
||||
return (
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={
|
||||
Array.isArray(value)
|
||||
? (value as string[]).join(', ')
|
||||
: (value as string) || ''
|
||||
}
|
||||
onChange={e => handleFieldChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder || 'Komma-getrennte Tags eingeben...'}
|
||||
disabled={isDisabled}
|
||||
className={baseInputClasses}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
Mehrere Tags durch Komma trennen
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
// Backdrop
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget) onCancel()
|
||||
}}
|
||||
>
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-lg max-h-[90vh] bg-white dark:bg-gray-800 rounded-xl shadow-2xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 shrink-0">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{catalog.name}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
title="Schliessen"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
|
||||
<div className="px-6 py-4 space-y-5">
|
||||
{catalog.fields.map(field => (
|
||||
<div key={field.key}>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
{field.label}
|
||||
{field.required && !isSystemEntry && (
|
||||
<span className="text-red-500 ml-0.5">*</span>
|
||||
)}
|
||||
</label>
|
||||
{field.description && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mb-1.5">
|
||||
{field.description}
|
||||
</p>
|
||||
)}
|
||||
{renderField(field)}
|
||||
{errors[field.key] && (
|
||||
<p className="mt-1 text-xs text-red-500 dark:text-red-400">
|
||||
{errors[field.key]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 shrink-0">
|
||||
{isSystemEntry ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-violet-600 hover:bg-violet-700 rounded-lg transition-colors"
|
||||
>
|
||||
{isCreateMode ? 'Erstellen' : 'Speichern'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import {
|
||||
Database,
|
||||
Search,
|
||||
Plus,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
BarChart3,
|
||||
Layers,
|
||||
Users,
|
||||
Settings,
|
||||
} from 'lucide-react'
|
||||
import type {
|
||||
CatalogId,
|
||||
CatalogModule,
|
||||
CatalogEntry,
|
||||
CatalogMeta,
|
||||
CustomCatalogEntry,
|
||||
} from '@/lib/sdk/catalog-manager/types'
|
||||
import { CATALOG_MODULE_LABELS } from '@/lib/sdk/catalog-manager/types'
|
||||
import {
|
||||
CATALOG_REGISTRY,
|
||||
getAllEntries,
|
||||
getCatalogsByModule,
|
||||
getOverviewStats,
|
||||
searchCatalog,
|
||||
} from '@/lib/sdk/catalog-manager/catalog-registry'
|
||||
import CatalogModuleTabs from '@/components/catalog-manager/CatalogModuleTabs'
|
||||
import CatalogTable from '@/components/catalog-manager/CatalogTable'
|
||||
import CatalogEntryForm from '@/components/catalog-manager/CatalogEntryForm'
|
||||
|
||||
// =============================================================================
|
||||
// STAT CARD COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
interface StatCardProps {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
value: number
|
||||
color: 'violet' | 'blue' | 'emerald' | 'amber'
|
||||
}
|
||||
|
||||
const colorMap = {
|
||||
violet: {
|
||||
bg: 'bg-violet-50 dark:bg-violet-900/20',
|
||||
text: 'text-violet-600 dark:text-violet-400',
|
||||
},
|
||||
blue: {
|
||||
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
text: 'text-blue-600 dark:text-blue-400',
|
||||
},
|
||||
emerald: {
|
||||
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
|
||||
text: 'text-emerald-600 dark:text-emerald-400',
|
||||
},
|
||||
amber: {
|
||||
bg: 'bg-amber-50 dark:bg-amber-900/20',
|
||||
text: 'text-amber-600 dark:text-amber-400',
|
||||
},
|
||||
}
|
||||
|
||||
function StatCard({ icon, label, value, color }: StatCardProps) {
|
||||
const colors = colorMap[color]
|
||||
return (
|
||||
<div className={`flex items-center gap-3 px-4 py-3 rounded-xl ${colors.bg}`}>
|
||||
<div className={colors.text}>{icon}</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{label}</p>
|
||||
<p className={`text-lg font-bold ${colors.text}`}>{value.toLocaleString('de-DE')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN CONTENT COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export function CatalogManagerContent() {
|
||||
const { state, dispatch } = useSDK()
|
||||
const customCatalogs = state.customCatalogs ?? {}
|
||||
|
||||
// UI State
|
||||
const [activeModule, setActiveModule] = useState<CatalogModule | 'all'>('all')
|
||||
const [selectedCatalogId, setSelectedCatalogId] = useState<CatalogId | null>(null)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [expandedCatalogs, setExpandedCatalogs] = useState<Set<CatalogId>>(new Set())
|
||||
const [formState, setFormState] = useState<{
|
||||
open: boolean
|
||||
catalog: CatalogMeta | null
|
||||
entry: CatalogEntry | null
|
||||
}>({ open: false, catalog: null, entry: null })
|
||||
|
||||
// Computed data
|
||||
const overviewStats = useMemo(() => getOverviewStats(customCatalogs), [customCatalogs])
|
||||
|
||||
const visibleCatalogs = useMemo(() => {
|
||||
if (activeModule === 'all') {
|
||||
return Object.values(CATALOG_REGISTRY)
|
||||
}
|
||||
return getCatalogsByModule(activeModule)
|
||||
}, [activeModule])
|
||||
|
||||
const selectedCatalog = selectedCatalogId ? CATALOG_REGISTRY[selectedCatalogId] : null
|
||||
|
||||
const catalogEntries = useMemo(() => {
|
||||
if (!selectedCatalogId) return []
|
||||
const custom = customCatalogs[selectedCatalogId] || []
|
||||
if (searchQuery.trim()) {
|
||||
return searchCatalog(selectedCatalogId, searchQuery, custom)
|
||||
}
|
||||
return getAllEntries(selectedCatalogId, custom)
|
||||
}, [selectedCatalogId, customCatalogs, searchQuery])
|
||||
|
||||
// Handlers
|
||||
const toggleCatalogExpand = useCallback((id: CatalogId) => {
|
||||
setExpandedCatalogs(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSelectCatalog = useCallback((id: CatalogId) => {
|
||||
setSelectedCatalogId(id)
|
||||
setSearchQuery('')
|
||||
}, [])
|
||||
|
||||
const handleAddEntry = useCallback((catalog: CatalogMeta) => {
|
||||
setFormState({ open: true, catalog, entry: null })
|
||||
}, [])
|
||||
|
||||
const handleEditEntry = useCallback((catalog: CatalogMeta, entry: CatalogEntry) => {
|
||||
setFormState({ open: true, catalog, entry })
|
||||
}, [])
|
||||
|
||||
const handleCloseForm = useCallback(() => {
|
||||
setFormState({ open: false, catalog: null, entry: null })
|
||||
}, [])
|
||||
|
||||
const handleSaveEntry = useCallback((data: Record<string, unknown>) => {
|
||||
if (!formState.catalog) return
|
||||
|
||||
if (formState.entry && formState.entry.source === 'custom') {
|
||||
// Update existing custom entry
|
||||
dispatch({
|
||||
type: 'UPDATE_CUSTOM_CATALOG_ENTRY',
|
||||
payload: {
|
||||
catalogId: formState.catalog.id,
|
||||
entryId: formState.entry.id,
|
||||
data,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// Create new custom entry
|
||||
const newEntry: CustomCatalogEntry = {
|
||||
id: crypto.randomUUID(),
|
||||
catalogId: formState.catalog.id,
|
||||
data,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
dispatch({
|
||||
type: 'ADD_CUSTOM_CATALOG_ENTRY',
|
||||
payload: newEntry,
|
||||
})
|
||||
}
|
||||
|
||||
handleCloseForm()
|
||||
}, [formState, dispatch, handleCloseForm])
|
||||
|
||||
const handleDeleteEntry = useCallback((catalogId: CatalogId, entryId: string) => {
|
||||
dispatch({
|
||||
type: 'DELETE_CUSTOM_CATALOG_ENTRY',
|
||||
payload: { catalogId, entryId },
|
||||
})
|
||||
}, [dispatch])
|
||||
|
||||
// =============================================================================
|
||||
// RENDER
|
||||
// =============================================================================
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="max-w-7xl mx-auto px-6 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-violet-100 dark:bg-violet-900/30 rounded-xl">
|
||||
<Database className="h-6 w-6 text-violet-600 dark:text-violet-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Katalogverwaltung
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
Alle SDK-Kataloge und Auswahltabellen zentral verwalten
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Bar */}
|
||||
<div className="grid grid-cols-4 gap-4 mt-6">
|
||||
<StatCard
|
||||
icon={<Layers className="h-5 w-5" />}
|
||||
label="Kataloge"
|
||||
value={overviewStats.totalCatalogs}
|
||||
color="violet"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Database className="h-5 w-5" />}
|
||||
label="System-Eintraege"
|
||||
value={overviewStats.totalSystemEntries}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
label="Eigene Eintraege"
|
||||
value={overviewStats.totalCustomEntries}
|
||||
color="emerald"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<BarChart3 className="h-5 w-5" />}
|
||||
label="Gesamt"
|
||||
value={overviewStats.totalEntries}
|
||||
color="amber"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Module Tabs */}
|
||||
<div className="max-w-7xl mx-auto px-6 mt-6">
|
||||
<CatalogModuleTabs
|
||||
activeModule={activeModule}
|
||||
onModuleChange={setActiveModule}
|
||||
stats={overviewStats}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="max-w-7xl mx-auto px-6 py-6">
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
{/* Left: Catalog List */}
|
||||
<div className="col-span-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
{activeModule === 'all'
|
||||
? 'Alle Kataloge'
|
||||
: CATALOG_MODULE_LABELS[activeModule]}
|
||||
<span className="ml-2 text-gray-400 font-normal">
|
||||
({visibleCatalogs.length})
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-100 dark:divide-gray-700/50 max-h-[calc(100vh-400px)] overflow-y-auto">
|
||||
{visibleCatalogs.map(catalog => {
|
||||
const customCount = customCatalogs[catalog.id]?.length ?? 0
|
||||
const isSelected = selectedCatalogId === catalog.id
|
||||
|
||||
return (
|
||||
<button
|
||||
key={catalog.id}
|
||||
onClick={() => handleSelectCatalog(catalog.id)}
|
||||
className={`w-full text-left px-4 py-3 transition-colors ${
|
||||
isSelected
|
||||
? 'bg-violet-50 dark:bg-violet-900/20 border-l-3 border-l-violet-600'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className={`text-sm font-medium truncate ${
|
||||
isSelected
|
||||
? 'text-violet-700 dark:text-violet-300'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
}`}>
|
||||
{catalog.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
|
||||
{catalog.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-2 shrink-0">
|
||||
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
||||
{catalog.systemCount}
|
||||
</span>
|
||||
{customCount > 0 && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
|
||||
+{customCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Catalog Detail / Table */}
|
||||
<div className="col-span-8">
|
||||
{selectedCatalog ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
{/* Catalog Header */}
|
||||
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{selectedCatalog.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{selectedCatalog.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{CATALOG_MODULE_LABELS[selectedCatalog.module]}
|
||||
</span>
|
||||
{selectedCatalog.allowCustom && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400">
|
||||
Erweiterbar
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<CatalogTable
|
||||
catalog={selectedCatalog}
|
||||
entries={catalogEntries}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
onEditCustomEntry={(entry) => handleEditEntry(selectedCatalog, entry)}
|
||||
onDeleteCustomEntry={(entryId) => handleDeleteEntry(selectedCatalog.id, entryId)}
|
||||
onAddEntry={() => handleAddEntry(selectedCatalog)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* Empty State */
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border border-gray-200 dark:border-gray-700 p-12">
|
||||
<div className="text-center">
|
||||
<Settings className="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
Katalog auswaehlen
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
|
||||
Waehlen Sie einen Katalog aus der Liste links, um dessen Eintraege anzuzeigen und zu verwalten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Modal */}
|
||||
{formState.open && formState.catalog && (
|
||||
<CatalogEntryForm
|
||||
catalog={formState.catalog}
|
||||
entry={formState.entry}
|
||||
onSave={handleSaveEntry}
|
||||
onCancel={handleCloseForm}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useEffect } from 'react'
|
||||
import {
|
||||
ShieldAlert,
|
||||
FileText,
|
||||
Building2,
|
||||
Bot,
|
||||
BookOpen,
|
||||
Database,
|
||||
} from 'lucide-react'
|
||||
import type {
|
||||
CatalogModule,
|
||||
CatalogOverviewStats,
|
||||
} from '@/lib/sdk/catalog-manager/types'
|
||||
import { CATALOG_MODULE_LABELS } from '@/lib/sdk/catalog-manager/types'
|
||||
|
||||
interface CatalogModuleTabsProps {
|
||||
activeModule: CatalogModule | 'all'
|
||||
onModuleChange: (module: CatalogModule | 'all') => void
|
||||
stats: CatalogOverviewStats
|
||||
}
|
||||
|
||||
const MODULE_ICON_MAP: Record<CatalogModule, React.ComponentType<{ className?: string }>> = {
|
||||
dsfa: ShieldAlert,
|
||||
vvt: FileText,
|
||||
vendor: Building2,
|
||||
ai_act: Bot,
|
||||
reference: BookOpen,
|
||||
}
|
||||
|
||||
interface TabDefinition {
|
||||
key: CatalogModule | 'all'
|
||||
label: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
export default function CatalogModuleTabs({
|
||||
activeModule,
|
||||
onModuleChange,
|
||||
stats,
|
||||
}: CatalogModuleTabsProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const activeTabRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// Scroll active tab into view on mount and when active changes
|
||||
useEffect(() => {
|
||||
if (activeTabRef.current && scrollRef.current) {
|
||||
const container = scrollRef.current
|
||||
const tab = activeTabRef.current
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const tabRect = tab.getBoundingClientRect()
|
||||
|
||||
if (tabRect.left < containerRect.left || tabRect.right > containerRect.right) {
|
||||
tab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
|
||||
}
|
||||
}
|
||||
}, [activeModule])
|
||||
|
||||
const tabs: TabDefinition[] = [
|
||||
{ key: 'all', label: 'Alle', icon: Database },
|
||||
...Object.entries(CATALOG_MODULE_LABELS).map(([key, label]) => ({
|
||||
key: key as CatalogModule,
|
||||
label,
|
||||
icon: MODULE_ICON_MAP[key as CatalogModule],
|
||||
})),
|
||||
]
|
||||
|
||||
const getCount = (key: CatalogModule | 'all'): number => {
|
||||
if (key === 'all') {
|
||||
return stats.totalEntries
|
||||
}
|
||||
return stats.byModule?.[key]?.entries ?? 0
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex overflow-x-auto scrollbar-hide border-b border-gray-200 dark:border-gray-700"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{tabs.map(tab => {
|
||||
const isActive = activeModule === tab.key
|
||||
const Icon = tab.icon
|
||||
const count = getCount(tab.key)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
ref={isActive ? activeTabRef : undefined}
|
||||
onClick={() => onModuleChange(tab.key)}
|
||||
className={`relative flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap transition-colors shrink-0 ${
|
||||
isActive
|
||||
? 'text-violet-700 dark:text-violet-400 font-semibold'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span>{tab.label}</span>
|
||||
<span
|
||||
className={`inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-medium rounded-full ${
|
||||
isActive
|
||||
? 'bg-violet-100 dark:bg-violet-900/40 text-violet-700 dark:text-violet-300'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
|
||||
{/* Active indicator line */}
|
||||
{isActive && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-violet-600 dark:bg-violet-400 rounded-t" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Eye,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Database,
|
||||
} from 'lucide-react'
|
||||
import type { CatalogMeta, CatalogEntry } from '@/lib/sdk/catalog-manager/types'
|
||||
|
||||
interface CatalogTableProps {
|
||||
catalog: CatalogMeta
|
||||
entries: CatalogEntry[]
|
||||
searchQuery: string
|
||||
onSearchChange: (query: string) => void
|
||||
onEditCustomEntry: (entry: CatalogEntry) => void
|
||||
onDeleteCustomEntry: (entryId: string) => void
|
||||
onAddEntry: () => void
|
||||
}
|
||||
|
||||
type SortField = 'id' | 'name' | 'category' | 'type'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
|
||||
export default function CatalogTable({
|
||||
catalog,
|
||||
entries,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
onEditCustomEntry,
|
||||
onDeleteCustomEntry,
|
||||
onAddEntry,
|
||||
}: CatalogTableProps) {
|
||||
const [sortField, setSortField] = useState<SortField>('name')
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(prev => (prev === 'asc' ? 'desc' : 'asc'))
|
||||
} else {
|
||||
setSortField(field)
|
||||
setSortDirection('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const sortedEntries = useMemo(() => {
|
||||
const sorted = [...entries].sort((a, b) => {
|
||||
let aVal = ''
|
||||
let bVal = ''
|
||||
|
||||
switch (sortField) {
|
||||
case 'id':
|
||||
aVal = a.id
|
||||
bVal = b.id
|
||||
break
|
||||
case 'name':
|
||||
aVal = a.displayName
|
||||
bVal = b.displayName
|
||||
break
|
||||
case 'category':
|
||||
aVal = a.category || ''
|
||||
bVal = b.category || ''
|
||||
break
|
||||
case 'type':
|
||||
aVal = a.source
|
||||
bVal = b.source
|
||||
break
|
||||
}
|
||||
|
||||
const cmp = aVal.localeCompare(bVal, 'de')
|
||||
return sortDirection === 'asc' ? cmp : -cmp
|
||||
})
|
||||
|
||||
return sorted
|
||||
}, [entries, sortField, sortDirection])
|
||||
|
||||
const SortIcon = ({ field }: { field: SortField }) => {
|
||||
if (sortField !== field) {
|
||||
return (
|
||||
<span className="ml-1 inline-flex flex-col opacity-30">
|
||||
<ChevronUp className="h-3 w-3 -mb-1" />
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return sortDirection === 'asc' ? (
|
||||
<ChevronUp className="ml-1 h-3.5 w-3.5 inline" />
|
||||
) : (
|
||||
<ChevronDown className="ml-1 h-3.5 w-3.5 inline" />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Search & Add Header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
<div className="relative w-full sm:w-80">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
placeholder="Eintraege durchsuchen..."
|
||||
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{catalog.allowCustom && (
|
||||
<button
|
||||
onClick={onAddEntry}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-white bg-violet-600 hover:bg-violet-700 rounded-lg transition-colors shrink-0"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Eintrag hinzufuegen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
|
||||
<th
|
||||
className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200 whitespace-nowrap"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
Name
|
||||
<SortIcon field="name" />
|
||||
</th>
|
||||
{catalog.categoryField && (
|
||||
<th
|
||||
className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200 whitespace-nowrap"
|
||||
onClick={() => handleSort('category')}
|
||||
>
|
||||
Kategorie
|
||||
<SortIcon field="category" />
|
||||
</th>
|
||||
)}
|
||||
<th
|
||||
className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200 whitespace-nowrap"
|
||||
onClick={() => handleSort('type')}
|
||||
>
|
||||
Quelle
|
||||
<SortIcon field="type" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
Aktionen
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedEntries.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={catalog.categoryField ? 4 : 3}
|
||||
className="px-4 py-12 text-center text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Database className="h-8 w-8 opacity-40" />
|
||||
<p className="text-sm">
|
||||
{searchQuery
|
||||
? `Keine Eintraege gefunden fuer "${searchQuery}"`
|
||||
: 'Keine Eintraege vorhanden'}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedEntries.map((entry) => (
|
||||
<tr
|
||||
key={`${entry.source}-${entry.id}`}
|
||||
className="border-b border-gray-100 dark:border-gray-700/50 hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-2.5">
|
||||
<div>
|
||||
<p className="text-gray-900 dark:text-gray-100 font-medium truncate max-w-xs">
|
||||
{entry.displayName}
|
||||
</p>
|
||||
{entry.displayDescription && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-xs mt-0.5">
|
||||
{entry.displayDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
{catalog.categoryField && (
|
||||
<td className="px-4 py-2.5 text-gray-600 dark:text-gray-300">
|
||||
{entry.category || '\u2014'}
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-2.5">
|
||||
{entry.source === 'system' ? (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
|
||||
System
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300">
|
||||
Benutzerdefiniert
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{entry.source === 'system' ? (
|
||||
<button
|
||||
onClick={() => onEditCustomEntry(entry)}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-600 dark:text-gray-400 hover:text-violet-600 dark:hover:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20 rounded transition-colors"
|
||||
title="Details anzeigen"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
Details
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => onEditCustomEntry(entry)}
|
||||
className="inline-flex items-center p-1.5 text-gray-500 dark:text-gray-400 hover:text-violet-600 dark:hover:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20 rounded transition-colors"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteCustomEntry(entry.id)}
|
||||
className="inline-flex items-center p-1.5 text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
|
||||
title="Loeschen"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Footer with count */}
|
||||
{sortedEntries.length > 0 && (
|
||||
<div className="px-4 py-3 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400">
|
||||
{sortedEntries.length} {sortedEntries.length === 1 ? 'Eintrag' : 'Eintraege'}
|
||||
{searchQuery && ` (gefiltert)`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
'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' },
|
||||
education: { name: 'Bildung & Schule', icon: 'graduation', color: 'blue' },
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
'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 | /education | /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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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>
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
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}`}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
'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: 8000, category: 'core' },
|
||||
{ name: 'Consent Service', port: 8081, category: 'core' },
|
||||
{ name: 'Voice Service', port: 8091, category: 'core' },
|
||||
{ name: 'Klausur Service', port: 8086, category: 'core' },
|
||||
{ name: 'Mail Service (Mailpit)', port: 8025, category: 'core' },
|
||||
{ name: 'Edu Search', port: 8088, category: 'core' },
|
||||
{ name: 'H5P Service', port: 8092, category: 'core' },
|
||||
{ name: 'Ollama/LLM', port: 11434, category: 'ai' },
|
||||
{ name: 'Embedding Service', port: 8087, category: 'ai' },
|
||||
{ name: 'PostgreSQL', port: 5432, category: 'database' },
|
||||
{ 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Settings, MessageSquare, HelpCircle, RefreshCw } from 'lucide-react'
|
||||
import { CompanionMode, TeacherSettings, FeedbackType } from '@/lib/companion/types'
|
||||
import { DEFAULT_TEACHER_SETTINGS, STORAGE_KEYS } from '@/lib/companion/constants'
|
||||
|
||||
// Components
|
||||
import { ModeToggle } from './ModeToggle'
|
||||
import { PhaseTimeline } from './companion-mode/PhaseTimeline'
|
||||
import { StatsGrid } from './companion-mode/StatsGrid'
|
||||
import { SuggestionList } from './companion-mode/SuggestionList'
|
||||
import { EventsCard } from './companion-mode/EventsCard'
|
||||
import { LessonContainer } from './lesson-mode/LessonContainer'
|
||||
import { SettingsModal } from './modals/SettingsModal'
|
||||
import { FeedbackModal } from './modals/FeedbackModal'
|
||||
import { OnboardingModal } from './modals/OnboardingModal'
|
||||
|
||||
// Hooks
|
||||
import { useCompanionData } from '@/hooks/companion/useCompanionData'
|
||||
import { useLessonSession } from '@/hooks/companion/useLessonSession'
|
||||
import { useKeyboardShortcuts } from '@/hooks/companion/useKeyboardShortcuts'
|
||||
|
||||
export function CompanionDashboard() {
|
||||
// Mode state
|
||||
const [mode, setMode] = useState<CompanionMode>('companion')
|
||||
|
||||
// Modal states
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showFeedback, setShowFeedback] = useState(false)
|
||||
const [showOnboarding, setShowOnboarding] = useState(false)
|
||||
|
||||
// Settings
|
||||
const [settings, setSettings] = useState<TeacherSettings>(DEFAULT_TEACHER_SETTINGS)
|
||||
|
||||
// Load settings from localStorage
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.SETTINGS)
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored)
|
||||
setSettings({ ...DEFAULT_TEACHER_SETTINGS, ...parsed })
|
||||
} catch {
|
||||
// Invalid stored settings
|
||||
}
|
||||
}
|
||||
|
||||
// Check if onboarding needed
|
||||
const onboardingStored = localStorage.getItem(STORAGE_KEYS.ONBOARDING_STATE)
|
||||
if (!onboardingStored) {
|
||||
setShowOnboarding(true)
|
||||
}
|
||||
|
||||
// Restore last mode
|
||||
const lastMode = localStorage.getItem(STORAGE_KEYS.LAST_MODE) as CompanionMode
|
||||
if (lastMode && ['companion', 'lesson', 'classic'].includes(lastMode)) {
|
||||
setMode(lastMode)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Save mode to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEYS.LAST_MODE, mode)
|
||||
}, [mode])
|
||||
|
||||
// Companion data hook
|
||||
const { data: companionData, loading: companionLoading, refresh } = useCompanionData()
|
||||
|
||||
// Lesson session hook
|
||||
const {
|
||||
session,
|
||||
startLesson,
|
||||
endLesson,
|
||||
pauseLesson,
|
||||
resumeLesson,
|
||||
extendTime,
|
||||
skipPhase,
|
||||
saveReflection,
|
||||
addHomework,
|
||||
removeHomework,
|
||||
isPaused,
|
||||
} = useLessonSession({
|
||||
onOvertimeStart: () => {
|
||||
// Play sound if enabled
|
||||
if (settings.soundNotifications) {
|
||||
// TODO: Play notification sound
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Handle pause/resume toggle
|
||||
const handlePauseToggle = useCallback(() => {
|
||||
if (isPaused) {
|
||||
resumeLesson()
|
||||
} else {
|
||||
pauseLesson()
|
||||
}
|
||||
}, [isPaused, pauseLesson, resumeLesson])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useKeyboardShortcuts({
|
||||
onPauseResume: mode === 'lesson' && session ? handlePauseToggle : undefined,
|
||||
onExtend: mode === 'lesson' && session && !isPaused ? () => extendTime(5) : undefined,
|
||||
onNextPhase: mode === 'lesson' && session && !isPaused ? skipPhase : undefined,
|
||||
onCloseModal: () => {
|
||||
setShowSettings(false)
|
||||
setShowFeedback(false)
|
||||
setShowOnboarding(false)
|
||||
},
|
||||
enabled: settings.showKeyboardShortcuts,
|
||||
})
|
||||
|
||||
// Handle settings save
|
||||
const handleSaveSettings = (newSettings: TeacherSettings) => {
|
||||
setSettings(newSettings)
|
||||
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(newSettings))
|
||||
}
|
||||
|
||||
// Handle feedback submit
|
||||
const handleFeedbackSubmit = async (type: FeedbackType, title: string, description: string) => {
|
||||
const response = await fetch('/api/admin/companion/feedback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
sessionId: session?.sessionId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to submit feedback')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle onboarding complete
|
||||
const handleOnboardingComplete = (data: { state?: string; schoolType?: string }) => {
|
||||
localStorage.setItem(STORAGE_KEYS.ONBOARDING_STATE, JSON.stringify({
|
||||
...data,
|
||||
completed: true,
|
||||
completedAt: new Date().toISOString(),
|
||||
}))
|
||||
setShowOnboarding(false)
|
||||
setSettings({ ...settings, onboardingCompleted: true })
|
||||
}
|
||||
|
||||
// Handle lesson start
|
||||
const handleStartLesson = (data: { classId: string; subject: string; topic?: string; templateId?: string }) => {
|
||||
startLesson(data)
|
||||
setMode('lesson')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-[calc(100vh-200px)] ${settings.highContrastMode ? 'high-contrast' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<ModeToggle
|
||||
currentMode={mode}
|
||||
onModeChange={setMode}
|
||||
disabled={!!session && session.status === 'in_progress'}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Refresh Button */}
|
||||
{mode === 'companion' && (
|
||||
<button
|
||||
onClick={refresh}
|
||||
disabled={companionLoading}
|
||||
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Aktualisieren"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 ${companionLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Feedback Button */}
|
||||
<button
|
||||
onClick={() => setShowFeedback(true)}
|
||||
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Feedback"
|
||||
>
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Settings Button */}
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Einstellungen"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Help Button */}
|
||||
<button
|
||||
onClick={() => setShowOnboarding(true)}
|
||||
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Hilfe"
|
||||
>
|
||||
<HelpCircle className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
{mode === 'companion' && (
|
||||
<div className="space-y-6">
|
||||
{/* Phase Timeline */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Aktuelle Phase</h3>
|
||||
{companionData ? (
|
||||
<PhaseTimeline
|
||||
phases={companionData.phases}
|
||||
currentPhaseIndex={companionData.phases.findIndex(p => p.status === 'active')}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 bg-slate-100 rounded animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<StatsGrid
|
||||
stats={companionData?.stats || { classesCount: 0, studentsCount: 0, learningUnitsCreated: 0, gradesEntered: 0 }}
|
||||
loading={companionLoading}
|
||||
/>
|
||||
|
||||
{/* Two Column Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Suggestions */}
|
||||
<SuggestionList
|
||||
suggestions={companionData?.suggestions || []}
|
||||
loading={companionLoading}
|
||||
onSuggestionClick={(suggestion) => {
|
||||
// Navigate to action target
|
||||
window.location.href = suggestion.actionTarget
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Events */}
|
||||
<EventsCard
|
||||
events={companionData?.upcomingEvents || []}
|
||||
loading={companionLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Start Lesson Button */}
|
||||
<div className="bg-gradient-to-r from-blue-500 to-blue-600 rounded-xl p-6 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-1">Bereit fuer die naechste Stunde?</h3>
|
||||
<p className="text-blue-100">Starten Sie den Lesson-Modus fuer strukturierten Unterricht.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMode('lesson')}
|
||||
className="px-6 py-3 bg-white text-blue-600 rounded-xl font-semibold hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
Stunde starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'lesson' && (
|
||||
<LessonContainer
|
||||
session={session}
|
||||
onStartLesson={handleStartLesson}
|
||||
onEndLesson={endLesson}
|
||||
onPauseToggle={handlePauseToggle}
|
||||
onExtendTime={extendTime}
|
||||
onSkipPhase={skipPhase}
|
||||
onSaveReflection={saveReflection}
|
||||
onAddHomework={addHomework}
|
||||
onRemoveHomework={removeHomework}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'classic' && (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center">
|
||||
<h2 className="text-xl font-semibold text-slate-800 mb-2">Classic Mode</h2>
|
||||
<p className="text-slate-600 mb-4">
|
||||
Die klassische Ansicht ohne Timer und Phasenstruktur.
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
Dieser Modus ist fuer flexible Unterrichtsgestaltung gedacht.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<SettingsModal
|
||||
isOpen={showSettings}
|
||||
onClose={() => setShowSettings(false)}
|
||||
settings={settings}
|
||||
onSave={handleSaveSettings}
|
||||
/>
|
||||
|
||||
<FeedbackModal
|
||||
isOpen={showFeedback}
|
||||
onClose={() => setShowFeedback(false)}
|
||||
onSubmit={handleFeedbackSubmit}
|
||||
/>
|
||||
|
||||
<OnboardingModal
|
||||
isOpen={showOnboarding}
|
||||
onClose={() => setShowOnboarding(false)}
|
||||
onComplete={handleOnboardingComplete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { GraduationCap, Timer, Layout } from 'lucide-react'
|
||||
import { CompanionMode } from '@/lib/companion/types'
|
||||
|
||||
interface ModeToggleProps {
|
||||
currentMode: CompanionMode
|
||||
onModeChange: (mode: CompanionMode) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const modes: { id: CompanionMode; label: string; icon: React.ReactNode; description: string }[] = [
|
||||
{
|
||||
id: 'companion',
|
||||
label: 'Companion',
|
||||
icon: <GraduationCap className="w-4 h-4" />,
|
||||
description: 'Dashboard mit Vorschlaegen',
|
||||
},
|
||||
{
|
||||
id: 'lesson',
|
||||
label: 'Lesson',
|
||||
icon: <Timer className="w-4 h-4" />,
|
||||
description: 'Timer und Phasen',
|
||||
},
|
||||
{
|
||||
id: 'classic',
|
||||
label: 'Classic',
|
||||
icon: <Layout className="w-4 h-4" />,
|
||||
description: 'Klassische Ansicht',
|
||||
},
|
||||
]
|
||||
|
||||
export function ModeToggle({ currentMode, onModeChange, disabled }: ModeToggleProps) {
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-1 inline-flex gap-1">
|
||||
{modes.map((mode) => {
|
||||
const isActive = currentMode === mode.id
|
||||
return (
|
||||
<button
|
||||
key={mode.id}
|
||||
onClick={() => onModeChange(mode.id)}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium
|
||||
transition-all duration-200
|
||||
${isActive
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
|
||||
}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
title={mode.description}
|
||||
>
|
||||
{mode.icon}
|
||||
<span>{mode.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
'use client'
|
||||
|
||||
import { Calendar, FileQuestion, Users, Clock, ChevronRight } from 'lucide-react'
|
||||
import { UpcomingEvent, EventType } from '@/lib/companion/types'
|
||||
import { EVENT_TYPE_CONFIG } from '@/lib/companion/constants'
|
||||
|
||||
interface EventsCardProps {
|
||||
events: UpcomingEvent[]
|
||||
onEventClick?: (event: UpcomingEvent) => void
|
||||
loading?: boolean
|
||||
maxItems?: number
|
||||
}
|
||||
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
FileQuestion,
|
||||
Users,
|
||||
Clock,
|
||||
Calendar,
|
||||
}
|
||||
|
||||
function getEventIcon(type: EventType) {
|
||||
const config = EVENT_TYPE_CONFIG[type]
|
||||
const Icon = iconMap[config.icon] || Calendar
|
||||
return { Icon, ...config }
|
||||
}
|
||||
|
||||
function formatEventDate(dateStr: string, inDays: number): string {
|
||||
if (inDays === 0) return 'Heute'
|
||||
if (inDays === 1) return 'Morgen'
|
||||
if (inDays < 7) return `In ${inDays} Tagen`
|
||||
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
interface EventItemProps {
|
||||
event: UpcomingEvent
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
function EventItem({ event, onClick }: EventItemProps) {
|
||||
const { Icon, color, bg } = getEventIcon(event.type)
|
||||
const isUrgent = event.inDays <= 2
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`
|
||||
w-full flex items-center gap-3 p-3 rounded-lg
|
||||
transition-all duration-200
|
||||
hover:bg-slate-50
|
||||
${isUrgent ? 'bg-red-50/50' : ''}
|
||||
`}
|
||||
>
|
||||
<div className={`p-2 rounded-lg ${bg}`}>
|
||||
<Icon className={`w-4 h-4 ${color}`} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<p className="font-medium text-slate-900 truncate">{event.title}</p>
|
||||
<p className={`text-sm ${isUrgent ? 'text-red-600 font-medium' : 'text-slate-500'}`}>
|
||||
{formatEventDate(event.date, event.inDays)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ChevronRight className="w-4 h-4 text-slate-400 flex-shrink-0" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function EventsCard({
|
||||
events,
|
||||
onEventClick,
|
||||
loading,
|
||||
maxItems = 5,
|
||||
}: EventsCardProps) {
|
||||
const displayEvents = events.slice(0, maxItems)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Calendar className="w-5 h-5 text-blue-500" />
|
||||
<h3 className="font-semibold text-slate-900">Termine</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-14 bg-slate-100 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Calendar className="w-5 h-5 text-blue-500" />
|
||||
<h3 className="font-semibold text-slate-900">Termine</h3>
|
||||
</div>
|
||||
<div className="text-center py-6">
|
||||
<Calendar className="w-10 h-10 text-slate-300 mx-auto mb-2" />
|
||||
<p className="text-sm text-slate-500">Keine anstehenden Termine</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5 text-blue-500" />
|
||||
<h3 className="font-semibold text-slate-900">Termine</h3>
|
||||
</div>
|
||||
<span className="text-sm text-slate-500">
|
||||
{events.length} Termin{events.length !== 1 ? 'e' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{displayEvents.map((event) => (
|
||||
<EventItem
|
||||
key={event.id}
|
||||
event={event}
|
||||
onClick={() => onEventClick?.(event)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{events.length > maxItems && (
|
||||
<button className="w-full mt-3 py-2 text-sm text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg transition-colors">
|
||||
Alle {events.length} anzeigen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact inline version for header/toolbar
|
||||
*/
|
||||
export function EventsInline({ events }: { events: UpcomingEvent[] }) {
|
||||
const nextEvent = events[0]
|
||||
|
||||
if (!nextEvent) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>Keine Termine</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { Icon, color } = getEventIcon(nextEvent.type)
|
||||
const isUrgent = nextEvent.inDays <= 2
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 text-sm ${isUrgent ? 'text-red-600' : 'text-slate-600'}`}>
|
||||
<Icon className={`w-4 h-4 ${color}`} />
|
||||
<span className="truncate max-w-[150px]">{nextEvent.title}</span>
|
||||
<span className="text-slate-400">-</span>
|
||||
<span className={isUrgent ? 'font-medium' : ''}>
|
||||
{formatEventDate(nextEvent.date, nextEvent.inDays)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
'use client'
|
||||
|
||||
import { Check } from 'lucide-react'
|
||||
import { Phase } from '@/lib/companion/types'
|
||||
import { PHASE_COLORS, formatMinutes } from '@/lib/companion/constants'
|
||||
|
||||
interface PhaseTimelineProps {
|
||||
phases: Phase[]
|
||||
currentPhaseIndex: number
|
||||
onPhaseClick?: (index: number) => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function PhaseTimeline({
|
||||
phases,
|
||||
currentPhaseIndex,
|
||||
onPhaseClick,
|
||||
compact = false,
|
||||
}: PhaseTimelineProps) {
|
||||
return (
|
||||
<div className={`flex items-center ${compact ? 'gap-2' : 'gap-3'}`}>
|
||||
{phases.map((phase, index) => {
|
||||
const isActive = index === currentPhaseIndex
|
||||
const isCompleted = phase.status === 'completed'
|
||||
const isPast = index < currentPhaseIndex
|
||||
const colors = PHASE_COLORS[phase.id]
|
||||
|
||||
return (
|
||||
<div key={phase.id} className="flex items-center">
|
||||
{/* Phase Dot/Circle */}
|
||||
<button
|
||||
onClick={() => onPhaseClick?.(index)}
|
||||
disabled={!onPhaseClick}
|
||||
className={`
|
||||
relative flex items-center justify-center
|
||||
${compact ? 'w-8 h-8' : 'w-10 h-10'}
|
||||
rounded-full font-semibold text-sm
|
||||
transition-all duration-300
|
||||
${onPhaseClick ? 'cursor-pointer hover:scale-110' : 'cursor-default'}
|
||||
${isActive
|
||||
? `ring-4 ring-offset-2 ${colors.tailwind} text-white`
|
||||
: isCompleted || isPast
|
||||
? `${colors.tailwind} text-white opacity-80`
|
||||
: 'bg-slate-200 text-slate-500'
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: isActive || isCompleted || isPast ? colors.hex : undefined,
|
||||
// Use CSS custom property for ring color with Tailwind
|
||||
'--tw-ring-color': isActive ? colors.hex : undefined,
|
||||
} as React.CSSProperties}
|
||||
title={`${phase.displayName} (${formatMinutes(phase.duration)})`}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<Check className={compact ? 'w-4 h-4' : 'w-5 h-5'} />
|
||||
) : (
|
||||
phase.shortName
|
||||
)}
|
||||
|
||||
{/* Active indicator pulse */}
|
||||
{isActive && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-full animate-ping opacity-30"
|
||||
style={{ backgroundColor: colors.hex }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Connector Line */}
|
||||
{index < phases.length - 1 && (
|
||||
<div
|
||||
className={`
|
||||
${compact ? 'w-4' : 'w-8'} h-1 mx-1
|
||||
${isPast || isCompleted
|
||||
? 'bg-gradient-to-r'
|
||||
: 'bg-slate-200'
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
background: isPast || isCompleted
|
||||
? `linear-gradient(to right, ${colors.hex}, ${PHASE_COLORS[phases[index + 1].id].hex})`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detailed Phase Timeline with labels and durations
|
||||
*/
|
||||
export function PhaseTimelineDetailed({
|
||||
phases,
|
||||
currentPhaseIndex,
|
||||
onPhaseClick,
|
||||
}: PhaseTimelineProps) {
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Unterrichtsphasen</h3>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
{phases.map((phase, index) => {
|
||||
const isActive = index === currentPhaseIndex
|
||||
const isCompleted = phase.status === 'completed'
|
||||
const isPast = index < currentPhaseIndex
|
||||
const colors = PHASE_COLORS[phase.id]
|
||||
|
||||
return (
|
||||
<div key={phase.id} className="flex flex-col items-center flex-1">
|
||||
{/* Top connector line */}
|
||||
<div className="w-full flex items-center mb-2">
|
||||
{index > 0 && (
|
||||
<div
|
||||
className="flex-1 h-1"
|
||||
style={{
|
||||
background: isPast || isCompleted
|
||||
? PHASE_COLORS[phases[index - 1].id].hex
|
||||
: '#e2e8f0',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{index === 0 && <div className="flex-1" />}
|
||||
|
||||
{/* Phase Circle */}
|
||||
<button
|
||||
onClick={() => onPhaseClick?.(index)}
|
||||
disabled={!onPhaseClick}
|
||||
className={`
|
||||
relative w-12 h-12 rounded-full
|
||||
flex items-center justify-center
|
||||
font-bold text-lg
|
||||
transition-all duration-300
|
||||
${onPhaseClick ? 'cursor-pointer hover:scale-110' : 'cursor-default'}
|
||||
${isActive ? 'ring-4 ring-offset-2 shadow-lg' : ''}
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: isActive || isCompleted || isPast ? colors.hex : '#e2e8f0',
|
||||
color: isActive || isCompleted || isPast ? 'white' : '#64748b',
|
||||
'--tw-ring-color': isActive ? `${colors.hex}40` : undefined,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<Check className="w-6 h-6" />
|
||||
) : (
|
||||
phase.shortName
|
||||
)}
|
||||
|
||||
{isActive && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-full animate-ping opacity-20"
|
||||
style={{ backgroundColor: colors.hex }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{index < phases.length - 1 && (
|
||||
<div
|
||||
className="flex-1 h-1"
|
||||
style={{
|
||||
background: isCompleted ? colors.hex : '#e2e8f0',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{index === phases.length - 1 && <div className="flex-1" />}
|
||||
</div>
|
||||
|
||||
{/* Phase Label */}
|
||||
<span
|
||||
className={`
|
||||
text-sm font-medium mt-2
|
||||
${isActive ? 'text-slate-900' : 'text-slate-500'}
|
||||
`}
|
||||
>
|
||||
{phase.displayName}
|
||||
</span>
|
||||
|
||||
{/* Duration */}
|
||||
<span
|
||||
className={`
|
||||
text-xs mt-1
|
||||
${isActive ? 'text-slate-700' : 'text-slate-400'}
|
||||
`}
|
||||
>
|
||||
{formatMinutes(phase.duration)}
|
||||
</span>
|
||||
|
||||
{/* Actual time if completed */}
|
||||
{phase.actualTime !== undefined && phase.actualTime > 0 && (
|
||||
<span className="text-xs text-slate-400 mt-0.5">
|
||||
(tatsaechlich: {Math.round(phase.actualTime / 60)} Min)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client'
|
||||
|
||||
import { Users, GraduationCap, BookOpen, FileCheck } from 'lucide-react'
|
||||
import { CompanionStats } from '@/lib/companion/types'
|
||||
|
||||
interface StatsGridProps {
|
||||
stats: CompanionStats
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
label: string
|
||||
value: number
|
||||
icon: React.ReactNode
|
||||
color: string
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
function StatCard({ label, value, icon, color, loading }: StatCardProps) {
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 mb-1">{label}</p>
|
||||
{loading ? (
|
||||
<div className="h-8 w-16 bg-slate-200 rounded animate-pulse" />
|
||||
) : (
|
||||
<p className="text-2xl font-bold text-slate-900">{value}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-2 rounded-lg ${color}`}>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatsGrid({ stats, loading }: StatsGridProps) {
|
||||
const statCards = [
|
||||
{
|
||||
label: 'Klassen',
|
||||
value: stats.classesCount,
|
||||
icon: <Users className="w-5 h-5 text-blue-600" />,
|
||||
color: 'bg-blue-100',
|
||||
},
|
||||
{
|
||||
label: 'Schueler',
|
||||
value: stats.studentsCount,
|
||||
icon: <GraduationCap className="w-5 h-5 text-green-600" />,
|
||||
color: 'bg-green-100',
|
||||
},
|
||||
{
|
||||
label: 'Lerneinheiten',
|
||||
value: stats.learningUnitsCreated,
|
||||
icon: <BookOpen className="w-5 h-5 text-purple-600" />,
|
||||
color: 'bg-purple-100',
|
||||
},
|
||||
{
|
||||
label: 'Noten',
|
||||
value: stats.gradesEntered,
|
||||
icon: <FileCheck className="w-5 h-5 text-amber-600" />,
|
||||
color: 'bg-amber-100',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{statCards.map((card) => (
|
||||
<StatCard
|
||||
key={card.label}
|
||||
label={card.label}
|
||||
value={card.value}
|
||||
icon={card.icon}
|
||||
color={card.color}
|
||||
loading={loading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact version of StatsGrid for sidebar or smaller spaces
|
||||
*/
|
||||
export function StatsGridCompact({ stats, loading }: StatsGridProps) {
|
||||
const items = [
|
||||
{ label: 'Klassen', value: stats.classesCount, icon: <Users className="w-4 h-4" /> },
|
||||
{ label: 'Schueler', value: stats.studentsCount, icon: <GraduationCap className="w-4 h-4" /> },
|
||||
{ label: 'Einheiten', value: stats.learningUnitsCreated, icon: <BookOpen className="w-4 h-4" /> },
|
||||
{ label: 'Noten', value: stats.gradesEntered, icon: <FileCheck className="w-4 h-4" /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-3">Statistiken</h3>
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-slate-600">
|
||||
{item.icon}
|
||||
<span className="text-sm">{item.label}</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="h-5 w-8 bg-slate-200 rounded animate-pulse" />
|
||||
) : (
|
||||
<span className="font-semibold text-slate-900">{item.value}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronRight, Clock, Lightbulb, ClipboardCheck, BookOpen, Calendar, Users, MessageSquare, FileText } from 'lucide-react'
|
||||
import { Suggestion, SuggestionPriority } from '@/lib/companion/types'
|
||||
import { PRIORITY_COLORS } from '@/lib/companion/constants'
|
||||
|
||||
interface SuggestionListProps {
|
||||
suggestions: Suggestion[]
|
||||
onSuggestionClick?: (suggestion: Suggestion) => void
|
||||
loading?: boolean
|
||||
maxItems?: number
|
||||
}
|
||||
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
ClipboardCheck,
|
||||
BookOpen,
|
||||
Calendar,
|
||||
Users,
|
||||
Clock,
|
||||
MessageSquare,
|
||||
FileText,
|
||||
Lightbulb,
|
||||
}
|
||||
|
||||
function getIcon(iconName: string) {
|
||||
const Icon = iconMap[iconName] || Lightbulb
|
||||
return Icon
|
||||
}
|
||||
|
||||
interface SuggestionCardProps {
|
||||
suggestion: Suggestion
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
function SuggestionCard({ suggestion, onClick }: SuggestionCardProps) {
|
||||
const priorityStyles = PRIORITY_COLORS[suggestion.priority]
|
||||
const Icon = getIcon(suggestion.icon)
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`
|
||||
w-full p-4 rounded-xl border text-left
|
||||
transition-all duration-200
|
||||
hover:shadow-md hover:scale-[1.01]
|
||||
${priorityStyles.bg} ${priorityStyles.border}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Priority Dot & Icon */}
|
||||
<div className="flex-shrink-0 relative">
|
||||
<div className={`p-2 rounded-lg bg-white shadow-sm`}>
|
||||
<Icon className={`w-5 h-5 ${priorityStyles.text}`} />
|
||||
</div>
|
||||
<span
|
||||
className={`absolute -top-1 -right-1 w-3 h-3 rounded-full ${priorityStyles.dot}`}
|
||||
title={`Prioritaet: ${suggestion.priority}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className={`font-medium ${priorityStyles.text} mb-1`}>
|
||||
{suggestion.title}
|
||||
</h4>
|
||||
<p className="text-sm text-slate-600 line-clamp-2">
|
||||
{suggestion.description}
|
||||
</p>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<span className="inline-flex items-center gap-1 text-xs text-slate-500">
|
||||
<Clock className="w-3 h-3" />
|
||||
~{suggestion.estimatedTime} Min
|
||||
</span>
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${priorityStyles.bg} ${priorityStyles.text}`}>
|
||||
{suggestion.priority}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<ChevronRight className="w-5 h-5 text-slate-400 flex-shrink-0" />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function SuggestionList({
|
||||
suggestions,
|
||||
onSuggestionClick,
|
||||
loading,
|
||||
maxItems = 5,
|
||||
}: SuggestionListProps) {
|
||||
// Sort by priority: urgent > high > medium > low
|
||||
const priorityOrder: Record<SuggestionPriority, number> = {
|
||||
urgent: 0,
|
||||
high: 1,
|
||||
medium: 2,
|
||||
low: 3,
|
||||
}
|
||||
|
||||
const sortedSuggestions = [...suggestions]
|
||||
.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
|
||||
.slice(0, maxItems)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Lightbulb className="w-5 h-5 text-amber-500" />
|
||||
<h3 className="font-semibold text-slate-900">Vorschlaege</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-24 bg-slate-100 rounded-xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (suggestions.length === 0) {
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Lightbulb className="w-5 h-5 text-amber-500" />
|
||||
<h3 className="font-semibold text-slate-900">Vorschlaege</h3>
|
||||
</div>
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<ClipboardCheck className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<p className="text-slate-600">Alles erledigt!</p>
|
||||
<p className="text-sm text-slate-500 mt-1">Keine offenen Aufgaben</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lightbulb className="w-5 h-5 text-amber-500" />
|
||||
<h3 className="font-semibold text-slate-900">Vorschlaege</h3>
|
||||
</div>
|
||||
<span className="text-sm text-slate-500">
|
||||
{suggestions.length} Aufgabe{suggestions.length !== 1 ? 'n' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{sortedSuggestions.map((suggestion) => (
|
||||
<SuggestionCard
|
||||
key={suggestion.id}
|
||||
suggestion={suggestion}
|
||||
onClick={() => onSuggestionClick?.(suggestion)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{suggestions.length > maxItems && (
|
||||
<button className="w-full mt-4 py-2 text-sm text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg transition-colors">
|
||||
Alle {suggestions.length} anzeigen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Main components
|
||||
export { CompanionDashboard } from './CompanionDashboard'
|
||||
export { ModeToggle } from './ModeToggle'
|
||||
|
||||
// Companion Mode components
|
||||
export { PhaseTimeline, PhaseTimelineDetailed } from './companion-mode/PhaseTimeline'
|
||||
export { StatsGrid, StatsGridCompact } from './companion-mode/StatsGrid'
|
||||
export { SuggestionList } from './companion-mode/SuggestionList'
|
||||
export { EventsCard, EventsInline } from './companion-mode/EventsCard'
|
||||
|
||||
// Lesson Mode components
|
||||
export { LessonContainer } from './lesson-mode/LessonContainer'
|
||||
export { LessonStartForm } from './lesson-mode/LessonStartForm'
|
||||
export { LessonActiveView } from './lesson-mode/LessonActiveView'
|
||||
export { LessonEndedView } from './lesson-mode/LessonEndedView'
|
||||
export { VisualPieTimer, CompactTimer } from './lesson-mode/VisualPieTimer'
|
||||
export { QuickActionsBar, QuickActionsCompact } from './lesson-mode/QuickActionsBar'
|
||||
export { HomeworkSection } from './lesson-mode/HomeworkSection'
|
||||
export { ReflectionSection } from './lesson-mode/ReflectionSection'
|
||||
|
||||
// Modals
|
||||
export { SettingsModal } from './modals/SettingsModal'
|
||||
export { FeedbackModal } from './modals/FeedbackModal'
|
||||
export { OnboardingModal } from './modals/OnboardingModal'
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Plus, Trash2, BookOpen, Calendar } from 'lucide-react'
|
||||
import { Homework } from '@/lib/companion/types'
|
||||
|
||||
interface HomeworkSectionProps {
|
||||
homeworkList: Homework[]
|
||||
onAdd: (title: string, dueDate: string) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
export function HomeworkSection({ homeworkList, onAdd, onRemove }: HomeworkSectionProps) {
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [newDueDate, setNewDueDate] = useState('')
|
||||
const [isAdding, setIsAdding] = useState(false)
|
||||
|
||||
// Default due date to next week
|
||||
const getDefaultDueDate = () => {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + 7)
|
||||
return date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newTitle.trim()) return
|
||||
|
||||
onAdd(newTitle.trim(), newDueDate || getDefaultDueDate())
|
||||
setNewTitle('')
|
||||
setNewDueDate('')
|
||||
setIsAdding(false)
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
|
||||
<BookOpen className="w-5 h-5 text-slate-400" />
|
||||
Hausaufgaben
|
||||
</h3>
|
||||
{!isAdding && (
|
||||
<button
|
||||
onClick={() => setIsAdding(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Hinzufuegen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Form */}
|
||||
{isAdding && (
|
||||
<form onSubmit={handleSubmit} className="mb-4 p-4 bg-blue-50 rounded-xl">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Aufgabe
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder="z.B. Aufgabe 1-5 auf S. 42..."
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Faellig am
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newDueDate}
|
||||
onChange={(e) => setNewDueDate(e.target.value)}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newTitle.trim()}
|
||||
className="flex-1 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsAdding(false)
|
||||
setNewTitle('')
|
||||
setNewDueDate('')
|
||||
}}
|
||||
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Homework List */}
|
||||
{homeworkList.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<BookOpen className="w-10 h-10 text-slate-300 mx-auto mb-2" />
|
||||
<p className="text-slate-500">Keine Hausaufgaben eingetragen</p>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
Fuegen Sie Hausaufgaben hinzu, um sie zu dokumentieren
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{homeworkList.map((hw) => (
|
||||
<div
|
||||
key={hw.id}
|
||||
className="flex items-start gap-3 p-4 bg-slate-50 rounded-xl group"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-slate-900">{hw.title}</p>
|
||||
<div className="flex items-center gap-2 mt-1 text-sm text-slate-500">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>Faellig: {formatDate(hw.dueDate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onRemove(hw.id)}
|
||||
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Entfernen"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
'use client'
|
||||
|
||||
import { BookOpen, Clock, Users } from 'lucide-react'
|
||||
import { LessonSession } from '@/lib/companion/types'
|
||||
import { VisualPieTimer } from './VisualPieTimer'
|
||||
import { QuickActionsBar } from './QuickActionsBar'
|
||||
import { PhaseTimelineDetailed } from '../companion-mode/PhaseTimeline'
|
||||
import {
|
||||
PHASE_COLORS,
|
||||
PHASE_DISPLAY_NAMES,
|
||||
formatTime,
|
||||
getTimerColorStatus,
|
||||
} from '@/lib/companion/constants'
|
||||
|
||||
interface LessonActiveViewProps {
|
||||
session: LessonSession
|
||||
onPauseToggle: () => void
|
||||
onExtendTime: (minutes: number) => void
|
||||
onSkipPhase: () => void
|
||||
onEndLesson: () => void
|
||||
}
|
||||
|
||||
export function LessonActiveView({
|
||||
session,
|
||||
onPauseToggle,
|
||||
onExtendTime,
|
||||
onSkipPhase,
|
||||
onEndLesson,
|
||||
}: LessonActiveViewProps) {
|
||||
const currentPhase = session.phases[session.currentPhaseIndex]
|
||||
const phaseId = currentPhase?.phase || 'einstieg'
|
||||
const phaseColor = PHASE_COLORS[phaseId].hex
|
||||
const phaseName = PHASE_DISPLAY_NAMES[phaseId]
|
||||
|
||||
// Calculate timer values
|
||||
const phaseDurationSeconds = (currentPhase?.duration || 0) * 60
|
||||
const elapsedInPhase = currentPhase?.actualTime || 0
|
||||
const remainingSeconds = phaseDurationSeconds - elapsedInPhase
|
||||
const progress = Math.min(elapsedInPhase / phaseDurationSeconds, 1)
|
||||
const isOvertime = remainingSeconds < 0
|
||||
const colorStatus = getTimerColorStatus(remainingSeconds, isOvertime)
|
||||
|
||||
const isLastPhase = session.currentPhaseIndex === session.phases.length - 1
|
||||
|
||||
// Calculate total elapsed
|
||||
const totalElapsedMinutes = Math.floor(session.elapsedTime / 60)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with Session Info */}
|
||||
<div
|
||||
className="bg-gradient-to-r rounded-xl p-6 text-white"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${phaseColor}, ${phaseColor}dd)`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-white/80 text-sm mb-1">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>{session.className}</span>
|
||||
<span className="mx-2">|</span>
|
||||
<BookOpen className="w-4 h-4" />
|
||||
<span>{session.subject}</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold">
|
||||
{session.topic || phaseName}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="text-white/80 text-sm">Gesamtzeit</div>
|
||||
<div className="text-xl font-mono font-bold">
|
||||
{formatTime(session.elapsedTime)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Timer Section */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-8">
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Visual Pie Timer */}
|
||||
<VisualPieTimer
|
||||
progress={progress}
|
||||
remainingSeconds={remainingSeconds}
|
||||
totalSeconds={phaseDurationSeconds}
|
||||
colorStatus={colorStatus}
|
||||
isPaused={session.isPaused}
|
||||
currentPhaseName={phaseName}
|
||||
phaseColor={phaseColor}
|
||||
onTogglePause={onPauseToggle}
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mt-8 w-full max-w-md">
|
||||
<QuickActionsBar
|
||||
onExtend={onExtendTime}
|
||||
onPause={onPauseToggle}
|
||||
onResume={onPauseToggle}
|
||||
onSkip={onSkipPhase}
|
||||
onEnd={onEndLesson}
|
||||
isPaused={session.isPaused}
|
||||
isLastPhase={isLastPhase}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase Timeline */}
|
||||
<PhaseTimelineDetailed
|
||||
phases={session.phases.map((p, i) => ({
|
||||
id: p.phase,
|
||||
shortName: p.phase[0].toUpperCase(),
|
||||
displayName: PHASE_DISPLAY_NAMES[p.phase],
|
||||
duration: p.duration,
|
||||
status: p.status === 'active' ? 'active' : p.status === 'completed' ? 'completed' : 'planned',
|
||||
actualTime: p.actualTime,
|
||||
color: PHASE_COLORS[p.phase].hex,
|
||||
}))}
|
||||
currentPhaseIndex={session.currentPhaseIndex}
|
||||
onPhaseClick={(index) => {
|
||||
// Optional: Allow clicking to navigate to a phase
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Lesson Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
|
||||
<Clock className="w-5 h-5 text-slate-400 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-slate-900">{totalElapsedMinutes}</div>
|
||||
<div className="text-sm text-slate-500">Minuten vergangen</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full mx-auto mb-2"
|
||||
style={{ backgroundColor: phaseColor }}
|
||||
/>
|
||||
<div className="text-2xl font-bold text-slate-900">
|
||||
{session.currentPhaseIndex + 1}/{session.phases.length}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Phase</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
|
||||
<Clock className="w-5 h-5 text-slate-400 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-slate-900">
|
||||
{session.totalPlannedDuration - totalElapsedMinutes}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Minuten verbleibend</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts Hint */}
|
||||
<div className="text-center text-sm text-slate-400">
|
||||
<span className="inline-flex items-center gap-4">
|
||||
<span>
|
||||
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">Leertaste</kbd> Pause
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">E</kbd> +5 Min
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">N</kbd> Weiter
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import { LessonSession, LessonStatus } from '@/lib/companion/types'
|
||||
import { LessonStartForm } from './LessonStartForm'
|
||||
import { LessonActiveView } from './LessonActiveView'
|
||||
import { LessonEndedView } from './LessonEndedView'
|
||||
|
||||
interface LessonContainerProps {
|
||||
session: LessonSession | null
|
||||
onStartLesson: (data: { classId: string; subject: string; topic?: string; templateId?: string }) => void
|
||||
onEndLesson: () => void
|
||||
onPauseToggle: () => void
|
||||
onExtendTime: (minutes: number) => void
|
||||
onSkipPhase: () => void
|
||||
onSaveReflection: (rating: number, notes: string, nextSteps: string) => void
|
||||
onAddHomework: (title: string, dueDate: string) => void
|
||||
onRemoveHomework: (id: string) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function LessonContainer({
|
||||
session,
|
||||
onStartLesson,
|
||||
onEndLesson,
|
||||
onPauseToggle,
|
||||
onExtendTime,
|
||||
onSkipPhase,
|
||||
onSaveReflection,
|
||||
onAddHomework,
|
||||
onRemoveHomework,
|
||||
loading,
|
||||
}: LessonContainerProps) {
|
||||
// Determine which view to show based on session state
|
||||
const getView = (): 'start' | 'active' | 'ended' => {
|
||||
if (!session) return 'start'
|
||||
|
||||
const status = session.status
|
||||
if (status === 'completed') return 'ended'
|
||||
if (status === 'not_started') return 'start'
|
||||
|
||||
return 'active'
|
||||
}
|
||||
|
||||
const view = getView()
|
||||
|
||||
if (view === 'start') {
|
||||
return (
|
||||
<LessonStartForm
|
||||
onStart={onStartLesson}
|
||||
loading={loading}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (view === 'ended' && session) {
|
||||
return (
|
||||
<LessonEndedView
|
||||
session={session}
|
||||
onSaveReflection={onSaveReflection}
|
||||
onAddHomework={onAddHomework}
|
||||
onRemoveHomework={onRemoveHomework}
|
||||
onStartNew={() => onEndLesson()} // This will clear the session and show start form
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (session) {
|
||||
return (
|
||||
<LessonActiveView
|
||||
session={session}
|
||||
onPauseToggle={onPauseToggle}
|
||||
onExtendTime={onExtendTime}
|
||||
onSkipPhase={onSkipPhase}
|
||||
onEndLesson={onEndLesson}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle, Clock, BarChart3, Plus, RefreshCw } from 'lucide-react'
|
||||
import { LessonSession } from '@/lib/companion/types'
|
||||
import { HomeworkSection } from './HomeworkSection'
|
||||
import { ReflectionSection } from './ReflectionSection'
|
||||
import {
|
||||
PHASE_COLORS,
|
||||
PHASE_DISPLAY_NAMES,
|
||||
formatTime,
|
||||
formatMinutes,
|
||||
} from '@/lib/companion/constants'
|
||||
|
||||
interface LessonEndedViewProps {
|
||||
session: LessonSession
|
||||
onSaveReflection: (rating: number, notes: string, nextSteps: string) => void
|
||||
onAddHomework: (title: string, dueDate: string) => void
|
||||
onRemoveHomework: (id: string) => void
|
||||
onStartNew: () => void
|
||||
}
|
||||
|
||||
export function LessonEndedView({
|
||||
session,
|
||||
onSaveReflection,
|
||||
onAddHomework,
|
||||
onRemoveHomework,
|
||||
onStartNew,
|
||||
}: LessonEndedViewProps) {
|
||||
const [activeTab, setActiveTab] = useState<'summary' | 'homework' | 'reflection'>('summary')
|
||||
|
||||
// Calculate analytics
|
||||
const totalPlannedSeconds = session.totalPlannedDuration * 60
|
||||
const totalActualSeconds = session.elapsedTime
|
||||
const timeDiff = totalActualSeconds - totalPlannedSeconds
|
||||
const timeDiffMinutes = Math.round(timeDiff / 60)
|
||||
|
||||
const startTime = new Date(session.startTime)
|
||||
const endTime = session.endTime ? new Date(session.endTime) : new Date()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Success Header */}
|
||||
<div className="bg-gradient-to-r from-green-500 to-green-600 rounded-xl p-6 text-white">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-white/20 rounded-full">
|
||||
<CheckCircle className="w-8 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Stunde beendet!</h2>
|
||||
<p className="text-green-100">
|
||||
{session.className} - {session.subject}
|
||||
{session.topic && ` - ${session.topic}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-1 flex">
|
||||
{[
|
||||
{ id: 'summary', label: 'Zusammenfassung', icon: BarChart3 },
|
||||
{ id: 'homework', label: 'Hausaufgaben', icon: Plus },
|
||||
{ id: 'reflection', label: 'Reflexion', icon: RefreshCw },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as typeof activeTab)}
|
||||
className={`
|
||||
flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg
|
||||
font-medium transition-all duration-200
|
||||
${activeTab === tab.id
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'summary' && (
|
||||
<div className="space-y-6">
|
||||
{/* Time Overview */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-slate-400" />
|
||||
Zeitauswertung
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="text-center p-4 bg-slate-50 rounded-xl">
|
||||
<div className="text-2xl font-bold text-slate-900">
|
||||
{formatTime(totalActualSeconds)}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Tatsaechlich</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-slate-50 rounded-xl">
|
||||
<div className="text-2xl font-bold text-slate-900">
|
||||
{formatMinutes(session.totalPlannedDuration)}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Geplant</div>
|
||||
</div>
|
||||
<div className={`text-center p-4 rounded-xl ${timeDiff > 0 ? 'bg-amber-50' : 'bg-green-50'}`}>
|
||||
<div className={`text-2xl font-bold ${timeDiff > 0 ? 'text-amber-600' : 'text-green-600'}`}>
|
||||
{timeDiffMinutes > 0 ? '+' : ''}{timeDiffMinutes} Min
|
||||
</div>
|
||||
<div className={`text-sm ${timeDiff > 0 ? 'text-amber-500' : 'text-green-500'}`}>
|
||||
Differenz
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Times */}
|
||||
<div className="flex items-center justify-between text-sm text-slate-500 border-t border-slate-100 pt-4">
|
||||
<span>Start: {startTime.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span>Ende: {endTime.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase Breakdown */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-slate-400" />
|
||||
Phasen-Analyse
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{session.phases.map((phase) => {
|
||||
const plannedSeconds = phase.duration * 60
|
||||
const actualSeconds = phase.actualTime
|
||||
const diff = actualSeconds - plannedSeconds
|
||||
const diffMinutes = Math.round(diff / 60)
|
||||
const percentage = Math.min((actualSeconds / plannedSeconds) * 100, 150)
|
||||
|
||||
return (
|
||||
<div key={phase.phase} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: PHASE_COLORS[phase.phase].hex }}
|
||||
/>
|
||||
<span className="font-medium text-slate-700">
|
||||
{PHASE_DISPLAY_NAMES[phase.phase]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-slate-500">
|
||||
<span>{Math.round(actualSeconds / 60)} / {phase.duration} Min</span>
|
||||
<span className={`
|
||||
px-2 py-0.5 rounded text-xs font-medium
|
||||
${diff > 60 ? 'bg-amber-100 text-amber-700' : diff < -60 ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'}
|
||||
`}>
|
||||
{diffMinutes > 0 ? '+' : ''}{diffMinutes} Min
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="h-3 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.min(percentage, 100)}%`,
|
||||
backgroundColor: percentage > 100
|
||||
? '#f59e0b' // amber for overtime
|
||||
: PHASE_COLORS[phase.phase].hex,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'homework' && (
|
||||
<HomeworkSection
|
||||
homeworkList={session.homeworkList}
|
||||
onAdd={onAddHomework}
|
||||
onRemove={onRemoveHomework}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'reflection' && (
|
||||
<ReflectionSection
|
||||
reflection={session.reflection}
|
||||
onSave={onSaveReflection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Start New Lesson Button */}
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={onStartNew}
|
||||
className="w-full py-4 px-6 bg-slate-900 text-white rounded-xl font-semibold hover:bg-slate-800 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
Neue Stunde starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Play, Clock, BookOpen, Users, ChevronDown, Info } from 'lucide-react'
|
||||
import { LessonTemplate, PhaseDurations, Class } from '@/lib/companion/types'
|
||||
import {
|
||||
SYSTEM_TEMPLATES,
|
||||
DEFAULT_PHASE_DURATIONS,
|
||||
PHASE_DISPLAY_NAMES,
|
||||
PHASE_ORDER,
|
||||
calculateTotalDuration,
|
||||
formatMinutes,
|
||||
} from '@/lib/companion/constants'
|
||||
|
||||
interface LessonStartFormProps {
|
||||
onStart: (data: {
|
||||
classId: string
|
||||
subject: string
|
||||
topic?: string
|
||||
templateId?: string
|
||||
}) => void
|
||||
loading?: boolean
|
||||
availableClasses?: Class[]
|
||||
}
|
||||
|
||||
// Mock classes for development
|
||||
const MOCK_CLASSES: Class[] = [
|
||||
{ id: 'c1', name: '9a', grade: '9', studentCount: 28 },
|
||||
{ id: 'c2', name: '9b', grade: '9', studentCount: 26 },
|
||||
{ id: 'c3', name: '10a', grade: '10', studentCount: 24 },
|
||||
{ id: 'c4', name: 'Deutsch LK', grade: 'Q1', studentCount: 18 },
|
||||
{ id: 'c5', name: 'Mathe GK', grade: 'Q2', studentCount: 22 },
|
||||
]
|
||||
|
||||
const SUBJECTS = [
|
||||
'Deutsch',
|
||||
'Mathematik',
|
||||
'Englisch',
|
||||
'Biologie',
|
||||
'Physik',
|
||||
'Chemie',
|
||||
'Geschichte',
|
||||
'Geographie',
|
||||
'Politik',
|
||||
'Kunst',
|
||||
'Musik',
|
||||
'Sport',
|
||||
'Informatik',
|
||||
'Sonstiges',
|
||||
]
|
||||
|
||||
export function LessonStartForm({
|
||||
onStart,
|
||||
loading,
|
||||
availableClasses = MOCK_CLASSES,
|
||||
}: LessonStartFormProps) {
|
||||
const [selectedClass, setSelectedClass] = useState('')
|
||||
const [selectedSubject, setSelectedSubject] = useState('')
|
||||
const [topic, setTopic] = useState('')
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<LessonTemplate | null>(
|
||||
SYSTEM_TEMPLATES[0] as LessonTemplate
|
||||
)
|
||||
const [showTemplateDetails, setShowTemplateDetails] = useState(false)
|
||||
|
||||
const totalDuration = selectedTemplate
|
||||
? calculateTotalDuration(selectedTemplate.durations)
|
||||
: calculateTotalDuration(DEFAULT_PHASE_DURATIONS)
|
||||
|
||||
const canStart = selectedClass && selectedSubject
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!canStart) return
|
||||
|
||||
onStart({
|
||||
classId: selectedClass,
|
||||
subject: selectedSubject,
|
||||
topic: topic || undefined,
|
||||
templateId: selectedTemplate?.templateId,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-3 bg-blue-100 rounded-xl">
|
||||
<Play className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Neue Stunde starten</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
Waehlen Sie Klasse, Fach und Template
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Class Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
<Users className="w-4 h-4 inline mr-2" />
|
||||
Klasse *
|
||||
</label>
|
||||
<select
|
||||
value={selectedClass}
|
||||
onChange={(e) => setSelectedClass(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
|
||||
required
|
||||
>
|
||||
<option value="">Klasse auswaehlen...</option>
|
||||
{availableClasses.map((cls) => (
|
||||
<option key={cls.id} value={cls.id}>
|
||||
{cls.name} ({cls.studentCount} Schueler)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Subject Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
<BookOpen className="w-4 h-4 inline mr-2" />
|
||||
Fach *
|
||||
</label>
|
||||
<select
|
||||
value={selectedSubject}
|
||||
onChange={(e) => setSelectedSubject(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
|
||||
required
|
||||
>
|
||||
<option value="">Fach auswaehlen...</option>
|
||||
{SUBJECTS.map((subject) => (
|
||||
<option key={subject} value={subject}>
|
||||
{subject}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Topic (Optional) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Thema (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
placeholder="z.B. Quadratische Funktionen, Gedichtanalyse..."
|
||||
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Template Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
<Clock className="w-4 h-4 inline mr-2" />
|
||||
Template
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{SYSTEM_TEMPLATES.map((template) => {
|
||||
const tpl = template as LessonTemplate
|
||||
const isSelected = selectedTemplate?.templateId === tpl.templateId
|
||||
const total = calculateTotalDuration(tpl.durations)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tpl.templateId}
|
||||
type="button"
|
||||
onClick={() => setSelectedTemplate(tpl)}
|
||||
className={`
|
||||
w-full p-4 rounded-xl border text-left transition-all
|
||||
${isSelected
|
||||
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500/20'
|
||||
: 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className={`font-medium ${isSelected ? 'text-blue-900' : 'text-slate-900'}`}>
|
||||
{tpl.name}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">{tpl.description}</p>
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${isSelected ? 'text-blue-600' : 'text-slate-500'}`}>
|
||||
{formatMinutes(total)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Template Details Toggle */}
|
||||
{selectedTemplate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTemplateDetails(!showTemplateDetails)}
|
||||
className="mt-3 flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
Phasendauern anzeigen
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${showTemplateDetails ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Template Details */}
|
||||
{showTemplateDetails && selectedTemplate && (
|
||||
<div className="mt-3 p-4 bg-slate-50 rounded-xl">
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{PHASE_ORDER.map((phaseId) => (
|
||||
<div key={phaseId} className="text-center">
|
||||
<p className="text-xs text-slate-500">{PHASE_DISPLAY_NAMES[phaseId]}</p>
|
||||
<p className="font-medium text-slate-900">
|
||||
{selectedTemplate.durations[phaseId]} Min
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary & Start Button */}
|
||||
<div className="pt-4 border-t border-slate-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-sm text-slate-600">
|
||||
Gesamtdauer: <span className="font-semibold">{formatMinutes(totalDuration)}</span>
|
||||
</div>
|
||||
{selectedClass && (
|
||||
<div className="text-sm text-slate-600">
|
||||
Klasse: <span className="font-semibold">
|
||||
{availableClasses.find((c) => c.id === selectedClass)?.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canStart || loading}
|
||||
className={`
|
||||
w-full py-4 px-6 rounded-xl font-semibold text-lg
|
||||
flex items-center justify-center gap-3
|
||||
transition-all duration-200
|
||||
${canStart && !loading
|
||||
? 'bg-blue-600 hover:bg-blue-700 text-white shadow-lg shadow-blue-500/25'
|
||||
: 'bg-slate-200 text-slate-500 cursor-not-allowed'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Stunde wird gestartet...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-5 h-5" />
|
||||
Stunde starten
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
import { Plus, Pause, Play, SkipForward, Square, Clock } from 'lucide-react'
|
||||
|
||||
interface QuickActionsBarProps {
|
||||
onExtend: (minutes: number) => void
|
||||
onPause: () => void
|
||||
onResume: () => void
|
||||
onSkip: () => void
|
||||
onEnd: () => void
|
||||
isPaused: boolean
|
||||
isLastPhase: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function QuickActionsBar({
|
||||
onExtend,
|
||||
onPause,
|
||||
onResume,
|
||||
onSkip,
|
||||
onEnd,
|
||||
isPaused,
|
||||
isLastPhase,
|
||||
disabled,
|
||||
}: QuickActionsBarProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center gap-3 p-4 bg-white border border-slate-200 rounded-xl"
|
||||
role="toolbar"
|
||||
aria-label="Steuerung"
|
||||
>
|
||||
{/* Extend +5 Min */}
|
||||
<button
|
||||
onClick={() => onExtend(5)}
|
||||
disabled={disabled || isPaused}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-3 rounded-xl
|
||||
font-medium transition-all duration-200
|
||||
min-w-[52px] justify-center
|
||||
${disabled || isPaused
|
||||
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
|
||||
: 'bg-blue-50 text-blue-700 hover:bg-blue-100 active:scale-95'
|
||||
}
|
||||
`}
|
||||
title="+5 Minuten (E)"
|
||||
aria-keyshortcuts="e"
|
||||
aria-label="5 Minuten verlaengern"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<span>5 Min</span>
|
||||
</button>
|
||||
|
||||
{/* Pause / Resume */}
|
||||
<button
|
||||
onClick={isPaused ? onResume : onPause}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
flex items-center gap-2 px-6 py-3 rounded-xl
|
||||
font-semibold transition-all duration-200
|
||||
min-w-[52px] min-h-[52px] justify-center
|
||||
${disabled
|
||||
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
|
||||
: isPaused
|
||||
? 'bg-green-600 text-white hover:bg-green-700 shadow-lg shadow-green-500/25 active:scale-95'
|
||||
: 'bg-amber-500 text-white hover:bg-amber-600 shadow-lg shadow-amber-500/25 active:scale-95'
|
||||
}
|
||||
`}
|
||||
title={isPaused ? 'Fortsetzen (Leertaste)' : 'Pausieren (Leertaste)'}
|
||||
aria-keyshortcuts="Space"
|
||||
aria-label={isPaused ? 'Stunde fortsetzen' : 'Stunde pausieren'}
|
||||
>
|
||||
{isPaused ? (
|
||||
<>
|
||||
<Play className="w-5 h-5" />
|
||||
<span>Fortsetzen</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="w-5 h-5" />
|
||||
<span>Pause</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Skip Phase / End Lesson */}
|
||||
{isLastPhase ? (
|
||||
<button
|
||||
onClick={onEnd}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-3 rounded-xl
|
||||
font-medium transition-all duration-200
|
||||
min-w-[52px] justify-center
|
||||
${disabled
|
||||
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
|
||||
: 'bg-red-50 text-red-700 hover:bg-red-100 active:scale-95'
|
||||
}
|
||||
`}
|
||||
title="Stunde beenden"
|
||||
aria-label="Stunde beenden"
|
||||
>
|
||||
<Square className="w-5 h-5" />
|
||||
<span>Beenden</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onSkip}
|
||||
disabled={disabled || isPaused}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-3 rounded-xl
|
||||
font-medium transition-all duration-200
|
||||
min-w-[52px] justify-center
|
||||
${disabled || isPaused
|
||||
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200 active:scale-95'
|
||||
}
|
||||
`}
|
||||
title="Naechste Phase (N)"
|
||||
aria-keyshortcuts="n"
|
||||
aria-label="Zur naechsten Phase springen"
|
||||
>
|
||||
<SkipForward className="w-5 h-5" />
|
||||
<span>Weiter</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact version for mobile or sidebar
|
||||
*/
|
||||
export function QuickActionsCompact({
|
||||
onExtend,
|
||||
onPause,
|
||||
onResume,
|
||||
onSkip,
|
||||
isPaused,
|
||||
isLastPhase,
|
||||
disabled,
|
||||
}: Omit<QuickActionsBarProps, 'onEnd'>) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onExtend(5)}
|
||||
disabled={disabled || isPaused}
|
||||
className={`
|
||||
p-2 rounded-lg transition-all
|
||||
${disabled || isPaused
|
||||
? 'text-slate-300'
|
||||
: 'text-blue-600 hover:bg-blue-50'
|
||||
}
|
||||
`}
|
||||
title="+5 Min"
|
||||
>
|
||||
<Clock className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={isPaused ? onResume : onPause}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
p-2 rounded-lg transition-all
|
||||
${disabled
|
||||
? 'text-slate-300'
|
||||
: isPaused
|
||||
? 'text-green-600 hover:bg-green-50'
|
||||
: 'text-amber-600 hover:bg-amber-50'
|
||||
}
|
||||
`}
|
||||
title={isPaused ? 'Fortsetzen' : 'Pausieren'}
|
||||
>
|
||||
{isPaused ? <Play className="w-5 h-5" /> : <Pause className="w-5 h-5" />}
|
||||
</button>
|
||||
|
||||
{!isLastPhase && (
|
||||
<button
|
||||
onClick={onSkip}
|
||||
disabled={disabled || isPaused}
|
||||
className={`
|
||||
p-2 rounded-lg transition-all
|
||||
${disabled || isPaused
|
||||
? 'text-slate-300'
|
||||
: 'text-slate-600 hover:bg-slate-50'
|
||||
}
|
||||
`}
|
||||
title="Naechste Phase"
|
||||
>
|
||||
<SkipForward className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Star, Save, CheckCircle } from 'lucide-react'
|
||||
import { LessonReflection } from '@/lib/companion/types'
|
||||
|
||||
interface ReflectionSectionProps {
|
||||
reflection?: LessonReflection
|
||||
onSave: (rating: number, notes: string, nextSteps: string) => void
|
||||
}
|
||||
|
||||
export function ReflectionSection({ reflection, onSave }: ReflectionSectionProps) {
|
||||
const [rating, setRating] = useState(reflection?.rating || 0)
|
||||
const [notes, setNotes] = useState(reflection?.notes || '')
|
||||
const [nextSteps, setNextSteps] = useState(reflection?.nextSteps || '')
|
||||
const [hoverRating, setHoverRating] = useState(0)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (reflection) {
|
||||
setRating(reflection.rating)
|
||||
setNotes(reflection.notes)
|
||||
setNextSteps(reflection.nextSteps)
|
||||
}
|
||||
}, [reflection])
|
||||
|
||||
const handleSave = () => {
|
||||
if (rating === 0) return
|
||||
onSave(rating, notes, nextSteps)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
|
||||
const ratingLabels = [
|
||||
'', // 0
|
||||
'Verbesserungsbedarf',
|
||||
'Okay',
|
||||
'Gut',
|
||||
'Sehr gut',
|
||||
'Ausgezeichnet',
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6 space-y-6">
|
||||
{/* Star Rating */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">
|
||||
Wie lief die Stunde?
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const isFilled = star <= (hoverRating || rating)
|
||||
return (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => setRating(star)}
|
||||
onMouseEnter={() => setHoverRating(star)}
|
||||
onMouseLeave={() => setHoverRating(0)}
|
||||
className="p-1 transition-transform hover:scale-110"
|
||||
aria-label={`${star} Stern${star > 1 ? 'e' : ''}`}
|
||||
>
|
||||
<Star
|
||||
className={`w-8 h-8 ${
|
||||
isFilled
|
||||
? 'fill-amber-400 text-amber-400'
|
||||
: 'text-slate-300'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{(hoverRating || rating) > 0 && (
|
||||
<span className="ml-3 text-sm text-slate-600">
|
||||
{ratingLabels[hoverRating || rating]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Notizen zur Stunde
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Was lief gut? Was koennte besser laufen? Besondere Vorkommnisse..."
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Next Steps */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Naechste Schritte
|
||||
</label>
|
||||
<textarea
|
||||
value={nextSteps}
|
||||
onChange={(e) => setNextSteps(e.target.value)}
|
||||
placeholder="Was muss fuer die naechste Stunde vorbereitet werden? Follow-ups..."
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={rating === 0}
|
||||
className={`
|
||||
w-full py-3 px-6 rounded-xl font-semibold
|
||||
flex items-center justify-center gap-2
|
||||
transition-all duration-200
|
||||
${saved
|
||||
? 'bg-green-600 text-white'
|
||||
: rating === 0
|
||||
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{saved ? (
|
||||
<>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
Gespeichert!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-5 h-5" />
|
||||
Reflexion speichern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Previous Reflection Info */}
|
||||
{reflection?.savedAt && (
|
||||
<p className="text-center text-sm text-slate-400">
|
||||
Zuletzt gespeichert: {new Date(reflection.savedAt).toLocaleString('de-DE')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client'
|
||||
|
||||
import { Pause, Play } from 'lucide-react'
|
||||
import { TimerColorStatus } from '@/lib/companion/types'
|
||||
import {
|
||||
PIE_TIMER_RADIUS,
|
||||
PIE_TIMER_CIRCUMFERENCE,
|
||||
PIE_TIMER_STROKE_WIDTH,
|
||||
PIE_TIMER_SIZE,
|
||||
TIMER_COLOR_CLASSES,
|
||||
TIMER_BG_COLORS,
|
||||
formatTime,
|
||||
} from '@/lib/companion/constants'
|
||||
|
||||
interface VisualPieTimerProps {
|
||||
progress: number // 0-1 (how much time has elapsed)
|
||||
remainingSeconds: number
|
||||
totalSeconds: number
|
||||
colorStatus: TimerColorStatus
|
||||
isPaused: boolean
|
||||
currentPhaseName: string
|
||||
phaseColor: string
|
||||
onTogglePause?: () => void
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const sizeConfig = {
|
||||
sm: { outer: 120, viewBox: 100, radius: 38, stroke: 6, fontSize: 'text-lg' },
|
||||
md: { outer: 180, viewBox: 100, radius: 40, stroke: 7, fontSize: 'text-2xl' },
|
||||
lg: { outer: 240, viewBox: 100, radius: 42, stroke: 8, fontSize: 'text-4xl' },
|
||||
}
|
||||
|
||||
export function VisualPieTimer({
|
||||
progress,
|
||||
remainingSeconds,
|
||||
totalSeconds,
|
||||
colorStatus,
|
||||
isPaused,
|
||||
currentPhaseName,
|
||||
phaseColor,
|
||||
onTogglePause,
|
||||
size = 'lg',
|
||||
}: VisualPieTimerProps) {
|
||||
const config = sizeConfig[size]
|
||||
const circumference = 2 * Math.PI * config.radius
|
||||
|
||||
// Calculate stroke-dashoffset for progress
|
||||
// Progress goes from 0 (full) to 1 (empty), so offset decreases as time passes
|
||||
const strokeDashoffset = circumference * (1 - progress)
|
||||
|
||||
// For overtime, show a pulsing full circle
|
||||
const isOvertime = colorStatus === 'overtime'
|
||||
const displayTime = formatTime(remainingSeconds)
|
||||
|
||||
// Get color classes based on status
|
||||
const colorClasses = TIMER_COLOR_CLASSES[colorStatus]
|
||||
const bgColorClass = TIMER_BG_COLORS[colorStatus]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Timer Circle */}
|
||||
<div
|
||||
className={`relative ${bgColorClass} rounded-full p-4 transition-colors duration-300`}
|
||||
style={{ width: config.outer, height: config.outer }}
|
||||
>
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox={`0 0 ${config.viewBox} ${config.viewBox}`}
|
||||
className="transform -rotate-90"
|
||||
>
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={config.viewBox / 2}
|
||||
cy={config.viewBox / 2}
|
||||
r={config.radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={config.stroke}
|
||||
className="text-slate-200"
|
||||
/>
|
||||
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx={config.viewBox / 2}
|
||||
cy={config.viewBox / 2}
|
||||
r={config.radius}
|
||||
fill="none"
|
||||
stroke={isOvertime ? '#dc2626' : phaseColor}
|
||||
strokeWidth={config.stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={isOvertime ? 0 : strokeDashoffset}
|
||||
className={`transition-all duration-100 ${isOvertime ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Center Content */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
{/* Time Display */}
|
||||
<span
|
||||
className={`
|
||||
font-mono font-bold ${config.fontSize}
|
||||
${isOvertime ? 'text-red-600 animate-pulse' : colorStatus === 'critical' ? 'text-red-500' : colorStatus === 'warning' ? 'text-amber-500' : 'text-slate-900'}
|
||||
`}
|
||||
>
|
||||
{displayTime}
|
||||
</span>
|
||||
|
||||
{/* Phase Name */}
|
||||
<span className="text-sm text-slate-500 mt-1">
|
||||
{currentPhaseName}
|
||||
</span>
|
||||
|
||||
{/* Paused Indicator */}
|
||||
{isPaused && (
|
||||
<span className="text-xs text-amber-600 font-medium mt-1 flex items-center gap-1">
|
||||
<Pause className="w-3 h-3" />
|
||||
Pausiert
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Overtime Badge */}
|
||||
{isOvertime && (
|
||||
<span className="absolute -bottom-2 px-2 py-0.5 bg-red-600 text-white text-xs font-bold rounded-full">
|
||||
+{Math.abs(Math.floor(remainingSeconds / 60))} Min
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pause/Play Button (overlay) */}
|
||||
{onTogglePause && (
|
||||
<button
|
||||
onClick={onTogglePause}
|
||||
className={`
|
||||
absolute inset-0 rounded-full
|
||||
flex items-center justify-center
|
||||
opacity-0 hover:opacity-100
|
||||
bg-black/20 backdrop-blur-sm
|
||||
transition-opacity duration-200
|
||||
`}
|
||||
aria-label={isPaused ? 'Fortsetzen' : 'Pausieren'}
|
||||
>
|
||||
{isPaused ? (
|
||||
<Play className="w-12 h-12 text-white" />
|
||||
) : (
|
||||
<Pause className="w-12 h-12 text-white" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Text */}
|
||||
<div className="mt-4 text-center">
|
||||
{isOvertime ? (
|
||||
<p className="text-red-600 font-semibold animate-pulse">
|
||||
Ueberzogen - Zeit fuer die naechste Phase!
|
||||
</p>
|
||||
) : colorStatus === 'critical' ? (
|
||||
<p className="text-red-500 font-medium">
|
||||
Weniger als 2 Minuten verbleibend
|
||||
</p>
|
||||
) : colorStatus === 'warning' ? (
|
||||
<p className="text-amber-500">
|
||||
Weniger als 5 Minuten verbleibend
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact timer for header/toolbar
|
||||
*/
|
||||
export function CompactTimer({
|
||||
remainingSeconds,
|
||||
colorStatus,
|
||||
isPaused,
|
||||
phaseName,
|
||||
phaseColor,
|
||||
}: {
|
||||
remainingSeconds: number
|
||||
colorStatus: TimerColorStatus
|
||||
isPaused: boolean
|
||||
phaseName: string
|
||||
phaseColor: string
|
||||
}) {
|
||||
const isOvertime = colorStatus === 'overtime'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2 bg-white border border-slate-200 rounded-xl">
|
||||
{/* Phase indicator */}
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: phaseColor }}
|
||||
/>
|
||||
|
||||
{/* Phase name */}
|
||||
<span className="text-sm font-medium text-slate-600">{phaseName}</span>
|
||||
|
||||
{/* Time */}
|
||||
<span
|
||||
className={`
|
||||
font-mono font-bold
|
||||
${isOvertime ? 'text-red-600 animate-pulse' : colorStatus === 'critical' ? 'text-red-500' : colorStatus === 'warning' ? 'text-amber-500' : 'text-slate-900'}
|
||||
`}
|
||||
>
|
||||
{formatTime(remainingSeconds)}
|
||||
</span>
|
||||
|
||||
{/* Paused badge */}
|
||||
{isPaused && (
|
||||
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 text-xs font-medium rounded">
|
||||
Pausiert
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { X, MessageSquare, Bug, Lightbulb, Send, CheckCircle } from 'lucide-react'
|
||||
import { FeedbackType } from '@/lib/companion/types'
|
||||
|
||||
interface FeedbackModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (type: FeedbackType, title: string, description: string) => Promise<void>
|
||||
}
|
||||
|
||||
const feedbackTypes: { id: FeedbackType; label: string; icon: typeof Bug; color: string }[] = [
|
||||
{ id: 'bug', label: 'Bug melden', icon: Bug, color: 'text-red-600 bg-red-50' },
|
||||
{ id: 'feature', label: 'Feature-Wunsch', icon: Lightbulb, color: 'text-amber-600 bg-amber-50' },
|
||||
{ id: 'feedback', label: 'Allgemeines Feedback', icon: MessageSquare, color: 'text-blue-600 bg-blue-50' },
|
||||
]
|
||||
|
||||
export function FeedbackModal({ isOpen, onClose, onSubmit }: FeedbackModalProps) {
|
||||
const [type, setType] = useState<FeedbackType>('feedback')
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!title.trim() || !description.trim()) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await onSubmit(type, title.trim(), description.trim())
|
||||
setIsSuccess(true)
|
||||
setTimeout(() => {
|
||||
setIsSuccess(false)
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
setType('feedback')
|
||||
onClose()
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
console.error('Failed to submit feedback:', error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-xl">
|
||||
<MessageSquare className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Feedback senden</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Success State */}
|
||||
{isSuccess ? (
|
||||
<div className="p-12 text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-slate-900 mb-2">Vielen Dank!</h3>
|
||||
<p className="text-slate-600">Ihr Feedback wurde erfolgreich gesendet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Feedback Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">
|
||||
Art des Feedbacks
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{feedbackTypes.map((ft) => (
|
||||
<button
|
||||
key={ft.id}
|
||||
type="button"
|
||||
onClick={() => setType(ft.id)}
|
||||
className={`
|
||||
p-4 rounded-xl border-2 text-center transition-all
|
||||
${type === ft.id
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-slate-200 hover:border-slate-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg ${ft.color} flex items-center justify-center mx-auto mb-2`}>
|
||||
<ft.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${type === ft.id ? 'text-blue-700' : 'text-slate-700'}`}>
|
||||
{ft.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Titel *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={
|
||||
type === 'bug'
|
||||
? 'z.B. Timer stoppt nach Pause nicht mehr'
|
||||
: type === 'feature'
|
||||
? 'z.B. Materialien an Stunde anhaengen'
|
||||
: 'z.B. Super nuetzliches Tool!'
|
||||
}
|
||||
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Beschreibung *
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={
|
||||
type === 'bug'
|
||||
? 'Bitte beschreiben Sie den Fehler moeglichst genau. Was haben Sie gemacht? Was ist passiert? Was haetten Sie erwartet?'
|
||||
: type === 'feature'
|
||||
? 'Beschreiben Sie die gewuenschte Funktion. Warum waere sie hilfreich?'
|
||||
: 'Teilen Sie uns Ihre Gedanken mit...'
|
||||
}
|
||||
rows={5}
|
||||
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-slate-200 bg-slate-50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!title.trim() || !description.trim() || isSubmitting}
|
||||
className={`
|
||||
flex items-center gap-2 px-6 py-2 rounded-lg font-medium
|
||||
transition-all duration-200
|
||||
${!title.trim() || !description.trim() || isSubmitting
|
||||
? 'bg-slate-200 text-slate-500 cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Senden...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4" />
|
||||
Absenden
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronRight, ChevronLeft, Check, GraduationCap, Settings, Timer } from 'lucide-react'
|
||||
|
||||
interface OnboardingModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onComplete: (data: { state?: string; schoolType?: string }) => void
|
||||
}
|
||||
|
||||
const STATES = [
|
||||
'Baden-Wuerttemberg',
|
||||
'Bayern',
|
||||
'Berlin',
|
||||
'Brandenburg',
|
||||
'Bremen',
|
||||
'Hamburg',
|
||||
'Hessen',
|
||||
'Mecklenburg-Vorpommern',
|
||||
'Niedersachsen',
|
||||
'Nordrhein-Westfalen',
|
||||
'Rheinland-Pfalz',
|
||||
'Saarland',
|
||||
'Sachsen',
|
||||
'Sachsen-Anhalt',
|
||||
'Schleswig-Holstein',
|
||||
'Thueringen',
|
||||
]
|
||||
|
||||
const SCHOOL_TYPES = [
|
||||
'Grundschule',
|
||||
'Hauptschule',
|
||||
'Realschule',
|
||||
'Gymnasium',
|
||||
'Gesamtschule',
|
||||
'Berufsschule',
|
||||
'Foerderschule',
|
||||
'Andere',
|
||||
]
|
||||
|
||||
interface Step {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
icon: typeof GraduationCap
|
||||
}
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Willkommen',
|
||||
description: 'Der Companion hilft Ihnen bei der Unterrichtsplanung und -durchfuehrung.',
|
||||
icon: GraduationCap,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Ihre Schule',
|
||||
description: 'Waehlen Sie Ihr Bundesland und Ihre Schulform.',
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Bereit!',
|
||||
description: 'Sie koennen jetzt mit dem Lesson-Modus starten.',
|
||||
icon: Timer,
|
||||
},
|
||||
]
|
||||
|
||||
export function OnboardingModal({ isOpen, onClose, onComplete }: OnboardingModalProps) {
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
const [selectedState, setSelectedState] = useState('')
|
||||
const [selectedSchoolType, setSelectedSchoolType] = useState('')
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const canProceed = () => {
|
||||
if (currentStep === 2) {
|
||||
return selectedState !== '' && selectedSchoolType !== ''
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < 3) {
|
||||
setCurrentStep(currentStep + 1)
|
||||
} else {
|
||||
onComplete({
|
||||
state: selectedState,
|
||||
schoolType: selectedSchoolType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const currentStepData = steps[currentStep - 1]
|
||||
const Icon = currentStepData.icon
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 overflow-hidden">
|
||||
{/* Progress Bar */}
|
||||
<div className="h-1 bg-slate-100">
|
||||
<div
|
||||
className="h-full bg-blue-600 transition-all duration-300"
|
||||
style={{ width: `${(currentStep / 3) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-8">
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`
|
||||
w-3 h-3 rounded-full transition-all
|
||||
${step.id === currentStep
|
||||
? 'bg-blue-600 scale-125'
|
||||
: step.id < currentStep
|
||||
? 'bg-blue-600'
|
||||
: 'bg-slate-200'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<Icon className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
|
||||
{/* Title & Description */}
|
||||
<h2 className="text-2xl font-bold text-slate-900 text-center mb-2">
|
||||
{currentStepData.title}
|
||||
</h2>
|
||||
<p className="text-slate-600 text-center mb-8">
|
||||
{currentStepData.description}
|
||||
</p>
|
||||
|
||||
{/* Step Content */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-blue-50 rounded-xl">
|
||||
<div className="text-2xl mb-1">5</div>
|
||||
<div className="text-xs text-slate-600">Phasen</div>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-xl">
|
||||
<div className="text-2xl mb-1">45</div>
|
||||
<div className="text-xs text-slate-600">Minuten</div>
|
||||
</div>
|
||||
<div className="p-4 bg-purple-50 rounded-xl">
|
||||
<div className="text-2xl mb-1">∞</div>
|
||||
<div className="text-xs text-slate-600">Flexibel</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-4">
|
||||
Einstieg → Erarbeitung → Sicherung → Transfer → Reflexion
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-4">
|
||||
{/* State Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Bundesland
|
||||
</label>
|
||||
<select
|
||||
value={selectedState}
|
||||
onChange={(e) => setSelectedState(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
|
||||
>
|
||||
<option value="">Bitte waehlen...</option>
|
||||
{STATES.map((state) => (
|
||||
<option key={state} value={state}>
|
||||
{state}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* School Type Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Schulform
|
||||
</label>
|
||||
<select
|
||||
value={selectedSchoolType}
|
||||
onChange={(e) => setSelectedSchoolType(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
|
||||
>
|
||||
<option value="">Bitte waehlen...</option>
|
||||
{SCHOOL_TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto">
|
||||
<Check className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
<div className="p-4 bg-slate-50 rounded-xl">
|
||||
<p className="text-sm text-slate-600">
|
||||
<strong>Bundesland:</strong> {selectedState || 'Nicht angegeben'}
|
||||
</p>
|
||||
<p className="text-sm text-slate-600">
|
||||
<strong>Schulform:</strong> {selectedSchoolType || 'Nicht angegeben'}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">
|
||||
Sie koennen diese Einstellungen jederzeit aendern.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-6 border-t border-slate-200 bg-slate-50">
|
||||
<button
|
||||
onClick={currentStep === 1 ? onClose : handleBack}
|
||||
className="flex items-center gap-2 px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
|
||||
>
|
||||
{currentStep === 1 ? (
|
||||
'Ueberspringen'
|
||||
) : (
|
||||
<>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Zurueck
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed()}
|
||||
className={`
|
||||
flex items-center gap-2 px-6 py-2 rounded-lg font-medium
|
||||
transition-all duration-200
|
||||
${canProceed()
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-slate-200 text-slate-500 cursor-not-allowed'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{currentStep === 3 ? (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
Fertig
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Weiter
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Settings, Save, RotateCcw } from 'lucide-react'
|
||||
import { TeacherSettings, PhaseDurations } from '@/lib/companion/types'
|
||||
import {
|
||||
DEFAULT_TEACHER_SETTINGS,
|
||||
DEFAULT_PHASE_DURATIONS,
|
||||
PHASE_ORDER,
|
||||
PHASE_DISPLAY_NAMES,
|
||||
PHASE_COLORS,
|
||||
calculateTotalDuration,
|
||||
} from '@/lib/companion/constants'
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
settings: TeacherSettings
|
||||
onSave: (settings: TeacherSettings) => void
|
||||
}
|
||||
|
||||
export function SettingsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
settings,
|
||||
onSave,
|
||||
}: SettingsModalProps) {
|
||||
const [localSettings, setLocalSettings] = useState<TeacherSettings>(settings)
|
||||
const [durations, setDurations] = useState<PhaseDurations>(settings.defaultPhaseDurations)
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSettings(settings)
|
||||
setDurations(settings.defaultPhaseDurations)
|
||||
}, [settings])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const totalDuration = calculateTotalDuration(durations)
|
||||
|
||||
const handleDurationChange = (phase: keyof PhaseDurations, value: number) => {
|
||||
const newDurations = { ...durations, [phase]: Math.max(1, Math.min(60, value)) }
|
||||
setDurations(newDurations)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setDurations(DEFAULT_PHASE_DURATIONS)
|
||||
setLocalSettings(DEFAULT_TEACHER_SETTINGS)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
const newSettings: TeacherSettings = {
|
||||
...localSettings,
|
||||
defaultPhaseDurations: durations,
|
||||
}
|
||||
onSave(newSettings)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-100 rounded-xl">
|
||||
<Settings className="w-5 h-5 text-slate-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Einstellungen</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6 overflow-y-auto max-h-[60vh]">
|
||||
{/* Phase Durations */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-4">
|
||||
Standard-Phasendauern (Minuten)
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{PHASE_ORDER.map((phase) => (
|
||||
<div key={phase} className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 w-32">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: PHASE_COLORS[phase].hex }}
|
||||
/>
|
||||
<span className="text-sm text-slate-700">
|
||||
{PHASE_DISPLAY_NAMES[phase]}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={60}
|
||||
value={durations[phase]}
|
||||
onChange={(e) => handleDurationChange(phase, parseInt(e.target.value) || 1)}
|
||||
className="w-20 px-3 py-2 border border-slate-200 rounded-lg text-center focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={45}
|
||||
value={durations[phase]}
|
||||
onChange={(e) => handleDurationChange(phase, parseInt(e.target.value))}
|
||||
className="flex-1"
|
||||
style={{
|
||||
accentColor: PHASE_COLORS[phase].hex,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 p-3 bg-slate-50 rounded-xl flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Gesamtdauer:</span>
|
||||
<span className="font-semibold text-slate-900">{totalDuration} Minuten</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Other Settings */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-200">
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-4">
|
||||
Weitere Einstellungen
|
||||
</h3>
|
||||
|
||||
{/* Auto Advance */}
|
||||
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
Automatischer Phasenwechsel
|
||||
</span>
|
||||
<p className="text-xs text-slate-500">
|
||||
Phasen automatisch wechseln wenn Zeit abgelaufen
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.autoAdvancePhases}
|
||||
onChange={(e) =>
|
||||
setLocalSettings({ ...localSettings, autoAdvancePhases: e.target.checked })
|
||||
}
|
||||
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Sound Notifications */}
|
||||
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
Ton-Benachrichtigungen
|
||||
</span>
|
||||
<p className="text-xs text-slate-500">
|
||||
Signalton bei Phasenende und Warnungen
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.soundNotifications}
|
||||
onChange={(e) =>
|
||||
setLocalSettings({ ...localSettings, soundNotifications: e.target.checked })
|
||||
}
|
||||
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Keyboard Shortcuts */}
|
||||
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
Tastaturkuerzel anzeigen
|
||||
</span>
|
||||
<p className="text-xs text-slate-500">
|
||||
Hinweise zu Tastaturkuerzeln einblenden
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.showKeyboardShortcuts}
|
||||
onChange={(e) =>
|
||||
setLocalSettings({ ...localSettings, showKeyboardShortcuts: e.target.checked })
|
||||
}
|
||||
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* High Contrast */}
|
||||
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
Hoher Kontrast
|
||||
</span>
|
||||
<p className="text-xs text-slate-500">
|
||||
Bessere Sichtbarkeit durch erhoehten Kontrast
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.highContrastMode}
|
||||
onChange={(e) =>
|
||||
setLocalSettings({ ...localSettings, highContrastMode: e.target.checked })
|
||||
}
|
||||
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-6 border-t border-slate-200 bg-slate-50">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="flex items-center gap-2 px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Zuruecksetzen
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { Book, Code, FileText, HelpCircle, Zap, Terminal, Database, Shield, ChevronRight, Clock } from 'lucide-react'
|
||||
|
||||
interface NavItem {
|
||||
title: string
|
||||
href: string
|
||||
icon?: React.ReactNode
|
||||
items?: NavItem[]
|
||||
}
|
||||
|
||||
const navigation: NavItem[] = [
|
||||
{
|
||||
title: 'Getting Started',
|
||||
href: '/developers/getting-started',
|
||||
icon: <Zap className="w-4 h-4" />,
|
||||
items: [
|
||||
{ title: 'Quick Start', href: '/developers/getting-started' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'SDK Documentation',
|
||||
href: '/developers/sdk',
|
||||
icon: <Code className="w-4 h-4" />,
|
||||
items: [
|
||||
{ title: 'Overview', href: '/developers/sdk' },
|
||||
{ title: 'Installation', href: '/developers/sdk/installation' },
|
||||
{ title: 'Configuration', href: '/developers/sdk/configuration' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Consent SDK',
|
||||
href: '/developers/sdk/consent',
|
||||
icon: <Shield className="w-4 h-4" />,
|
||||
items: [
|
||||
{ title: 'Uebersicht', href: '/developers/sdk/consent' },
|
||||
{ title: 'Installation', href: '/developers/sdk/consent/installation' },
|
||||
{ title: 'API Referenz', href: '/developers/sdk/consent/api-reference' },
|
||||
{ title: 'Frameworks', href: '/developers/sdk/consent/frameworks' },
|
||||
{ title: 'Mobile SDKs', href: '/developers/sdk/consent/mobile' },
|
||||
{ title: 'Sicherheit', href: '/developers/sdk/consent/security' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'API Reference',
|
||||
href: '/developers/api',
|
||||
icon: <Terminal className="w-4 h-4" />,
|
||||
items: [
|
||||
{ title: 'Overview', href: '/developers/api' },
|
||||
{ title: 'State API', href: '/developers/api/state' },
|
||||
{ title: 'RAG Search API', href: '/developers/api/rag' },
|
||||
{ title: 'Generation API', href: '/developers/api/generate' },
|
||||
{ title: 'Export API', href: '/developers/api/export' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Guides',
|
||||
href: '/developers/guides',
|
||||
icon: <Book className="w-4 h-4" />,
|
||||
items: [
|
||||
{ title: 'Overview', href: '/developers/guides' },
|
||||
{ title: 'Phase 1: Assessment', href: '/developers/guides/phase1' },
|
||||
{ title: 'Phase 2: Dokumentation', href: '/developers/guides/phase2' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Changelog',
|
||||
href: '/developers/changelog',
|
||||
icon: <Clock className="w-4 h-4" />,
|
||||
items: [
|
||||
{ title: 'Versionshistorie', href: '/developers/changelog' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
interface DevPortalLayoutProps {
|
||||
children: React.ReactNode
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function DevPortalLayout({ children, title, description }: DevPortalLayoutProps) {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 border-b border-gray-200 bg-white/95 backdrop-blur">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/developers" className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center">
|
||||
<FileText className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="font-semibold text-gray-900">Developer Portal</span>
|
||||
</Link>
|
||||
<span className="text-gray-300">|</span>
|
||||
<span className="text-sm text-gray-500">AI Compliance SDK</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/sdk"
|
||||
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
SDK Dashboard
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/breakpilot/compliance-sdk"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-7xl mx-auto flex">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-64 shrink-0 border-r border-gray-200 h-[calc(100vh-64px)] sticky top-16 overflow-y-auto">
|
||||
<nav className="p-4 space-y-6">
|
||||
{navigation.map((section) => (
|
||||
<div key={section.href}>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-900 mb-2">
|
||||
{section.icon}
|
||||
<span>{section.title}</span>
|
||||
</div>
|
||||
{section.items && (
|
||||
<ul className="ml-6 space-y-1">
|
||||
{section.items.map((item) => (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`block py-1.5 text-sm transition-colors ${
|
||||
pathname === item.href
|
||||
? 'text-blue-600 font-medium'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 min-w-0">
|
||||
<div className="max-w-3xl mx-auto px-8 py-12">
|
||||
{(title || description) && (
|
||||
<div className="mb-8">
|
||||
{title && (
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">{title}</h1>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-lg text-gray-600">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="prose prose-gray prose-blue max-w-none">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Re-usable components for documentation
|
||||
export function ApiEndpoint({
|
||||
method,
|
||||
path,
|
||||
description,
|
||||
}: {
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
path: string
|
||||
description: string
|
||||
}) {
|
||||
const methodColors = {
|
||||
GET: 'bg-green-100 text-green-800',
|
||||
POST: 'bg-blue-100 text-blue-800',
|
||||
PUT: 'bg-yellow-100 text-yellow-800',
|
||||
DELETE: 'bg-red-100 text-red-800',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-4 my-4 not-prose">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className={`px-2 py-1 text-xs font-bold rounded ${methodColors[method]}`}>
|
||||
{method}
|
||||
</span>
|
||||
<code className="text-sm font-mono text-gray-800">{path}</code>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CodeBlock({
|
||||
language,
|
||||
children,
|
||||
filename,
|
||||
}: {
|
||||
language: string
|
||||
children: string
|
||||
filename?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="my-4 not-prose">
|
||||
{filename && (
|
||||
<div className="bg-gray-800 text-gray-300 text-xs px-4 py-2 rounded-t-lg border-b border-gray-700">
|
||||
{filename}
|
||||
</div>
|
||||
)}
|
||||
<pre className={`bg-gray-900 text-gray-100 p-4 overflow-x-auto text-sm ${filename ? 'rounded-b-lg' : 'rounded-lg'}`}>
|
||||
<code className={`language-${language}`}>{children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ParameterTable({
|
||||
parameters,
|
||||
}: {
|
||||
parameters: Array<{
|
||||
name: string
|
||||
type: string
|
||||
required?: boolean
|
||||
description: string
|
||||
}>
|
||||
}) {
|
||||
return (
|
||||
<div className="my-4 overflow-x-auto not-prose">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Parameter</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Required</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{parameters.map((param) => (
|
||||
<tr key={param.name}>
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-sm text-blue-600">{param.name}</code>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<code className="text-sm text-gray-600">{param.type}</code>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{param.required ? (
|
||||
<span className="text-red-600 text-sm">Yes</span>
|
||||
) : (
|
||||
<span className="text-gray-400 text-sm">No</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{param.description}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InfoBox({
|
||||
type = 'info',
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
type?: 'info' | 'warning' | 'success' | 'error'
|
||||
title?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const styles = {
|
||||
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
|
||||
success: 'bg-green-50 border-green-200 text-green-800',
|
||||
error: 'bg-red-50 border-red-200 text-red-800',
|
||||
}
|
||||
|
||||
const icons = {
|
||||
info: <HelpCircle className="w-5 h-5" />,
|
||||
warning: <Shield className="w-5 h-5" />,
|
||||
success: <Zap className="w-5 h-5" />,
|
||||
error: <Shield className="w-5 h-5" />,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`my-4 p-4 border rounded-lg ${styles[type]} not-prose`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 mt-0.5">{icons[type]}</div>
|
||||
<div>
|
||||
{title && <p className="font-medium mb-1">{title}</p>}
|
||||
<div className="text-sm">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import {
|
||||
Shield, Download, FileCode, Layers, Smartphone, Lock,
|
||||
ChevronDown, ChevronRight, Home, BookOpen,
|
||||
Code2
|
||||
} from 'lucide-react'
|
||||
|
||||
interface NavItem {
|
||||
title: string
|
||||
href: string
|
||||
icon?: React.ReactNode
|
||||
children?: NavItem[]
|
||||
}
|
||||
|
||||
const navigation: NavItem[] = [
|
||||
{
|
||||
title: 'Uebersicht',
|
||||
href: '/developers/sdk/consent',
|
||||
icon: <Home className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
title: 'Installation',
|
||||
href: '/developers/sdk/consent/installation',
|
||||
icon: <Download className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
title: 'API Referenz',
|
||||
href: '/developers/sdk/consent/api-reference',
|
||||
icon: <FileCode className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
title: 'Frameworks',
|
||||
href: '/developers/sdk/consent/frameworks',
|
||||
icon: <Layers className="w-4 h-4" />,
|
||||
children: [
|
||||
{ title: 'React', href: '/developers/sdk/consent/frameworks/react' },
|
||||
{ title: 'Vue', href: '/developers/sdk/consent/frameworks/vue' },
|
||||
{ title: 'Angular', href: '/developers/sdk/consent/frameworks/angular' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Mobile SDKs',
|
||||
href: '/developers/sdk/consent/mobile',
|
||||
icon: <Smartphone className="w-4 h-4" />,
|
||||
children: [
|
||||
{ title: 'iOS (Swift)', href: '/developers/sdk/consent/mobile/ios' },
|
||||
{ title: 'Android (Kotlin)', href: '/developers/sdk/consent/mobile/android' },
|
||||
{ title: 'Flutter', href: '/developers/sdk/consent/mobile/flutter' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Sicherheit',
|
||||
href: '/developers/sdk/consent/security',
|
||||
icon: <Lock className="w-4 h-4" />,
|
||||
},
|
||||
]
|
||||
|
||||
function NavLink({ item, depth = 0 }: { item: NavItem; depth?: number }) {
|
||||
const pathname = usePathname()
|
||||
const isActive = pathname === item.href
|
||||
const isParentActive = item.children?.some((child) => pathname === child.href)
|
||||
const [isOpen, setIsOpen] = React.useState(isActive || isParentActive)
|
||||
|
||||
const hasChildren = item.children && item.children.length > 0
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`flex-1 flex items-center gap-2 px-3 py-2 text-sm rounded-lg transition-colors ${
|
||||
isActive
|
||||
? 'bg-violet-100 text-violet-900 font-medium'
|
||||
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||
}`}
|
||||
style={{ paddingLeft: `${12 + depth * 12}px` }}
|
||||
>
|
||||
{item.icon && <span className="shrink-0">{item.icon}</span>}
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{hasChildren && isOpen && (
|
||||
<div className="mt-1 space-y-1">
|
||||
{item.children?.map((child) => (
|
||||
<NavLink key={child.href} item={child} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SDKDocsSidebar() {
|
||||
return (
|
||||
<aside className="w-64 h-[calc(100vh-64px)] fixed top-16 left-0 border-r border-gray-200 bg-white overflow-y-auto">
|
||||
<div className="p-4">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
href="/developers/sdk/consent"
|
||||
className="flex items-center gap-3 p-3 rounded-xl bg-gradient-to-r from-violet-50 to-purple-50 border border-violet-100"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-violet-600 to-purple-600 flex items-center justify-center">
|
||||
<Shield className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900">Consent SDK</div>
|
||||
<div className="text-xs text-gray-500">v1.0.0</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<NavLink key={item.href} item={item} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Resources */}
|
||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||
<div className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-3 px-3">
|
||||
Ressourcen
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<a
|
||||
href="https://github.com/breakpilot/consent-sdk"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 rounded-lg transition-colors"
|
||||
>
|
||||
<Code2 className="w-4 h-4" />
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
<Link
|
||||
href="/developers"
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 rounded-lg transition-colors"
|
||||
>
|
||||
<BookOpen className="w-4 h-4" />
|
||||
<span>Developer Portal</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export default SDKDocsSidebar
|
||||
@@ -0,0 +1,366 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Dokumente Tab for edu-search page
|
||||
* Shows filterable list of Abitur documents with pagination
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { FileText, Filter, ChevronLeft, ChevronRight, Eye, Download, Search, X, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
AbiturDokument,
|
||||
AbiturDocsResponse,
|
||||
formatFileSize,
|
||||
formatDocumentTitle,
|
||||
FAECHER,
|
||||
JAHRE,
|
||||
BUNDESLAENDER,
|
||||
NIVEAUS,
|
||||
TYPEN,
|
||||
} from '@/lib/education/abitur-docs-types'
|
||||
import { PDFPreviewModal } from './PDFPreviewModal'
|
||||
|
||||
interface DokumenteTabProps {
|
||||
onDocumentCountChange?: (count: number) => void
|
||||
}
|
||||
|
||||
export function DokumenteTab({ onDocumentCountChange }: DokumenteTabProps) {
|
||||
const [documents, setDocuments] = useState<AbiturDokument[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Pagination
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const limit = 20
|
||||
|
||||
// Filters
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
const [filterFach, setFilterFach] = useState<string>('')
|
||||
const [filterJahr, setFilterJahr] = useState<string>('')
|
||||
const [filterBundesland, setFilterBundesland] = useState<string>('')
|
||||
const [filterNiveau, setFilterNiveau] = useState<string>('')
|
||||
const [filterTyp, setFilterTyp] = useState<string>('')
|
||||
|
||||
// Modal
|
||||
const [selectedDocument, setSelectedDocument] = useState<AbiturDokument | null>(null)
|
||||
|
||||
// Fetch documents
|
||||
useEffect(() => {
|
||||
const fetchDocuments = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('page', page.toString())
|
||||
params.set('limit', limit.toString())
|
||||
if (filterFach) params.set('fach', filterFach)
|
||||
if (filterJahr) params.set('jahr', filterJahr)
|
||||
if (filterBundesland) params.set('bundesland', filterBundesland)
|
||||
if (filterNiveau) params.set('niveau', filterNiveau)
|
||||
if (filterTyp) params.set('typ', filterTyp)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/education/abitur-docs?${params.toString()}`)
|
||||
if (!response.ok) throw new Error('Fehler beim Laden der Dokumente')
|
||||
|
||||
const data: AbiturDocsResponse = await response.json()
|
||||
setDocuments(data.documents || [])
|
||||
setTotalPages(data.total_pages || 1)
|
||||
setTotal(data.total || 0)
|
||||
onDocumentCountChange?.(data.total || 0)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchDocuments()
|
||||
}, [page, filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp, onDocumentCountChange])
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilterFach('')
|
||||
setFilterJahr('')
|
||||
setFilterBundesland('')
|
||||
setFilterNiveau('')
|
||||
setFilterTyp('')
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const hasActiveFilters = filterFach || filterJahr || filterBundesland || filterNiveau || filterTyp
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Filter Bar */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={() => setFilterOpen(!filterOpen)}
|
||||
className={`px-4 py-2 rounded-lg font-medium flex items-center gap-2 transition-colors ${
|
||||
filterOpen || hasActiveFilters
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
Filter
|
||||
{hasActiveFilters && (
|
||||
<span className="bg-purple-600 text-white text-xs px-1.5 py-0.5 rounded-full">
|
||||
{[filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp].filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-sm text-slate-500 hover:text-slate-700 flex items-center gap-1"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Filter zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter Dropdowns */}
|
||||
{filterOpen && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 pt-4 border-t border-slate-200">
|
||||
{/* Fach */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">Fach</label>
|
||||
<select
|
||||
value={filterFach}
|
||||
onChange={(e) => { setFilterFach(e.target.value); setPage(1) }}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Alle Fächer</option>
|
||||
{FAECHER.map(f => (
|
||||
<option key={f.id} value={f.id}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Jahr */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">Jahr</label>
|
||||
<select
|
||||
value={filterJahr}
|
||||
onChange={(e) => { setFilterJahr(e.target.value); setPage(1) }}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Alle Jahre</option>
|
||||
{JAHRE.map(j => (
|
||||
<option key={j} value={j}>{j}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Bundesland */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">Bundesland</label>
|
||||
<select
|
||||
value={filterBundesland}
|
||||
onChange={(e) => { setFilterBundesland(e.target.value); setPage(1) }}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Alle Bundesländer</option>
|
||||
{BUNDESLAENDER.map(b => (
|
||||
<option key={b.id} value={b.id}>{b.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Niveau */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">Niveau</label>
|
||||
<select
|
||||
value={filterNiveau}
|
||||
onChange={(e) => { setFilterNiveau(e.target.value); setPage(1) }}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Alle Niveaus</option>
|
||||
{NIVEAUS.map(n => (
|
||||
<option key={n.id} value={n.id}>{n.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Typ */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">Typ</label>
|
||||
<select
|
||||
value={filterTyp}
|
||||
onChange={(e) => { setFilterTyp(e.target.value); setPage(1) }}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Alle Typen</option>
|
||||
{TYPEN.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document List */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12 text-red-600">
|
||||
<p>{error}</p>
|
||||
<button
|
||||
onClick={() => setPage(1)}
|
||||
className="mt-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
) : documents.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>Keine Dokumente gefunden</p>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="mt-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Filter zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-600">Dokument</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-600">Fach</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-600">Jahr</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-600">Niveau</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-600">Typ</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-slate-600">Groesse</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-600">Status</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-600">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{documents.map((doc) => {
|
||||
const fachLabel = FAECHER.find(f => f.id === doc.fach)?.label || doc.fach
|
||||
return (
|
||||
<tr
|
||||
key={doc.id}
|
||||
className="border-b border-slate-100 hover:bg-slate-50 cursor-pointer"
|
||||
onClick={() => setSelectedDocument(doc)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-red-500" />
|
||||
<span className="font-medium text-slate-900 truncate max-w-[200px]" title={doc.dateiname}>
|
||||
{doc.dateiname}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="capitalize">{fachLabel}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">{doc.jahr}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
doc.niveau === 'eA'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{doc.niveau}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
doc.typ === 'erwartungshorizont'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-purple-100 text-purple-700'
|
||||
}`}>
|
||||
{doc.typ === 'erwartungshorizont' ? 'EWH' : 'Aufgabe'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-slate-500">
|
||||
{formatFileSize(doc.file_size)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
doc.status === 'indexed'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: doc.status === 'error'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{doc.status === 'indexed' ? 'Indexiert' : doc.status === 'error' ? 'Fehler' : 'Ausstehend'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<div className="flex items-center justify-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => setSelectedDocument(doc)}
|
||||
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded"
|
||||
title="Vorschau"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<a
|
||||
href={doc.file_path}
|
||||
download={doc.dateiname}
|
||||
className="p-1.5 text-slate-600 hover:bg-slate-100 rounded"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 bg-slate-50">
|
||||
<div className="text-sm text-slate-500">
|
||||
Zeige {(page - 1) * limit + 1}-{Math.min(page * limit, total)} von {total} Dokumenten
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm text-slate-600">
|
||||
Seite {page} von {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PDF Preview Modal */}
|
||||
<PDFPreviewModal
|
||||
document={selectedDocument}
|
||||
onClose={() => setSelectedDocument(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* PDF Preview Modal for Abitur Documents
|
||||
* Shows PDF iframe with metadata sidebar
|
||||
*/
|
||||
|
||||
import { X, Download, ExternalLink, FileText, Calendar, BookOpen, Layers, Search } from 'lucide-react'
|
||||
import { AbiturDokument, formatFileSize, formatDocumentTitle, FAECHER, NIVEAUS } from '@/lib/education/abitur-docs-types'
|
||||
|
||||
interface PDFPreviewModalProps {
|
||||
document: AbiturDokument | null
|
||||
onClose: () => void
|
||||
backendUrl?: string
|
||||
}
|
||||
|
||||
export function PDFPreviewModal({ document, onClose, backendUrl = '' }: PDFPreviewModalProps) {
|
||||
if (!document) return null
|
||||
|
||||
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
|
||||
const niveauLabel = NIVEAUS.find(n => n.id === document.niveau)?.label || document.niveau
|
||||
|
||||
// Build PDF URL - try backend first, fall back to direct path
|
||||
const pdfUrl = backendUrl
|
||||
? `${backendUrl}/api/abitur-docs/${document.id}/file`
|
||||
: document.file_path
|
||||
|
||||
const handleDownload = () => {
|
||||
const link = window.document.createElement('a')
|
||||
link.href = pdfUrl
|
||||
link.download = document.dateiname
|
||||
link.click()
|
||||
}
|
||||
|
||||
const handleSearchInRAG = () => {
|
||||
// Navigate to edu-search with document as context
|
||||
window.location.href = `/education/edu-search?doc=${document.id}&search=1`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl w-[95vw] h-[90vh] max-w-7xl flex overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="absolute top-0 left-0 right-0 h-14 bg-white border-b border-slate-200 flex items-center justify-between px-4 z-10">
|
||||
<h2 className="font-semibold text-slate-900 truncate flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-blue-600" />
|
||||
{formatDocumentTitle(document)}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSearchInRAG}
|
||||
className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 flex items-center gap-1.5"
|
||||
title="In RAG suchen"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
RAG-Suche
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 flex items-center gap-1.5"
|
||||
title="Herunterladen"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Schliessen"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex w-full h-full pt-14">
|
||||
{/* PDF Viewer */}
|
||||
<div className="flex-1 bg-slate-100 p-4">
|
||||
<iframe
|
||||
src={pdfUrl}
|
||||
className="w-full h-full rounded-lg border border-slate-200 bg-white"
|
||||
title={document.dateiname}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Metadata Sidebar */}
|
||||
<div className="w-80 border-l border-slate-200 bg-slate-50 p-4 overflow-y-auto">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Dokument-Details</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Fach */}
|
||||
<div className="flex items-start gap-3">
|
||||
<BookOpen className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Fach</div>
|
||||
<div className="font-medium text-slate-900">{fachLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Jahr */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Calendar className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Jahr</div>
|
||||
<div className="font-medium text-slate-900">{document.jahr}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Niveau */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Layers className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Niveau</div>
|
||||
<div className="font-medium text-slate-900">{niveauLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aufgaben-Nummer */}
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Aufgabe</div>
|
||||
<div className="font-medium text-slate-900">
|
||||
{document.aufgaben_nummer}
|
||||
<span className="ml-2 px-2 py-0.5 bg-slate-200 text-slate-700 text-xs rounded-full">
|
||||
{document.typ === 'erwartungshorizont' ? 'Erwartungshorizont' : 'Aufgabe'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bundesland */}
|
||||
<div className="flex items-start gap-3">
|
||||
<ExternalLink className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Bundesland</div>
|
||||
<div className="font-medium text-slate-900 capitalize">{document.bundesland}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-slate-200" />
|
||||
|
||||
{/* File Info */}
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">Datei-Info</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3 text-sm space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Dateiname</span>
|
||||
<span className="text-slate-900 font-mono text-xs truncate max-w-[150px]" title={document.dateiname}>
|
||||
{document.dateiname}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Groesse</span>
|
||||
<span className="text-slate-900">{formatFileSize(document.file_size)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Status</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
document.status === 'indexed'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: document.status === 'error'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{document.status === 'indexed' ? 'Indexiert' : document.status === 'error' ? 'Fehler' : 'Ausstehend'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RAG Info */}
|
||||
{document.indexed && document.vector_ids.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">RAG-Index</div>
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-purple-700">
|
||||
<Search className="w-4 h-4" />
|
||||
<span>{document.vector_ids.length} Vektoren indexiert</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-purple-600">
|
||||
Confidence: {(document.confidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="text-xs text-slate-400 pt-2">
|
||||
<div>Erstellt: {new Date(document.created_at).toLocaleString('de-DE')}</div>
|
||||
<div>Aktualisiert: {new Date(document.updated_at).toLocaleString('de-DE')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* DevOps Pipeline Sidebar
|
||||
*
|
||||
* Kompakte Sidebar-Komponente fuer Cross-Navigation zwischen DevOps-Modulen.
|
||||
* Zeigt Pipeline-Flow und ermoeglicht schnelle Navigation.
|
||||
*
|
||||
* Features:
|
||||
* - Desktop: Fixierte Sidebar rechts
|
||||
* - Mobile: Floating Action Button mit Slide-In Drawer
|
||||
* - Live Pipeline-Status Badge
|
||||
* - Backlog-Count Badge
|
||||
* - Security-Findings-Count Badge
|
||||
* - Quick-Action: Pipeline triggern
|
||||
*
|
||||
* Datenfluss: CI/CD -> Tests -> SBOM -> Security
|
||||
*/
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState, useEffect } from 'react'
|
||||
import type {
|
||||
DevOpsToolId,
|
||||
DevOpsPipelineSidebarProps,
|
||||
DevOpsPipelineSidebarResponsiveProps,
|
||||
PipelineLiveStatus,
|
||||
} from '@/types/infrastructure-modules'
|
||||
import { DEVOPS_PIPELINE_MODULES } from '@/types/infrastructure-modules'
|
||||
|
||||
// =============================================================================
|
||||
// Icons
|
||||
// =============================================================================
|
||||
|
||||
const ToolIcon = ({ id }: { id: DevOpsToolId }) => {
|
||||
switch (id) {
|
||||
case 'ci-cd':
|
||||
return (
|
||||
<svg className="w-5 h-5" 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>
|
||||
)
|
||||
case 'tests':
|
||||
return (
|
||||
<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-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)
|
||||
case 'sbom':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
)
|
||||
case 'security':
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Server/Pipeline Icon fuer Header
|
||||
const ServerIcon = () => (
|
||||
<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>
|
||||
)
|
||||
|
||||
// Play Icon fuer Quick Action
|
||||
const PlayIcon = () => (
|
||||
<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>
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// Live Status Hook (optional - fetches status from API)
|
||||
// =============================================================================
|
||||
|
||||
function usePipelineLiveStatus(): PipelineLiveStatus | null {
|
||||
const [status, setStatus] = useState<PipelineLiveStatus | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Optional: Fetch live status from API
|
||||
// For now, return null and display static content
|
||||
// Uncomment below to enable live status fetching
|
||||
/*
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/infrastructure/woodpecker/status')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setStatus(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch pipeline status:', error)
|
||||
}
|
||||
}
|
||||
fetchStatus()
|
||||
const interval = setInterval(fetchStatus, 30000) // Poll every 30s
|
||||
return () => clearInterval(interval)
|
||||
*/
|
||||
}, [])
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Status Badge Component
|
||||
// =============================================================================
|
||||
|
||||
interface StatusBadgeProps {
|
||||
count: number
|
||||
type: 'backlog' | 'security' | 'running'
|
||||
}
|
||||
|
||||
function StatusBadge({ count, type }: StatusBadgeProps) {
|
||||
if (count === 0) return null
|
||||
|
||||
const colors = {
|
||||
backlog: 'bg-amber-500',
|
||||
security: 'bg-red-500',
|
||||
running: 'bg-green-500 animate-pulse',
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={`${colors[type]} text-white text-xs font-medium px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center`}>
|
||||
{count}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Sidebar Component
|
||||
// =============================================================================
|
||||
|
||||
export function DevOpsPipelineSidebar({
|
||||
currentTool,
|
||||
compact = false,
|
||||
className = '',
|
||||
}: DevOpsPipelineSidebarProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(!compact)
|
||||
const liveStatus = usePipelineLiveStatus()
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-slate-200 dark:border-gray-700 overflow-hidden ${className}`}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-4 py-3 bg-gradient-to-r from-orange-50 to-amber-50 dark:from-orange-900/20 dark:to-amber-900/20 border-b border-slate-200 dark:border-gray-700 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-orange-600 dark:text-orange-400">
|
||||
<ServerIcon />
|
||||
</span>
|
||||
<span className="font-semibold text-slate-700 dark:text-slate-200 text-sm">
|
||||
DevOps Pipeline
|
||||
</span>
|
||||
{/* Live status indicator */}
|
||||
{liveStatus?.isRunning && (
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" title="Pipeline laeuft" />
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-slate-400 transition-transform ${isExpanded ? '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>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isExpanded && (
|
||||
<div className="p-3 space-y-3">
|
||||
{/* Tool Links */}
|
||||
<div className="space-y-1">
|
||||
{DEVOPS_PIPELINE_MODULES.map((tool) => (
|
||||
<Link
|
||||
key={tool.id}
|
||||
href={tool.href}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
currentTool === tool.id
|
||||
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 font-medium'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<ToolIcon id={tool.id} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{tool.name}</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-500 truncate">
|
||||
{tool.description}
|
||||
</div>
|
||||
</div>
|
||||
{/* Status badges */}
|
||||
{tool.id === 'tests' && liveStatus && (
|
||||
<StatusBadge count={liveStatus.backlogCount} type="backlog" />
|
||||
)}
|
||||
{tool.id === 'security' && liveStatus && (
|
||||
<StatusBadge count={liveStatus.securityFindingsCount} type="security" />
|
||||
)}
|
||||
{currentTool === tool.id && (
|
||||
<span className="flex-shrink-0 w-2 h-2 bg-orange-500 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pipeline Flow Visualization */}
|
||||
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2 px-1">
|
||||
Pipeline Flow
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-2 py-2 bg-slate-50 dark:bg-gray-900 rounded-lg">
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span title="Code" className={currentTool === 'ci-cd' ? 'opacity-100' : 'opacity-50'}>📝</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span title="Build" className={currentTool === 'ci-cd' ? 'opacity-100' : 'opacity-50'}>🏗️</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span title="Test" className={currentTool === 'tests' ? 'opacity-100' : 'opacity-50'}>✅</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span title="SBOM" className={currentTool === 'sbom' ? 'opacity-100' : 'opacity-50'}>📦</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span title="Security" className={currentTool === 'security' ? 'opacity-100' : 'opacity-50'}>🛡️</span>
|
||||
<span className="text-slate-400">→</span>
|
||||
<span title="Deploy" className="opacity-50">🚀</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Info zum aktuellen Tool */}
|
||||
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 px-1">
|
||||
{currentTool === 'ci-cd' && (
|
||||
<span>Verwalten Sie Woodpecker Pipelines und Deployments</span>
|
||||
)}
|
||||
{currentTool === 'tests' && (
|
||||
<span>Ueberwachen Sie 280+ Tests ueber alle Services</span>
|
||||
)}
|
||||
{currentTool === 'sbom' && (
|
||||
<span>Pruefen Sie Abhaengigkeiten und Lizenzen</span>
|
||||
)}
|
||||
{currentTool === 'security' && (
|
||||
<span>Analysieren Sie Vulnerabilities und Security-Scans</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Action: Pipeline triggern */}
|
||||
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement pipeline trigger
|
||||
alert('Pipeline wird getriggert...')
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20 hover:bg-orange-100 dark:hover:bg-orange-900/30 rounded-lg transition-colors"
|
||||
>
|
||||
<PlayIcon />
|
||||
<span>Pipeline triggern</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Responsive Version with Mobile FAB + Drawer
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Responsive DevOps Sidebar mit Mobile FAB + Drawer
|
||||
*
|
||||
* Desktop (xl+): Fixierte Sidebar rechts
|
||||
* Mobile/Tablet: Floating Action Button unten rechts, oeffnet Drawer
|
||||
*/
|
||||
export function DevOpsPipelineSidebarResponsive({
|
||||
currentTool,
|
||||
compact = false,
|
||||
className = '',
|
||||
fabPosition = 'bottom-right',
|
||||
}: DevOpsPipelineSidebarResponsiveProps) {
|
||||
const [isMobileOpen, setIsMobileOpen] = useState(false)
|
||||
const liveStatus = usePipelineLiveStatus()
|
||||
|
||||
// Close drawer on escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setIsMobileOpen(false)
|
||||
}
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [])
|
||||
|
||||
// Prevent body scroll when drawer is open
|
||||
useEffect(() => {
|
||||
if (isMobileOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [isMobileOpen])
|
||||
|
||||
const fabPositionClasses = fabPosition === 'bottom-right'
|
||||
? 'right-4 bottom-20'
|
||||
: 'left-4 bottom-20'
|
||||
|
||||
// Calculate total badge count for FAB
|
||||
const totalBadgeCount = liveStatus
|
||||
? liveStatus.backlogCount + liveStatus.securityFindingsCount
|
||||
: 0
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop: Fixed Sidebar */}
|
||||
<div className={`hidden xl:block fixed right-6 top-24 w-64 z-10 ${className}`}>
|
||||
<DevOpsPipelineSidebar currentTool={currentTool} compact={compact} />
|
||||
</div>
|
||||
|
||||
{/* Mobile/Tablet: FAB */}
|
||||
<button
|
||||
onClick={() => setIsMobileOpen(true)}
|
||||
className={`xl:hidden fixed ${fabPositionClasses} z-40 w-14 h-14 bg-gradient-to-r from-orange-500 to-amber-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center group`}
|
||||
aria-label="DevOps Pipeline Navigation oeffnen"
|
||||
>
|
||||
<ServerIcon />
|
||||
{/* Badge indicator */}
|
||||
{totalBadgeCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center">
|
||||
{totalBadgeCount > 9 ? '9+' : totalBadgeCount}
|
||||
</span>
|
||||
)}
|
||||
{/* Pulse indicator when pipeline is running */}
|
||||
{liveStatus?.isRunning && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-400 rounded-full animate-pulse" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Mobile/Tablet: Drawer Overlay */}
|
||||
{isMobileOpen && (
|
||||
<div className="xl:hidden fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div className="absolute right-0 top-0 bottom-0 w-80 max-w-[85vw] bg-white dark:bg-gray-900 shadow-2xl transform transition-transform animate-slide-in-right">
|
||||
{/* Drawer Header */}
|
||||
<div className="flex items-center justify-between px-4 py-4 border-b border-slate-200 dark:border-gray-700 bg-gradient-to-r from-orange-50 to-amber-50 dark:from-orange-900/20 dark:to-amber-900/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-orange-600 dark:text-orange-400">
|
||||
<ServerIcon />
|
||||
</span>
|
||||
<span className="font-semibold text-slate-700 dark:text-slate-200">
|
||||
DevOps Pipeline
|
||||
</span>
|
||||
{liveStatus?.isRunning && (
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors"
|
||||
aria-label="Schliessen"
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Drawer Content */}
|
||||
<div className="p-4 space-y-4 overflow-y-auto max-h-[calc(100vh-80px)]">
|
||||
{/* Tool Links */}
|
||||
<div className="space-y-2">
|
||||
{DEVOPS_PIPELINE_MODULES.map((tool) => (
|
||||
<Link
|
||||
key={tool.id}
|
||||
href={tool.href}
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all ${
|
||||
currentTool === tool.id
|
||||
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 font-medium shadow-sm'
|
||||
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<ToolIcon id={tool.id} />
|
||||
<div className="flex-1">
|
||||
<div className="text-base font-medium">{tool.name}</div>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-500">
|
||||
{tool.description}
|
||||
</div>
|
||||
</div>
|
||||
{/* Status badges */}
|
||||
{tool.id === 'tests' && liveStatus && (
|
||||
<StatusBadge count={liveStatus.backlogCount} type="backlog" />
|
||||
)}
|
||||
{tool.id === 'security' && liveStatus && (
|
||||
<StatusBadge count={liveStatus.securityFindingsCount} type="security" />
|
||||
)}
|
||||
{currentTool === tool.id && (
|
||||
<span className="flex-shrink-0 w-2.5 h-2.5 bg-orange-500 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pipeline Flow Visualization */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">
|
||||
Pipeline Flow
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 p-4 bg-slate-50 dark:bg-gray-800 rounded-xl">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">📝</span>
|
||||
<span className="text-xs text-slate-500 mt-1">Code</span>
|
||||
</div>
|
||||
<span className="text-slate-400">→</span>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">🏗️</span>
|
||||
<span className="text-xs text-slate-500 mt-1">Build</span>
|
||||
</div>
|
||||
<span className="text-slate-400">→</span>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">✅</span>
|
||||
<span className="text-xs text-slate-500 mt-1">Test</span>
|
||||
</div>
|
||||
<span className="text-slate-400">→</span>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-2xl">🚀</span>
|
||||
<span className="text-xs text-slate-500 mt-1">Deploy</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Info */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
|
||||
<div className="text-sm text-slate-600 dark:text-slate-400 p-3 bg-slate-50 dark:bg-gray-800 rounded-xl">
|
||||
{currentTool === 'ci-cd' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Woodpecker Pipelines und Deployments verwalten
|
||||
</>
|
||||
)}
|
||||
{currentTool === 'tests' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> 280+ Tests ueber alle Services ueberwachen
|
||||
</>
|
||||
)}
|
||||
{currentTool === 'sbom' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Abhaengigkeiten und Lizenzen pruefen
|
||||
</>
|
||||
)}
|
||||
{currentTool === 'security' && (
|
||||
<>
|
||||
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Vulnerabilities und Security-Scans analysieren
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Action: Pipeline triggern */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement pipeline trigger
|
||||
alert('Pipeline wird getriggert...')
|
||||
setIsMobileOpen(false)
|
||||
}}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-base text-white bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 rounded-xl transition-colors font-medium"
|
||||
>
|
||||
<PlayIcon />
|
||||
<span>Pipeline triggern</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Link to Infrastructure Overview */}
|
||||
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
|
||||
<Link
|
||||
href="/infrastructure"
|
||||
onClick={() => setIsMobileOpen(false)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded-lg transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
<span>Zur Infrastructure-Uebersicht</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSS for slide-in animation */}
|
||||
<style jsx>{`
|
||||
@keyframes slide-in-right {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
.animate-slide-in-right {
|
||||
animation: slide-in-right 0.2s ease-out;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DevOpsPipelineSidebar
|
||||
@@ -0,0 +1,76 @@
|
||||
'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 v2</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
'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">
|
||||
Admin v2
|
||||
</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 === 'compliance-sdk' ? '/sdk' : `/${category.id}`
|
||||
const isCategoryActive = category.id === 'compliance-sdk'
|
||||
? category.modules.some(m => pathname.startsWith(m.href))
|
||||
: 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* BlockReviewPanel Component
|
||||
*
|
||||
* Provides a block-by-block review interface for OCR comparison results.
|
||||
* Shows what each OCR method (A, B, C, D) detected for each grid block.
|
||||
* Allows approving or correcting each block.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { GridData, GridCell } from './GridOverlay'
|
||||
import { getCellBlockNumber } from './GridOverlay'
|
||||
|
||||
export type BlockStatus = 'pending' | 'approved' | 'corrected' | 'skipped'
|
||||
|
||||
export interface MethodResult {
|
||||
methodId: string
|
||||
methodName: string
|
||||
text: string
|
||||
confidence?: number
|
||||
}
|
||||
|
||||
export interface BlockReviewData {
|
||||
blockNumber: number
|
||||
cell: GridCell
|
||||
methodResults: MethodResult[]
|
||||
status: BlockStatus
|
||||
correctedText?: string
|
||||
approvedMethodId?: string
|
||||
}
|
||||
|
||||
interface BlockReviewPanelProps {
|
||||
grid: GridData
|
||||
methodResults: Record<string, { vocabulary: Array<{ english: string; german: string; example?: string }> }>
|
||||
currentBlockNumber: number
|
||||
onBlockChange: (blockNumber: number) => void
|
||||
onApprove: (blockNumber: number, methodId: string, text: string) => void
|
||||
onCorrect: (blockNumber: number, correctedText: string) => void
|
||||
onSkip: (blockNumber: number) => void
|
||||
reviewData: Record<number, BlockReviewData>
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Method colors for consistent display
|
||||
const METHOD_COLORS: Record<string, { bg: string; border: string; text: string }> = {
|
||||
vision_llm: { bg: 'bg-blue-50', border: 'border-blue-300', text: 'text-blue-700' },
|
||||
tesseract: { bg: 'bg-green-50', border: 'border-green-300', text: 'text-green-700' },
|
||||
paddleocr: { bg: 'bg-purple-50', border: 'border-purple-300', text: 'text-purple-700' },
|
||||
claude_vision: { bg: 'bg-amber-50', border: 'border-amber-300', text: 'text-amber-700' },
|
||||
}
|
||||
|
||||
const METHOD_LABELS: Record<string, string> = {
|
||||
vision_llm: 'B: Vision LLM',
|
||||
tesseract: 'D: Tesseract',
|
||||
paddleocr: 'C: PaddleOCR',
|
||||
claude_vision: 'E: Claude Vision',
|
||||
}
|
||||
|
||||
export function BlockReviewPanel({
|
||||
grid,
|
||||
methodResults,
|
||||
currentBlockNumber,
|
||||
onBlockChange,
|
||||
onApprove,
|
||||
onCorrect,
|
||||
onSkip,
|
||||
reviewData,
|
||||
className,
|
||||
}: BlockReviewPanelProps) {
|
||||
const [correctionText, setCorrectionText] = useState('')
|
||||
const [showCorrection, setShowCorrection] = useState(false)
|
||||
|
||||
// Get all non-empty blocks
|
||||
const nonEmptyBlocks = useMemo(() => {
|
||||
const blocks: { blockNumber: number; cell: GridCell }[] = []
|
||||
grid.cells.flat().forEach((cell) => {
|
||||
if (cell.status !== 'empty') {
|
||||
blocks.push({
|
||||
blockNumber: getCellBlockNumber(cell, grid),
|
||||
cell,
|
||||
})
|
||||
}
|
||||
})
|
||||
return blocks.sort((a, b) => a.blockNumber - b.blockNumber)
|
||||
}, [grid])
|
||||
|
||||
// Current block data
|
||||
const currentBlock = useMemo(() => {
|
||||
return nonEmptyBlocks.find((b) => b.blockNumber === currentBlockNumber)
|
||||
}, [nonEmptyBlocks, currentBlockNumber])
|
||||
|
||||
// Current block index for navigation
|
||||
const currentIndex = useMemo(() => {
|
||||
return nonEmptyBlocks.findIndex((b) => b.blockNumber === currentBlockNumber)
|
||||
}, [nonEmptyBlocks, currentBlockNumber])
|
||||
|
||||
// Find matching vocabulary for current cell from each method
|
||||
const blockMethodResults = useMemo(() => {
|
||||
if (!currentBlock) return []
|
||||
|
||||
const results: MethodResult[] = []
|
||||
const cellRow = currentBlock.cell.row
|
||||
const cellCol = currentBlock.cell.col
|
||||
|
||||
// For each method, try to find vocabulary that matches this cell position
|
||||
Object.entries(methodResults).forEach(([methodId, data]) => {
|
||||
if (!data.vocabulary || data.vocabulary.length === 0) return
|
||||
|
||||
// Get the text from the grid cell itself (from grid detection)
|
||||
const cellText = currentBlock.cell.text
|
||||
|
||||
// Try to match vocabulary entries
|
||||
// This is a simplified matching - in production you'd match by position
|
||||
const vocabIndex = cellRow * grid.columns + cellCol
|
||||
|
||||
let matchedText = ''
|
||||
if (data.vocabulary[vocabIndex]) {
|
||||
const v = data.vocabulary[vocabIndex]
|
||||
matchedText = currentBlock.cell.column_type === 'english'
|
||||
? v.english
|
||||
: currentBlock.cell.column_type === 'german'
|
||||
? v.german
|
||||
: v.example || ''
|
||||
}
|
||||
|
||||
results.push({
|
||||
methodId,
|
||||
methodName: METHOD_LABELS[methodId] || methodId,
|
||||
text: matchedText || cellText || '(nicht erkannt)',
|
||||
confidence: currentBlock.cell.confidence,
|
||||
})
|
||||
})
|
||||
|
||||
// Always include the grid detection result
|
||||
if (currentBlock.cell.text) {
|
||||
results.unshift({
|
||||
methodId: 'grid_detection',
|
||||
methodName: 'Grid-OCR',
|
||||
text: currentBlock.cell.text,
|
||||
confidence: currentBlock.cell.confidence,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}, [currentBlock, methodResults, grid.columns])
|
||||
|
||||
// Navigation handlers
|
||||
const goToPrevious = useCallback(() => {
|
||||
if (currentIndex > 0) {
|
||||
onBlockChange(nonEmptyBlocks[currentIndex - 1].blockNumber)
|
||||
setShowCorrection(false)
|
||||
setCorrectionText('')
|
||||
}
|
||||
}, [currentIndex, nonEmptyBlocks, onBlockChange])
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
if (currentIndex < nonEmptyBlocks.length - 1) {
|
||||
onBlockChange(nonEmptyBlocks[currentIndex + 1].blockNumber)
|
||||
setShowCorrection(false)
|
||||
setCorrectionText('')
|
||||
}
|
||||
}, [currentIndex, nonEmptyBlocks, onBlockChange])
|
||||
|
||||
// Action handlers
|
||||
const handleApprove = useCallback((methodId: string, text: string) => {
|
||||
onApprove(currentBlockNumber, methodId, text)
|
||||
goToNext()
|
||||
}, [currentBlockNumber, onApprove, goToNext])
|
||||
|
||||
const handleCorrect = useCallback(() => {
|
||||
if (correctionText.trim()) {
|
||||
onCorrect(currentBlockNumber, correctionText.trim())
|
||||
goToNext()
|
||||
}
|
||||
}, [currentBlockNumber, correctionText, onCorrect, goToNext])
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
onSkip(currentBlockNumber)
|
||||
goToNext()
|
||||
}, [currentBlockNumber, onSkip, goToNext])
|
||||
|
||||
// Calculate progress
|
||||
const reviewedCount = Object.values(reviewData).filter(
|
||||
(r) => r.status !== 'pending'
|
||||
).length
|
||||
const progress = nonEmptyBlocks.length > 0
|
||||
? Math.round((reviewedCount / nonEmptyBlocks.length) * 100)
|
||||
: 0
|
||||
|
||||
const currentReview = reviewData[currentBlockNumber]
|
||||
|
||||
if (!currentBlock) {
|
||||
return (
|
||||
<div className={cn('p-4 text-center text-slate-500', className)}>
|
||||
Keine Blöcke zum Überprüfen
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col h-full', className)}>
|
||||
{/* Header with progress */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200 bg-slate-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold text-slate-700">
|
||||
Block {currentBlockNumber}
|
||||
</span>
|
||||
<span className="text-sm text-slate-500">
|
||||
({currentIndex + 1} von {nonEmptyBlocks.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-32 h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">{progress}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Block status indicator */}
|
||||
{currentReview && currentReview.status !== 'pending' && (
|
||||
<div className={cn(
|
||||
'px-4 py-2 text-sm font-medium',
|
||||
currentReview.status === 'approved' && 'bg-green-100 text-green-700',
|
||||
currentReview.status === 'corrected' && 'bg-blue-100 text-blue-700',
|
||||
currentReview.status === 'skipped' && 'bg-slate-100 text-slate-600',
|
||||
)}>
|
||||
{currentReview.status === 'approved' && `✓ Freigegeben: "${currentReview.correctedText}"`}
|
||||
{currentReview.status === 'corrected' && `✎ Korrigiert: "${currentReview.correctedText}"`}
|
||||
{currentReview.status === 'skipped' && '○ Übersprungen'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cell info */}
|
||||
<div className="px-4 py-2 bg-slate-50 border-b text-sm">
|
||||
<span className="text-slate-500">Position: </span>
|
||||
<span className="font-medium">Zeile {currentBlock.cell.row + 1}, Spalte {currentBlock.cell.col + 1}</span>
|
||||
{currentBlock.cell.column_type && (
|
||||
<>
|
||||
<span className="text-slate-500 ml-3">Typ: </span>
|
||||
<span className="font-medium capitalize">{currentBlock.cell.column_type}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Method results */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-600 mb-2">Erkannte Texte:</h4>
|
||||
|
||||
{blockMethodResults.map((result) => {
|
||||
const colors = METHOD_COLORS[result.methodId] || {
|
||||
bg: 'bg-slate-50',
|
||||
border: 'border-slate-300',
|
||||
text: 'text-slate-700'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.methodId}
|
||||
className={cn(
|
||||
'p-3 rounded-lg border-2 transition-all',
|
||||
colors.bg,
|
||||
colors.border,
|
||||
'hover:shadow-md cursor-pointer'
|
||||
)}
|
||||
onClick={() => handleApprove(result.methodId, result.text)}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={cn('text-xs font-semibold', colors.text)}>
|
||||
{result.methodName}
|
||||
</span>
|
||||
{result.confidence !== undefined && result.confidence < 0.7 && (
|
||||
<span className="text-xs text-orange-500">
|
||||
⚠ {Math.round(result.confidence * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-slate-900">
|
||||
{result.text || <span className="text-slate-400 italic">(leer)</span>}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-slate-500">
|
||||
Klicken zum Übernehmen
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Manual correction */}
|
||||
{showCorrection ? (
|
||||
<div className="p-3 rounded-lg border-2 border-indigo-300 bg-indigo-50">
|
||||
<label className="text-xs font-semibold text-indigo-700 block mb-2">
|
||||
Manuelle Korrektur:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={correctionText}
|
||||
onChange={(e) => setCorrectionText(e.target.value)}
|
||||
placeholder="Korrekten Text eingeben..."
|
||||
className="w-full px-3 py-2 border border-indigo-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCorrect()
|
||||
if (e.key === 'Escape') setShowCorrection(false)
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={handleCorrect}
|
||||
disabled={!correctionText.trim()}
|
||||
className="flex-1 px-3 py-1.5 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Übernehmen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCorrection(false)}
|
||||
className="px-3 py-1.5 bg-slate-200 text-slate-700 text-sm font-medium rounded-lg hover:bg-slate-300"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowCorrection(true)}
|
||||
className="w-full p-3 rounded-lg border-2 border-dashed border-slate-300 text-slate-500 text-sm hover:border-indigo-400 hover:text-indigo-600 transition-colors"
|
||||
>
|
||||
+ Manuell korrigieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation footer */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 bg-slate-50">
|
||||
<button
|
||||
onClick={goToPrevious}
|
||||
disabled={currentIndex === 0}
|
||||
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Zurück
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
Überspringen
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={goToNext}
|
||||
disabled={currentIndex === nonEmptyBlocks.length - 1}
|
||||
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Weiter
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* BlockReviewSummary Component
|
||||
*
|
||||
* Shows a summary of all reviewed blocks.
|
||||
*/
|
||||
interface BlockReviewSummaryProps {
|
||||
reviewData: Record<number, BlockReviewData>
|
||||
totalBlocks: number
|
||||
onBlockClick: (blockNumber: number) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function BlockReviewSummary({
|
||||
reviewData,
|
||||
totalBlocks,
|
||||
onBlockClick,
|
||||
className,
|
||||
}: BlockReviewSummaryProps) {
|
||||
const stats = useMemo(() => {
|
||||
const values = Object.values(reviewData)
|
||||
return {
|
||||
approved: values.filter((r) => r.status === 'approved').length,
|
||||
corrected: values.filter((r) => r.status === 'corrected').length,
|
||||
skipped: values.filter((r) => r.status === 'skipped').length,
|
||||
pending: totalBlocks - values.length,
|
||||
}
|
||||
}, [reviewData, totalBlocks])
|
||||
|
||||
return (
|
||||
<div className={cn('p-4 space-y-4', className)}>
|
||||
<h3 className="font-semibold text-slate-800">Überprüfungsübersicht</h3>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-2 bg-green-50 rounded-lg text-center">
|
||||
<div className="text-xl font-bold text-green-700">{stats.approved}</div>
|
||||
<div className="text-xs text-green-600">Freigegeben</div>
|
||||
</div>
|
||||
<div className="p-2 bg-blue-50 rounded-lg text-center">
|
||||
<div className="text-xl font-bold text-blue-700">{stats.corrected}</div>
|
||||
<div className="text-xs text-blue-600">Korrigiert</div>
|
||||
</div>
|
||||
<div className="p-2 bg-slate-100 rounded-lg text-center">
|
||||
<div className="text-xl font-bold text-slate-600">{stats.skipped}</div>
|
||||
<div className="text-xs text-slate-500">Übersprungen</div>
|
||||
</div>
|
||||
<div className="p-2 bg-amber-50 rounded-lg text-center">
|
||||
<div className="text-xl font-bold text-amber-600">{stats.pending}</div>
|
||||
<div className="text-xs text-amber-500">Ausstehend</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Block list */}
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{Object.entries(reviewData)
|
||||
.sort(([a], [b]) => Number(a) - Number(b))
|
||||
.map(([blockNum, data]) => (
|
||||
<button
|
||||
key={blockNum}
|
||||
onClick={() => onBlockClick(Number(blockNum))}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors',
|
||||
data.status === 'approved' && 'bg-green-50 hover:bg-green-100',
|
||||
data.status === 'corrected' && 'bg-blue-50 hover:bg-blue-100',
|
||||
data.status === 'skipped' && 'bg-slate-50 hover:bg-slate-100',
|
||||
)}
|
||||
>
|
||||
<span className="font-medium text-slate-700">Block {blockNum}</span>
|
||||
<span className={cn(
|
||||
'text-xs',
|
||||
data.status === 'approved' && 'text-green-600',
|
||||
data.status === 'corrected' && 'text-blue-600',
|
||||
data.status === 'skipped' && 'text-slate-500',
|
||||
)}>
|
||||
{data.status === 'approved' && '✓'}
|
||||
{data.status === 'corrected' && '✎'}
|
||||
{data.status === 'skipped' && '○'}
|
||||
{' '}
|
||||
{data.correctedText?.substring(0, 20)}
|
||||
{data.correctedText && data.correctedText.length > 20 && '...'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* CellCorrectionDialog Component
|
||||
*
|
||||
* Modal dialog for manually correcting OCR text in problematic or recognized cells.
|
||||
* Shows cropped image of the cell for reference and allows text input.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { GridCell } from './GridOverlay'
|
||||
|
||||
interface CellCorrectionDialogProps {
|
||||
cell: GridCell
|
||||
columnType: 'english' | 'german' | 'example' | 'unknown'
|
||||
sessionId: string
|
||||
pageNumber: number
|
||||
onSave: (text: string) => void
|
||||
onRetryOCR?: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function CellCorrectionDialog({
|
||||
cell,
|
||||
columnType,
|
||||
sessionId,
|
||||
pageNumber,
|
||||
onSave,
|
||||
onRetryOCR,
|
||||
onClose,
|
||||
}: CellCorrectionDialogProps) {
|
||||
const [text, setText] = useState(cell.text || '')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [retrying, setRetrying] = useState(false)
|
||||
const [cropUrl, setCropUrl] = useState<string | null>(null)
|
||||
|
||||
const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
// Load cell crop image
|
||||
useEffect(() => {
|
||||
const loadCrop = async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/cell-crop/${pageNumber}/${cell.row}/${cell.col}`
|
||||
)
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
setCropUrl(URL.createObjectURL(blob))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load cell crop:', e)
|
||||
}
|
||||
}
|
||||
loadCrop()
|
||||
|
||||
return () => {
|
||||
if (cropUrl) {
|
||||
URL.revokeObjectURL(cropUrl)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, pageNumber, cell.row, cell.col])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!text.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('text', text)
|
||||
|
||||
const res = await fetch(
|
||||
`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/cell/${cell.row}/${cell.col}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: formData,
|
||||
}
|
||||
)
|
||||
|
||||
if (res.ok) {
|
||||
onSave(text)
|
||||
onClose()
|
||||
} else {
|
||||
console.error('Failed to save cell:', await res.text())
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save failed:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRetryOCR = async () => {
|
||||
if (!onRetryOCR) return
|
||||
|
||||
setRetrying(true)
|
||||
try {
|
||||
await onRetryOCR()
|
||||
} finally {
|
||||
setRetrying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getColumnLabel = () => {
|
||||
switch (columnType) {
|
||||
case 'english':
|
||||
return 'Englisch'
|
||||
case 'german':
|
||||
return 'Deutsch'
|
||||
case 'example':
|
||||
return 'Beispielsatz'
|
||||
default:
|
||||
return 'Text'
|
||||
}
|
||||
}
|
||||
|
||||
const getPlaceholder = () => {
|
||||
switch (columnType) {
|
||||
case 'english':
|
||||
return 'Englisches Wort eingeben...'
|
||||
case 'german':
|
||||
return 'Deutsche Ubersetzung eingeben...'
|
||||
case 'example':
|
||||
return 'Beispielsatz eingeben...'
|
||||
default:
|
||||
return 'Text eingeben...'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="relative w-full max-w-lg bg-white rounded-xl shadow-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
{cell.status === 'problematic' ? 'Nicht erkannter Bereich' : 'Text bearbeiten'}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-500">
|
||||
Zeile {cell.row + 1}, Spalte {cell.col + 1} ({getColumnLabel()})
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 text-slate-500" 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>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Cell image preview */}
|
||||
<div className="border rounded-lg p-3 bg-slate-50">
|
||||
<p className="text-xs text-slate-500 mb-2 font-medium">Originalbild:</p>
|
||||
{cropUrl ? (
|
||||
<img
|
||||
src={cropUrl}
|
||||
alt="Zellinhalt"
|
||||
className="max-h-32 mx-auto rounded border border-slate-200 bg-white"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-20 flex items-center justify-center text-slate-400 text-sm">
|
||||
Lade Vorschau...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status indicator */}
|
||||
{cell.status === 'problematic' && (
|
||||
<div className="flex items-center gap-2 p-3 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<svg className="w-5 h-5 text-orange-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span className="text-sm text-orange-700">
|
||||
Diese Zelle konnte nicht automatisch erkannt werden.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current recognized text */}
|
||||
{cell.status === 'recognized' && cell.text && (
|
||||
<div className="p-3 bg-green-50 border border-green-200 rounded-lg">
|
||||
<p className="text-xs text-green-600 font-medium mb-1">Erkannter Text:</p>
|
||||
<p className="text-sm text-green-800">{cell.text}</p>
|
||||
{cell.confidence < 1 && (
|
||||
<p className="text-xs text-green-600 mt-1">
|
||||
Konfidenz: {Math.round(cell.confidence * 100)}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text input */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
{cell.status === 'problematic' ? 'Text eingeben' : 'Text korrigieren'}
|
||||
</label>
|
||||
{columnType === 'example' ? (
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={getPlaceholder()}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-slate-900"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder={getPlaceholder()}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-slate-900"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-slate-200 flex items-center justify-between bg-slate-50">
|
||||
<div>
|
||||
{onRetryOCR && cell.status === 'problematic' && (
|
||||
<button
|
||||
onClick={handleRetryOCR}
|
||||
disabled={retrying}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{retrying ? (
|
||||
<>
|
||||
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Erneut versuchen...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" 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>
|
||||
OCR erneut versuchen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={loading || !text.trim()}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Speichern...
|
||||
</>
|
||||
) : (
|
||||
'Speichern'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CellCorrectionDialog
|
||||
@@ -0,0 +1,549 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* GridOverlay Component
|
||||
*
|
||||
* SVG overlay for displaying detected OCR grid structure on document images.
|
||||
* Features:
|
||||
* - Cell status visualization (recognized/problematic/manual/empty)
|
||||
* - 1mm grid overlay for A4 pages (210x297mm)
|
||||
* - Text at original bounding-box positions
|
||||
* - Editable text (contentEditable) at original positions
|
||||
* - Click-to-edit for cells
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type CellStatus = 'empty' | 'recognized' | 'problematic' | 'manual'
|
||||
|
||||
export interface GridCell {
|
||||
row: number
|
||||
col: number
|
||||
x: number // X position as percentage (0-100)
|
||||
y: number // Y position as percentage (0-100)
|
||||
width: number // Width as percentage (0-100)
|
||||
height: number // Height as percentage (0-100)
|
||||
text: string
|
||||
confidence: number
|
||||
status: CellStatus
|
||||
column_type?: 'english' | 'german' | 'example' | 'unknown'
|
||||
x_mm?: number
|
||||
y_mm?: number
|
||||
width_mm?: number
|
||||
height_mm?: number
|
||||
}
|
||||
|
||||
export interface GridData {
|
||||
rows: number
|
||||
columns: number
|
||||
cells: GridCell[][]
|
||||
column_types: string[]
|
||||
column_boundaries: number[]
|
||||
row_boundaries: number[]
|
||||
deskew_angle: number
|
||||
stats: {
|
||||
recognized: number
|
||||
problematic: number
|
||||
empty: number
|
||||
manual?: number
|
||||
total: number
|
||||
coverage: number
|
||||
}
|
||||
page_dimensions?: {
|
||||
width_mm: number
|
||||
height_mm: number
|
||||
format: string
|
||||
}
|
||||
source?: string
|
||||
}
|
||||
|
||||
interface GridOverlayProps {
|
||||
grid: GridData
|
||||
imageUrl?: string
|
||||
onCellClick?: (cell: GridCell) => void
|
||||
onCellTextChange?: (cell: GridCell, newText: string) => void
|
||||
selectedCell?: GridCell | null
|
||||
showEmpty?: boolean
|
||||
showLabels?: boolean
|
||||
showNumbers?: boolean
|
||||
showTextLabels?: boolean
|
||||
showMmGrid?: boolean
|
||||
showTextAtPosition?: boolean
|
||||
editableText?: boolean
|
||||
highlightedBlockNumber?: number | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Status colors
|
||||
const STATUS_COLORS = {
|
||||
recognized: {
|
||||
fill: 'rgba(34, 197, 94, 0.2)',
|
||||
stroke: '#22c55e',
|
||||
hoverFill: 'rgba(34, 197, 94, 0.3)',
|
||||
},
|
||||
problematic: {
|
||||
fill: 'rgba(249, 115, 22, 0.3)',
|
||||
stroke: '#f97316',
|
||||
hoverFill: 'rgba(249, 115, 22, 0.4)',
|
||||
},
|
||||
manual: {
|
||||
fill: 'rgba(59, 130, 246, 0.2)',
|
||||
stroke: '#3b82f6',
|
||||
hoverFill: 'rgba(59, 130, 246, 0.3)',
|
||||
},
|
||||
empty: {
|
||||
fill: 'transparent',
|
||||
stroke: 'rgba(148, 163, 184, 0.3)',
|
||||
hoverFill: 'rgba(148, 163, 184, 0.1)',
|
||||
},
|
||||
}
|
||||
|
||||
// A4 dimensions for mm grid
|
||||
const A4_WIDTH_MM = 210
|
||||
const A4_HEIGHT_MM = 297
|
||||
|
||||
// Helper to calculate block number (1-indexed, row-by-row)
|
||||
export function getCellBlockNumber(cell: GridCell, grid: GridData): number {
|
||||
return cell.row * grid.columns + cell.col + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 1mm Grid SVG Lines for A4 format.
|
||||
* Renders inside a viewBox="0 0 100 100" (percentage-based).
|
||||
*/
|
||||
function MmGridLines() {
|
||||
const lines: React.ReactNode[] = []
|
||||
|
||||
// Vertical lines: 210 lines for 210mm
|
||||
for (let mm = 0; mm <= A4_WIDTH_MM; mm++) {
|
||||
const x = (mm / A4_WIDTH_MM) * 100
|
||||
const isCm = mm % 10 === 0
|
||||
lines.push(
|
||||
<line
|
||||
key={`v-${mm}`}
|
||||
x1={x}
|
||||
y1={0}
|
||||
x2={x}
|
||||
y2={100}
|
||||
stroke={isCm ? 'rgba(59, 130, 246, 0.25)' : 'rgba(59, 130, 246, 0.1)'}
|
||||
strokeWidth={isCm ? 0.08 : 0.03}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Horizontal lines: 297 lines for 297mm
|
||||
for (let mm = 0; mm <= A4_HEIGHT_MM; mm++) {
|
||||
const y = (mm / A4_HEIGHT_MM) * 100
|
||||
const isCm = mm % 10 === 0
|
||||
lines.push(
|
||||
<line
|
||||
key={`h-${mm}`}
|
||||
x1={0}
|
||||
y1={y}
|
||||
x2={100}
|
||||
y2={y}
|
||||
stroke={isCm ? 'rgba(59, 130, 246, 0.25)' : 'rgba(59, 130, 246, 0.1)'}
|
||||
strokeWidth={isCm ? 0.08 : 0.03}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <g style={{ pointerEvents: 'none' }}>{lines}</g>
|
||||
}
|
||||
|
||||
/**
|
||||
* Positioned text overlay using absolute-positioned HTML divs.
|
||||
* Each cell's text appears at its bounding-box position with matching font size.
|
||||
*/
|
||||
function PositionedTextLayer({
|
||||
cells,
|
||||
editable,
|
||||
onTextChange,
|
||||
}: {
|
||||
cells: GridCell[]
|
||||
editable: boolean
|
||||
onTextChange?: (cell: GridCell, text: string) => void
|
||||
}) {
|
||||
const [hoveredCell, setHoveredCell] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0" style={{ pointerEvents: editable ? 'auto' : 'none' }}>
|
||||
{cells.map((cell) => {
|
||||
if (cell.status === 'empty' || !cell.text) return null
|
||||
|
||||
const cellKey = `pos-${cell.row}-${cell.col}`
|
||||
const isHovered = hoveredCell === cellKey
|
||||
// Estimate font size from cell height: height_pct maps to roughly pt size
|
||||
// A4 at 100% = 297mm height. Cell height in % * 297mm / 100 = height_mm
|
||||
// Font size ~= height_mm * 2.2 (roughly matching print)
|
||||
const heightMm = cell.height_mm ?? (cell.height / 100 * A4_HEIGHT_MM)
|
||||
const fontSizePt = Math.max(6, Math.min(18, heightMm * 2.2))
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cellKey}
|
||||
className={cn(
|
||||
'absolute overflow-hidden transition-colors duration-100',
|
||||
editable && 'cursor-text hover:bg-yellow-100/40',
|
||||
isHovered && !editable && 'bg-blue-100/30',
|
||||
)}
|
||||
style={{
|
||||
left: `${cell.x}%`,
|
||||
top: `${cell.y}%`,
|
||||
width: `${cell.width}%`,
|
||||
height: `${cell.height}%`,
|
||||
fontSize: `${fontSizePt}pt`,
|
||||
fontFamily: '"Georgia", "Times New Roman", serif',
|
||||
lineHeight: 1.1,
|
||||
color: cell.status === 'manual' ? '#1e40af' : '#1a1a1a',
|
||||
padding: '0 1px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
onMouseEnter={() => setHoveredCell(cellKey)}
|
||||
onMouseLeave={() => setHoveredCell(null)}
|
||||
>
|
||||
{editable ? (
|
||||
<span
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
className="outline-none w-full"
|
||||
style={{ minHeight: '1em' }}
|
||||
onBlur={(e) => {
|
||||
const newText = e.currentTarget.textContent ?? ''
|
||||
if (newText !== cell.text && onTextChange) {
|
||||
onTextChange(cell, newText)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{cell.text}
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate">{cell.text}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function GridOverlay({
|
||||
grid,
|
||||
imageUrl,
|
||||
onCellClick,
|
||||
onCellTextChange,
|
||||
selectedCell,
|
||||
showEmpty = false,
|
||||
showLabels = true,
|
||||
showNumbers = false,
|
||||
showTextLabels = false,
|
||||
showMmGrid = false,
|
||||
showTextAtPosition = false,
|
||||
editableText = false,
|
||||
highlightedBlockNumber,
|
||||
className,
|
||||
}: GridOverlayProps) {
|
||||
const handleCellClick = useCallback(
|
||||
(cell: GridCell) => {
|
||||
if (onCellClick && cell.status !== 'empty') {
|
||||
onCellClick(cell)
|
||||
}
|
||||
},
|
||||
[onCellClick]
|
||||
)
|
||||
|
||||
const flatCells = grid.cells.flat()
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
{/* Background image */}
|
||||
{imageUrl && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Document"
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* SVG overlay */}
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{/* 1mm Grid */}
|
||||
{showMmGrid && <MmGridLines />}
|
||||
|
||||
{/* Column type labels */}
|
||||
{showLabels && grid.column_types.length > 0 && (
|
||||
<g>
|
||||
{grid.column_types.map((type, idx) => {
|
||||
const x = grid.column_boundaries[idx]
|
||||
const width = grid.column_boundaries[idx + 1] - x
|
||||
const label = type === 'english' ? 'EN' : type === 'german' ? 'DE' : type === 'example' ? 'Ex' : '?'
|
||||
return (
|
||||
<text
|
||||
key={`col-label-${idx}`}
|
||||
x={x + width / 2}
|
||||
y={1.5}
|
||||
textAnchor="middle"
|
||||
fontSize="1.5"
|
||||
fill="#64748b"
|
||||
fontWeight="bold"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Grid cells (skip if showing text at position to avoid double rendering) */}
|
||||
{!showTextAtPosition && flatCells.map((cell) => {
|
||||
const colors = STATUS_COLORS[cell.status]
|
||||
const isSelected = selectedCell?.row === cell.row && selectedCell?.col === cell.col
|
||||
const isClickable = cell.status !== 'empty' && onCellClick
|
||||
const blockNumber = getCellBlockNumber(cell, grid)
|
||||
const isHighlighted = highlightedBlockNumber === blockNumber
|
||||
|
||||
if (!showEmpty && cell.status === 'empty') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<g
|
||||
key={`cell-${cell.row}-${cell.col}`}
|
||||
style={{ pointerEvents: isClickable ? 'auto' : 'none' }}
|
||||
onClick={() => handleCellClick(cell)}
|
||||
className={isClickable ? 'cursor-pointer' : ''}
|
||||
>
|
||||
<rect
|
||||
x={cell.x}
|
||||
y={cell.y}
|
||||
width={cell.width}
|
||||
height={cell.height}
|
||||
fill={isHighlighted ? 'rgba(99, 102, 241, 0.3)' : colors.fill}
|
||||
stroke={isSelected || isHighlighted ? '#4f46e5' : colors.stroke}
|
||||
strokeWidth={isSelected || isHighlighted ? 0.3 : 0.15}
|
||||
rx={0.2}
|
||||
className={cn(
|
||||
'transition-all duration-150',
|
||||
isClickable && 'hover:fill-opacity-40'
|
||||
)}
|
||||
/>
|
||||
|
||||
{showNumbers && cell.status !== 'empty' && (
|
||||
<>
|
||||
<rect
|
||||
x={cell.x + 0.3}
|
||||
y={cell.y + 0.3}
|
||||
width={2.5}
|
||||
height={1.8}
|
||||
fill={isHighlighted ? '#4f46e5' : '#374151'}
|
||||
rx={0.3}
|
||||
/>
|
||||
<text
|
||||
x={cell.x + 1.55}
|
||||
y={cell.y + 1.5}
|
||||
textAnchor="middle"
|
||||
fontSize="1.2"
|
||||
fill="white"
|
||||
fontWeight="bold"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{blockNumber}
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!showNumbers && !showTextLabels && cell.status !== 'empty' && (
|
||||
<circle
|
||||
cx={cell.x + 0.8}
|
||||
cy={cell.y + 0.8}
|
||||
r={0.5}
|
||||
fill={colors.stroke}
|
||||
stroke="white"
|
||||
strokeWidth={0.1}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showTextLabels && (cell.status === 'recognized' || cell.status === 'manual') && cell.text && (
|
||||
<text
|
||||
x={cell.x + cell.width / 2}
|
||||
y={cell.y + cell.height / 2 + Math.min(cell.height * 0.2, 0.5)}
|
||||
textAnchor="middle"
|
||||
fontSize={Math.min(cell.height * 0.5, 1.4)}
|
||||
fill={cell.status === 'manual' ? '#1e40af' : '#166534'}
|
||||
fontWeight="500"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{cell.text.length > 15 ? cell.text.slice(0, 15) + '\u2026' : cell.text}
|
||||
</text>
|
||||
)}
|
||||
|
||||
{cell.status === 'recognized' && cell.confidence < 0.7 && (
|
||||
<text
|
||||
x={cell.x + cell.width - 0.5}
|
||||
y={cell.y + 1.2}
|
||||
fontSize="0.8"
|
||||
fill="#f97316"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
!
|
||||
</text>
|
||||
)}
|
||||
|
||||
{isSelected && (
|
||||
<rect
|
||||
x={cell.x}
|
||||
y={cell.y}
|
||||
width={cell.width}
|
||||
height={cell.height}
|
||||
fill="none"
|
||||
stroke="#4f46e5"
|
||||
strokeWidth={0.4}
|
||||
strokeDasharray="0.5,0.3"
|
||||
rx={0.2}
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Show cell outlines when in positioned text mode */}
|
||||
{showTextAtPosition && flatCells.map((cell) => {
|
||||
if (cell.status === 'empty') return null
|
||||
return (
|
||||
<rect
|
||||
key={`outline-${cell.row}-${cell.col}`}
|
||||
x={cell.x}
|
||||
y={cell.y}
|
||||
width={cell.width}
|
||||
height={cell.height}
|
||||
fill="none"
|
||||
stroke="rgba(99, 102, 241, 0.2)"
|
||||
strokeWidth={0.08}
|
||||
rx={0.1}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Row boundaries */}
|
||||
{grid.row_boundaries.map((y, idx) => (
|
||||
<line
|
||||
key={`row-line-${idx}`}
|
||||
x1={0}
|
||||
y1={y}
|
||||
x2={100}
|
||||
y2={y}
|
||||
stroke="rgba(148, 163, 184, 0.2)"
|
||||
strokeWidth={0.05}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Column boundaries */}
|
||||
{grid.column_boundaries.map((x, idx) => (
|
||||
<line
|
||||
key={`col-line-${idx}`}
|
||||
x1={x}
|
||||
y1={0}
|
||||
x2={x}
|
||||
y2={100}
|
||||
stroke="rgba(148, 163, 184, 0.2)"
|
||||
strokeWidth={0.05}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* Positioned text HTML overlay (outside SVG for proper text rendering) */}
|
||||
{showTextAtPosition && (
|
||||
<PositionedTextLayer
|
||||
cells={flatCells.filter(c => c.status !== 'empty' && c.text)}
|
||||
editable={editableText}
|
||||
onTextChange={onCellTextChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* GridStats Component
|
||||
*/
|
||||
interface GridStatsProps {
|
||||
stats: GridData['stats']
|
||||
deskewAngle?: number
|
||||
source?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function GridStats({ stats, deskewAngle, source, className }: GridStatsProps) {
|
||||
const coveragePercent = Math.round(stats.coverage * 100)
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-3', className)}>
|
||||
<div className="px-3 py-1.5 bg-green-50 text-green-700 rounded-lg text-sm font-medium">
|
||||
Erkannt: {stats.recognized}
|
||||
</div>
|
||||
{(stats.manual ?? 0) > 0 && (
|
||||
<div className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg text-sm font-medium">
|
||||
Manuell: {stats.manual}
|
||||
</div>
|
||||
)}
|
||||
{stats.problematic > 0 && (
|
||||
<div className="px-3 py-1.5 bg-orange-50 text-orange-700 rounded-lg text-sm font-medium">
|
||||
Problematisch: {stats.problematic}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-3 py-1.5 bg-slate-50 text-slate-600 rounded-lg text-sm font-medium">
|
||||
Leer: {stats.empty}
|
||||
</div>
|
||||
<div className="px-3 py-1.5 bg-indigo-50 text-indigo-700 rounded-lg text-sm font-medium">
|
||||
Abdeckung: {coveragePercent}%
|
||||
</div>
|
||||
{deskewAngle !== undefined && deskewAngle !== 0 && (
|
||||
<div className="px-3 py-1.5 bg-purple-50 text-purple-700 rounded-lg text-sm font-medium">
|
||||
Begradigt: {deskewAngle.toFixed(1)}
|
||||
</div>
|
||||
)}
|
||||
{source && (
|
||||
<div className="px-3 py-1.5 bg-cyan-50 text-cyan-700 rounded-lg text-sm font-medium">
|
||||
Quelle: {source === 'tesseract+grid_service' ? 'Tesseract' : 'Vision LLM'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Legend Component for GridOverlay
|
||||
*/
|
||||
export function GridLegend({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-4 text-sm', className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-2 border-green-500 bg-green-500/20" />
|
||||
<span className="text-slate-600">Erkannt</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-2 border-orange-500 bg-orange-500/30" />
|
||||
<span className="text-slate-600">Problematisch</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-2 border-blue-500 bg-blue-500/20" />
|
||||
<span className="text-slate-600">Manuell korrigiert</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded border-2 border-slate-300 bg-transparent" />
|
||||
<span className="text-slate-600">Leer</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* GroundTruthPanel — Step-through UI for labeling OCR ground truth.
|
||||
*
|
||||
* Shows page image with SVG overlay (color-coded bounding boxes),
|
||||
* alongside crops of the current entry and editable text fields.
|
||||
* Keyboard-driven: Enter=confirm, Tab=skip, Arrow keys=navigate.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
|
||||
// ---------- Types ----------
|
||||
|
||||
interface BBox {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
interface GTEntry {
|
||||
row_index: number
|
||||
english: string
|
||||
german: string
|
||||
example: string
|
||||
confidence: number
|
||||
bbox: BBox
|
||||
bbox_en: BBox
|
||||
bbox_de: BBox
|
||||
bbox_ex: BBox
|
||||
status?: 'pending' | 'confirmed' | 'edited' | 'skipped'
|
||||
}
|
||||
|
||||
interface GroundTruthPanelProps {
|
||||
sessionId: string
|
||||
selectedPage: number
|
||||
pageImageUrl: string
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
const STATUS_COLORS: Record<string, { fill: string; stroke: string }> = {
|
||||
current: { fill: 'rgba(250,204,21,0.25)', stroke: '#eab308' }, // yellow
|
||||
confirmed: { fill: 'rgba(34,197,94,0.18)', stroke: '#16a34a' }, // green
|
||||
edited: { fill: 'rgba(59,130,246,0.18)', stroke: '#2563eb' }, // blue
|
||||
skipped: { fill: 'rgba(148,163,184,0.15)', stroke: '#94a3b8' }, // gray
|
||||
pending: { fill: 'rgba(0,0,0,0)', stroke: '#cbd5e1' }, // outline only
|
||||
}
|
||||
|
||||
function getEntryColor(entry: GTEntry, index: number, currentIndex: number) {
|
||||
if (index === currentIndex) return STATUS_COLORS.current
|
||||
return STATUS_COLORS[entry.status || 'pending']
|
||||
}
|
||||
|
||||
// ---------- ImageCrop ----------
|
||||
|
||||
function ImageCrop({ imageUrl, bbox, naturalWidth, naturalHeight, maxWidth = 380, label }: {
|
||||
imageUrl: string
|
||||
bbox: BBox
|
||||
naturalWidth: number
|
||||
naturalHeight: number
|
||||
maxWidth?: number
|
||||
label?: string
|
||||
}) {
|
||||
if (!bbox || bbox.w === 0 || bbox.h === 0) return null
|
||||
|
||||
const cropWPx = (bbox.w / 100) * naturalWidth
|
||||
const cropHPx = (bbox.h / 100) * naturalHeight
|
||||
if (cropWPx < 1 || cropHPx < 1) return null
|
||||
|
||||
const scale = maxWidth / cropWPx
|
||||
const displayH = cropHPx * scale
|
||||
|
||||
return (
|
||||
<div>
|
||||
{label && <div className="text-xs font-medium text-slate-500 mb-1">{label}</div>}
|
||||
<div
|
||||
className="rounded-lg border border-slate-200 overflow-hidden bg-white"
|
||||
style={{ width: maxWidth, height: Math.min(displayH, 120), overflow: 'hidden', position: 'relative' }}
|
||||
>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: naturalWidth * scale,
|
||||
height: naturalHeight * scale,
|
||||
left: -(bbox.x / 100) * naturalWidth * scale,
|
||||
top: -(bbox.y / 100) * naturalHeight * scale,
|
||||
maxWidth: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Main Component ----------
|
||||
|
||||
export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: GroundTruthPanelProps) {
|
||||
const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
// State
|
||||
const [entries, setEntries] = useState<GTEntry[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [imageNatural, setImageNatural] = useState({ w: 0, h: 0 })
|
||||
const [showSummary, setShowSummary] = useState(false)
|
||||
const [savedMessage, setSavedMessage] = useState<string | null>(null)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [imageUrl, setImageUrl] = useState(pageImageUrl)
|
||||
const [deskewAngle, setDeskewAngle] = useState<number | null>(null)
|
||||
|
||||
// Editable fields for current entry
|
||||
const [editEn, setEditEn] = useState('')
|
||||
const [editDe, setEditDe] = useState('')
|
||||
const [editEx, setEditEx] = useState('')
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const enInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Reset image URL when page changes
|
||||
useEffect(() => {
|
||||
setImageUrl(pageImageUrl)
|
||||
setDeskewAngle(null)
|
||||
}, [pageImageUrl])
|
||||
|
||||
// Load natural image dimensions
|
||||
useEffect(() => {
|
||||
if (!imageUrl) return
|
||||
const img = new Image()
|
||||
img.onload = () => setImageNatural({ w: img.naturalWidth, h: img.naturalHeight })
|
||||
img.src = imageUrl
|
||||
}, [imageUrl])
|
||||
|
||||
// Sync edit fields when current entry changes
|
||||
useEffect(() => {
|
||||
const entry = entries[currentIndex]
|
||||
if (entry) {
|
||||
setEditEn(entry.english)
|
||||
setEditDe(entry.german)
|
||||
setEditEx(entry.example)
|
||||
}
|
||||
}, [currentIndex, entries])
|
||||
|
||||
// ---------- Actions ----------
|
||||
|
||||
const handleExtract = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setShowSummary(false)
|
||||
setSavedMessage(null)
|
||||
try {
|
||||
const res = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/extract-with-boxes/${selectedPage}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
||||
throw new Error(err.detail || 'Extract failed')
|
||||
}
|
||||
const data = await res.json()
|
||||
const loaded: GTEntry[] = (data.entries || []).map((e: GTEntry) => ({ ...e, status: 'pending' as const }))
|
||||
setEntries(loaded)
|
||||
setCurrentIndex(0)
|
||||
|
||||
// Switch to deskewed image if available
|
||||
if (data.deskewed) {
|
||||
setImageUrl(`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/deskewed-image/${selectedPage}`)
|
||||
setDeskewAngle(data.deskew_angle)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Extraction failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [sessionId, selectedPage])
|
||||
|
||||
const confirmEntry = useCallback(() => {
|
||||
if (entries.length === 0) return
|
||||
const entry = entries[currentIndex]
|
||||
const isEdited = editEn !== entry.english || editDe !== entry.german || editEx !== entry.example
|
||||
const updated = [...entries]
|
||||
updated[currentIndex] = {
|
||||
...entry,
|
||||
english: editEn,
|
||||
german: editDe,
|
||||
example: editEx,
|
||||
status: isEdited ? 'edited' : 'confirmed',
|
||||
}
|
||||
setEntries(updated)
|
||||
if (currentIndex < entries.length - 1) {
|
||||
setCurrentIndex(currentIndex + 1)
|
||||
} else {
|
||||
setShowSummary(true)
|
||||
}
|
||||
}, [entries, currentIndex, editEn, editDe, editEx])
|
||||
|
||||
const skipEntry = useCallback(() => {
|
||||
if (entries.length === 0) return
|
||||
const updated = [...entries]
|
||||
updated[currentIndex] = { ...updated[currentIndex], status: 'skipped' }
|
||||
setEntries(updated)
|
||||
if (currentIndex < entries.length - 1) {
|
||||
setCurrentIndex(currentIndex + 1)
|
||||
} else {
|
||||
setShowSummary(true)
|
||||
}
|
||||
}, [entries, currentIndex])
|
||||
|
||||
const goTo = useCallback((idx: number) => {
|
||||
if (idx >= 0 && idx < entries.length) {
|
||||
setCurrentIndex(idx)
|
||||
setShowSummary(false)
|
||||
}
|
||||
}, [entries.length])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/ground-truth/${selectedPage}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ entries }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Save failed')
|
||||
const data = await res.json()
|
||||
setSavedMessage(`Gespeichert: ${data.confirmed} bestaetigt, ${data.edited} editiert, ${data.skipped} uebersprungen`)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Save failed')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [sessionId, selectedPage, entries])
|
||||
|
||||
// ---------- Keyboard shortcuts ----------
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isFullscreen) {
|
||||
e.preventDefault()
|
||||
setIsFullscreen(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (entries.length === 0 || showSummary) return
|
||||
|
||||
// Don't capture when typing in inputs
|
||||
const tag = (e.target as HTMLElement)?.tagName
|
||||
const isInput = tag === 'INPUT' || tag === 'TEXTAREA'
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
confirmEntry()
|
||||
} else if (e.key === 'Tab' && !e.shiftKey) {
|
||||
if (!isInput) {
|
||||
e.preventDefault()
|
||||
skipEntry()
|
||||
}
|
||||
} else if (e.key === 'ArrowLeft' && !isInput) {
|
||||
e.preventDefault()
|
||||
goTo(currentIndex - 1)
|
||||
} else if (e.key === 'ArrowRight' && !isInput) {
|
||||
e.preventDefault()
|
||||
goTo(currentIndex + 1)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [entries.length, showSummary, isFullscreen, confirmEntry, skipEntry, goTo, currentIndex])
|
||||
|
||||
// ---------- Computed ----------
|
||||
|
||||
const currentEntry = entries[currentIndex]
|
||||
const confirmedCount = entries.filter(e => e.status === 'confirmed').length
|
||||
const editedCount = entries.filter(e => e.status === 'edited').length
|
||||
const skippedCount = entries.filter(e => e.status === 'skipped').length
|
||||
const processedCount = confirmedCount + editedCount + skippedCount
|
||||
const progress = entries.length > 0 ? Math.round((processedCount / entries.length) * 100) : 0
|
||||
|
||||
// ---------- Render: No entries yet ----------
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center" ref={panelRef}>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-2">Ground Truth Labeling</h3>
|
||||
<p className="text-sm text-slate-500 mb-6">
|
||||
Erkennung starten um Vokabeln mit Positionen zu extrahieren.
|
||||
Danach jede Zeile durchgehen und bestaetigen oder korrigieren.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleExtract}
|
||||
disabled={loading}
|
||||
className="px-6 py-3 bg-teal-600 text-white rounded-lg font-medium hover:bg-teal-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Erkennung laeuft...
|
||||
</span>
|
||||
) : 'Erkennung starten'}
|
||||
</button>
|
||||
{error && (
|
||||
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Render: Summary ----------
|
||||
|
||||
if (showSummary) {
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border border-slate-200 p-6 ${
|
||||
isFullscreen ? 'fixed inset-0 z-50 overflow-auto m-0 rounded-none' : ''
|
||||
}`} ref={panelRef}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Zusammenfassung</h3>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="p-1.5 rounded-lg hover:bg-slate-100 text-slate-500 hover:text-slate-700 transition-colors"
|
||||
title={isFullscreen ? 'Vollbild verlassen (Esc)' : 'Vollbild'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-700">{confirmedCount}</div>
|
||||
<div className="text-sm text-green-600">Bestaetigt</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-blue-700">{editedCount}</div>
|
||||
<div className="text-sm text-blue-600">Editiert</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-slate-700">{skippedCount}</div>
|
||||
<div className="text-sm text-slate-500">Uebersprungen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="flex-1 px-4 py-2.5 bg-teal-600 text-white rounded-lg font-medium hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Ground Truth speichern'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowSummary(false); setCurrentIndex(0) }}
|
||||
className="px-4 py-2.5 bg-slate-100 text-slate-700 rounded-lg font-medium hover:bg-slate-200"
|
||||
>
|
||||
Nochmal durchgehen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{savedMessage && (
|
||||
<div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg text-green-700 text-sm">
|
||||
{savedMessage}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Entry list for quick review */}
|
||||
<div className="mt-6 max-h-96 overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-white">
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-2 px-2 text-slate-500">#</th>
|
||||
<th className="text-left py-2 px-2 text-slate-500">English</th>
|
||||
<th className="text-left py-2 px-2 text-slate-500">Deutsch</th>
|
||||
<th className="text-left py-2 px-2 text-slate-500">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((e, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
onClick={() => goTo(i)}
|
||||
className="border-b border-slate-100 hover:bg-slate-50 cursor-pointer"
|
||||
>
|
||||
<td className="py-1.5 px-2 text-slate-400">{i + 1}</td>
|
||||
<td className="py-1.5 px-2">{e.english}</td>
|
||||
<td className="py-1.5 px-2">{e.german}</td>
|
||||
<td className="py-1.5 px-2">
|
||||
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
e.status === 'confirmed' ? 'bg-green-100 text-green-700' :
|
||||
e.status === 'edited' ? 'bg-blue-100 text-blue-700' :
|
||||
e.status === 'skipped' ? 'bg-slate-100 text-slate-500' :
|
||||
'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{e.status === 'confirmed' ? 'OK' :
|
||||
e.status === 'edited' ? 'Editiert' :
|
||||
e.status === 'skipped' ? 'Skip' : 'Offen'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Render: Main Review UI ----------
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border border-slate-200 overflow-hidden ${
|
||||
isFullscreen ? 'fixed inset-0 z-50 overflow-auto m-0 rounded-none bg-white' : ''
|
||||
}`} ref={panelRef}>
|
||||
{/* Header with progress + fullscreen toggle */}
|
||||
<div className="flex items-center gap-2 px-4 pt-2">
|
||||
<div className="flex-1 h-1.5 bg-slate-100 rounded-full">
|
||||
<div
|
||||
className="h-full bg-teal-500 transition-all duration-300 rounded-full"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 whitespace-nowrap">{currentIndex + 1}/{entries.length}</span>
|
||||
{deskewAngle !== null && (
|
||||
<span className="text-xs text-teal-600 whitespace-nowrap" title="Bild wurde begradigt">
|
||||
{deskewAngle.toFixed(1)}°
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="p-1.5 rounded-lg hover:bg-slate-100 text-slate-500 hover:text-slate-700 transition-colors"
|
||||
title={isFullscreen ? 'Vollbild verlassen (Esc)' : 'Vollbild'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`flex flex-col ${isFullscreen ? 'lg:flex-row h-[calc(100vh-3rem)]' : 'lg:flex-row'}`}>
|
||||
{/* Left: Page image with SVG overlay (2/3) */}
|
||||
<div className={`${isFullscreen ? 'lg:w-2/3 p-4 overflow-y-auto h-full' : 'lg:w-2/3 p-4'}`}>
|
||||
<div className="relative bg-slate-50 rounded-lg overflow-hidden">
|
||||
{imageUrl && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={`Seite ${selectedPage + 1}`}
|
||||
className="w-full"
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
{/* SVG Overlay */}
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
className="absolute inset-0 w-full h-full"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{entries.map((entry, i) => {
|
||||
const colors = getEntryColor(entry, i, currentIndex)
|
||||
return (
|
||||
<rect
|
||||
key={i}
|
||||
x={entry.bbox.x}
|
||||
y={entry.bbox.y}
|
||||
width={entry.bbox.w}
|
||||
height={entry.bbox.h}
|
||||
fill={colors.fill}
|
||||
stroke={colors.stroke}
|
||||
strokeWidth={i === currentIndex ? 0.3 : 0.15}
|
||||
style={{ cursor: 'pointer', pointerEvents: 'all' }}
|
||||
onClick={() => goTo(i)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-4 mt-3 text-xs text-slate-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-sm" style={{ background: STATUS_COLORS.current.fill, border: `1px solid ${STATUS_COLORS.current.stroke}` }} /> Aktuell
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-sm" style={{ background: STATUS_COLORS.confirmed.fill, border: `1px solid ${STATUS_COLORS.confirmed.stroke}` }} /> Bestaetigt
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-sm" style={{ background: STATUS_COLORS.edited.fill, border: `1px solid ${STATUS_COLORS.edited.stroke}` }} /> Editiert
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-sm" style={{ background: STATUS_COLORS.skipped.fill, border: `1px solid ${STATUS_COLORS.skipped.stroke}` }} /> Uebersprungen
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Crops + Edit fields (1/3) */}
|
||||
<div className={`lg:w-1/3 border-l border-slate-200 p-4 space-y-4 ${isFullscreen ? 'overflow-y-auto h-full' : ''}`}>
|
||||
{currentEntry && (
|
||||
<>
|
||||
{/* Row crop */}
|
||||
{imageNatural.w > 0 && (
|
||||
<ImageCrop
|
||||
imageUrl={imageUrl}
|
||||
bbox={currentEntry.bbox}
|
||||
naturalWidth={imageNatural.w}
|
||||
naturalHeight={imageNatural.h}
|
||||
label="Gesamte Zeile"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Column crops */}
|
||||
{imageNatural.w > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{currentEntry.bbox_en.w > 0 && (
|
||||
<ImageCrop
|
||||
imageUrl={imageUrl}
|
||||
bbox={currentEntry.bbox_en}
|
||||
naturalWidth={imageNatural.w}
|
||||
naturalHeight={imageNatural.h}
|
||||
maxWidth={120}
|
||||
label="EN"
|
||||
/>
|
||||
)}
|
||||
{currentEntry.bbox_de.w > 0 && (
|
||||
<ImageCrop
|
||||
imageUrl={imageUrl}
|
||||
bbox={currentEntry.bbox_de}
|
||||
naturalWidth={imageNatural.w}
|
||||
naturalHeight={imageNatural.h}
|
||||
maxWidth={120}
|
||||
label="DE"
|
||||
/>
|
||||
)}
|
||||
{currentEntry.bbox_ex.w > 0 && (
|
||||
<ImageCrop
|
||||
imageUrl={imageUrl}
|
||||
bbox={currentEntry.bbox_ex}
|
||||
naturalWidth={imageNatural.w}
|
||||
naturalHeight={imageNatural.h}
|
||||
maxWidth={120}
|
||||
label="EX"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confidence badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
currentEntry.confidence >= 70 ? 'bg-green-100 text-green-700' :
|
||||
currentEntry.confidence >= 40 ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
Konfidenz: {currentEntry.confidence}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Edit fields */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">English</label>
|
||||
<input
|
||||
ref={enInputRef}
|
||||
type="text"
|
||||
value={editEn}
|
||||
onChange={e => setEditEn(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">Deutsch</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editDe}
|
||||
onChange={e => setEditDe(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-500 mb-1">Beispiel</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editEx}
|
||||
onChange={e => setEditEx(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={confirmEntry}
|
||||
className="flex-1 px-4 py-2.5 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 text-sm"
|
||||
title="Enter"
|
||||
>
|
||||
OK (Enter)
|
||||
</button>
|
||||
<button
|
||||
onClick={skipEntry}
|
||||
className="flex-1 px-4 py-2.5 bg-slate-200 text-slate-700 rounded-lg font-medium hover:bg-slate-300 text-sm"
|
||||
title="Tab"
|
||||
>
|
||||
Skip (Tab)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => goTo(currentIndex - 1)}
|
||||
disabled={currentIndex === 0}
|
||||
className="px-3 py-1.5 bg-slate-100 rounded-lg text-sm text-slate-600 hover:bg-slate-200 disabled:opacity-30"
|
||||
>
|
||||
← Zurueck
|
||||
</button>
|
||||
<span className="text-sm text-slate-500 font-medium">
|
||||
{currentIndex + 1} / {entries.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => goTo(currentIndex + 1)}
|
||||
disabled={currentIndex === entries.length - 1}
|
||||
className="px-3 py-1.5 bg-slate-100 rounded-lg text-sm text-slate-600 hover:bg-slate-200 disabled:opacity-30"
|
||||
>
|
||||
Weiter →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress stats */}
|
||||
<div className="text-xs text-slate-400 text-center">
|
||||
{confirmedCount} bestaetigt · {editedCount} editiert · {skippedCount} uebersprungen · {progress}%
|
||||
</div>
|
||||
|
||||
{/* Keyboard hints */}
|
||||
<div className="text-xs text-slate-400 text-center border-t border-slate-100 pt-2">
|
||||
Enter = Bestaetigen · Tab = Ueberspringen · ←→ = Navigieren{isFullscreen ? ' \u00B7 Esc = Vollbild verlassen' : ''}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mx-4 mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { BlockReviewPanel, BlockReviewSummary, type BlockReviewData } from '../BlockReviewPanel'
|
||||
import type { GridData, GridCell } from '../GridOverlay'
|
||||
|
||||
// Mock grid data
|
||||
const createMockGrid = (rows: number = 3, cols: number = 3): GridData => {
|
||||
const cells: GridCell[][] = []
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const row: GridCell[] = []
|
||||
for (let c = 0; c < cols; c++) {
|
||||
row.push({
|
||||
row: r,
|
||||
col: c,
|
||||
x: (c / cols) * 100,
|
||||
y: (r / rows) * 100,
|
||||
width: 100 / cols,
|
||||
height: 100 / rows,
|
||||
text: r === 0 || c === 0 ? '' : `cell-${r}-${c}`,
|
||||
confidence: 0.85,
|
||||
status: r === 0 || c === 0 ? 'empty' : 'recognized',
|
||||
column_type: c === 1 ? 'english' : c === 2 ? 'german' : 'unknown',
|
||||
})
|
||||
}
|
||||
cells.push(row)
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
columns: cols,
|
||||
cells,
|
||||
column_types: ['unknown', 'english', 'german'],
|
||||
column_boundaries: [0, 33.33, 66.66, 100],
|
||||
row_boundaries: [0, 33.33, 66.66, 100],
|
||||
deskew_angle: 0,
|
||||
stats: {
|
||||
recognized: 4,
|
||||
problematic: 0,
|
||||
empty: 5,
|
||||
total: 9,
|
||||
coverage: 0.44,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const createMockMethodResults = () => ({
|
||||
vision_llm: {
|
||||
vocabulary: [
|
||||
{ english: 'word1', german: 'Wort1' },
|
||||
{ english: 'word2', german: 'Wort2' },
|
||||
],
|
||||
},
|
||||
tesseract: {
|
||||
vocabulary: [
|
||||
{ english: 'word1', german: 'Wort1' },
|
||||
{ english: 'word2', german: 'Wort2' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
describe('BlockReviewPanel', () => {
|
||||
const mockOnBlockChange = vi.fn()
|
||||
const mockOnApprove = vi.fn()
|
||||
const mockOnCorrect = vi.fn()
|
||||
const mockOnSkip = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the current block number', () => {
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={createMockGrid()}
|
||||
methodResults={createMockMethodResults()}
|
||||
currentBlockNumber={5}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={{}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Block 5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display progress percentage', () => {
|
||||
const reviewData: Record<number, BlockReviewData> = {
|
||||
5: {
|
||||
blockNumber: 5,
|
||||
cell: createMockGrid().cells[1][1],
|
||||
methodResults: [],
|
||||
status: 'approved',
|
||||
correctedText: 'approved text',
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={createMockGrid()}
|
||||
methodResults={createMockMethodResults()}
|
||||
currentBlockNumber={5}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={reviewData}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('25%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show cell position information', () => {
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={createMockGrid()}
|
||||
methodResults={createMockMethodResults()}
|
||||
currentBlockNumber={5}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={{}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Position:')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display method results', () => {
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={createMockGrid()}
|
||||
methodResults={createMockMethodResults()}
|
||||
currentBlockNumber={5}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={{}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Erkannte Texte:')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onSkip when skip button is clicked', () => {
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={createMockGrid()}
|
||||
methodResults={createMockMethodResults()}
|
||||
currentBlockNumber={5}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={{}}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Überspringen'))
|
||||
expect(mockOnSkip).toHaveBeenCalledWith(5)
|
||||
})
|
||||
|
||||
it('should show manual correction button', () => {
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={createMockGrid()}
|
||||
methodResults={createMockMethodResults()}
|
||||
currentBlockNumber={5}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={{}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('+ Manuell korrigieren')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show correction input when correction button is clicked', () => {
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={createMockGrid()}
|
||||
methodResults={createMockMethodResults()}
|
||||
currentBlockNumber={5}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={{}}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('+ Manuell korrigieren'))
|
||||
expect(screen.getByPlaceholderText('Korrekten Text eingeben...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onCorrect when correction is submitted', () => {
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={createMockGrid()}
|
||||
methodResults={createMockMethodResults()}
|
||||
currentBlockNumber={5}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={{}}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('+ Manuell korrigieren'))
|
||||
const input = screen.getByPlaceholderText('Korrekten Text eingeben...')
|
||||
fireEvent.change(input, { target: { value: 'corrected text' } })
|
||||
fireEvent.click(screen.getByText('Übernehmen'))
|
||||
|
||||
expect(mockOnCorrect).toHaveBeenCalledWith(5, 'corrected text')
|
||||
})
|
||||
|
||||
it('should show approved status when block is approved', () => {
|
||||
const reviewData: Record<number, BlockReviewData> = {
|
||||
5: {
|
||||
blockNumber: 5,
|
||||
cell: createMockGrid().cells[1][1],
|
||||
methodResults: [],
|
||||
status: 'approved',
|
||||
correctedText: 'approved text',
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={createMockGrid()}
|
||||
methodResults={createMockMethodResults()}
|
||||
currentBlockNumber={5}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={reviewData}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText(/Freigegeben:/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable previous button on first block', () => {
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={createMockGrid()}
|
||||
methodResults={createMockMethodResults()}
|
||||
currentBlockNumber={5}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={{}}
|
||||
/>
|
||||
)
|
||||
|
||||
const prevButton = screen.getByText('Zurück').closest('button')
|
||||
expect(prevButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should show empty message when no blocks available', () => {
|
||||
const emptyGrid: GridData = {
|
||||
...createMockGrid(),
|
||||
cells: [[{
|
||||
row: 0,
|
||||
col: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
text: '',
|
||||
confidence: 0,
|
||||
status: 'empty'
|
||||
}]],
|
||||
}
|
||||
|
||||
render(
|
||||
<BlockReviewPanel
|
||||
grid={emptyGrid}
|
||||
methodResults={{}}
|
||||
currentBlockNumber={1}
|
||||
onBlockChange={mockOnBlockChange}
|
||||
onApprove={mockOnApprove}
|
||||
onCorrect={mockOnCorrect}
|
||||
onSkip={mockOnSkip}
|
||||
reviewData={{}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Keine Blöcke zum Überprüfen')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BlockReviewSummary', () => {
|
||||
const mockOnBlockClick = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should display summary statistics', () => {
|
||||
const reviewData: Record<number, BlockReviewData> = {
|
||||
1: { blockNumber: 1, cell: {} as GridCell, methodResults: [], status: 'approved', correctedText: 'text1' },
|
||||
2: { blockNumber: 2, cell: {} as GridCell, methodResults: [], status: 'corrected', correctedText: 'text2' },
|
||||
3: { blockNumber: 3, cell: {} as GridCell, methodResults: [], status: 'skipped' },
|
||||
}
|
||||
|
||||
render(
|
||||
<BlockReviewSummary
|
||||
reviewData={reviewData}
|
||||
totalBlocks={5}
|
||||
onBlockClick={mockOnBlockClick}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Überprüfungsübersicht')).toBeInTheDocument()
|
||||
expect(screen.getByText('1')).toBeInTheDocument() // approved count
|
||||
expect(screen.getByText('Freigegeben')).toBeInTheDocument()
|
||||
expect(screen.getByText('Korrigiert')).toBeInTheDocument()
|
||||
expect(screen.getByText('Übersprungen')).toBeInTheDocument()
|
||||
expect(screen.getByText('Ausstehend')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onBlockClick when a block is clicked', () => {
|
||||
const reviewData: Record<number, BlockReviewData> = {
|
||||
5: { blockNumber: 5, cell: {} as GridCell, methodResults: [], status: 'approved', correctedText: 'text' },
|
||||
}
|
||||
|
||||
render(
|
||||
<BlockReviewSummary
|
||||
reviewData={reviewData}
|
||||
totalBlocks={10}
|
||||
onBlockClick={mockOnBlockClick}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Block 5'))
|
||||
expect(mockOnBlockClick).toHaveBeenCalledWith(5)
|
||||
})
|
||||
|
||||
it('should show correct counts for each status', () => {
|
||||
const reviewData: Record<number, BlockReviewData> = {
|
||||
1: { blockNumber: 1, cell: {} as GridCell, methodResults: [], status: 'approved', correctedText: 't1' },
|
||||
2: { blockNumber: 2, cell: {} as GridCell, methodResults: [], status: 'approved', correctedText: 't2' },
|
||||
3: { blockNumber: 3, cell: {} as GridCell, methodResults: [], status: 'corrected', correctedText: 't3' },
|
||||
}
|
||||
|
||||
render(
|
||||
<BlockReviewSummary
|
||||
reviewData={reviewData}
|
||||
totalBlocks={5}
|
||||
onBlockClick={mockOnBlockClick}
|
||||
/>
|
||||
)
|
||||
|
||||
// 2 approved, 1 corrected, 0 skipped, 2 pending
|
||||
const approvedCount = screen.getAllByText('2')[0]
|
||||
expect(approvedCount).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should truncate long corrected text', () => {
|
||||
const reviewData: Record<number, BlockReviewData> = {
|
||||
1: {
|
||||
blockNumber: 1,
|
||||
cell: {} as GridCell,
|
||||
methodResults: [],
|
||||
status: 'approved',
|
||||
correctedText: 'This is a very long text that should be truncated'
|
||||
},
|
||||
}
|
||||
|
||||
render(
|
||||
<BlockReviewSummary
|
||||
reviewData={reviewData}
|
||||
totalBlocks={1}
|
||||
onBlockClick={mockOnBlockClick}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText(/This is a very long t\.\.\./)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { GridOverlay, GridStats, GridLegend, getCellBlockNumber } from '../GridOverlay'
|
||||
import type { GridData, GridCell } from '../GridOverlay'
|
||||
|
||||
// Helper to create mock grid data
|
||||
const createMockGrid = (rows: number = 3, cols: number = 3): GridData => {
|
||||
const cells: GridCell[][] = []
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const row: GridCell[] = []
|
||||
for (let c = 0; c < cols; c++) {
|
||||
row.push({
|
||||
row: r,
|
||||
col: c,
|
||||
x: (c / cols) * 100,
|
||||
y: (r / rows) * 100,
|
||||
width: 100 / cols,
|
||||
height: 100 / rows,
|
||||
text: `cell-${r}-${c}`,
|
||||
confidence: 0.85,
|
||||
status: r === 0 ? 'empty' : c === 0 ? 'problematic' : 'recognized',
|
||||
column_type: c === 1 ? 'english' : c === 2 ? 'german' : 'unknown',
|
||||
})
|
||||
}
|
||||
cells.push(row)
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
columns: cols,
|
||||
cells,
|
||||
column_types: ['unknown', 'english', 'german'],
|
||||
column_boundaries: [0, 33.33, 66.66, 100],
|
||||
row_boundaries: [0, 33.33, 66.66, 100],
|
||||
deskew_angle: 0,
|
||||
stats: {
|
||||
recognized: 4,
|
||||
problematic: 2,
|
||||
empty: 3,
|
||||
manual: 0,
|
||||
total: 9,
|
||||
coverage: 0.67,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('getCellBlockNumber', () => {
|
||||
it('should return correct block number for first cell', () => {
|
||||
const grid = createMockGrid(3, 4)
|
||||
const cell: GridCell = { row: 0, col: 0, x: 0, y: 0, width: 25, height: 33, text: '', confidence: 1, status: 'empty' }
|
||||
|
||||
expect(getCellBlockNumber(cell, grid)).toBe(1)
|
||||
})
|
||||
|
||||
it('should return correct block number for cell in second row', () => {
|
||||
const grid = createMockGrid(3, 4)
|
||||
const cell: GridCell = { row: 1, col: 0, x: 0, y: 33, width: 25, height: 33, text: '', confidence: 1, status: 'empty' }
|
||||
|
||||
expect(getCellBlockNumber(cell, grid)).toBe(5)
|
||||
})
|
||||
|
||||
it('should return correct block number for last cell', () => {
|
||||
const grid = createMockGrid(3, 4)
|
||||
const cell: GridCell = { row: 2, col: 3, x: 75, y: 66, width: 25, height: 33, text: '', confidence: 1, status: 'empty' }
|
||||
|
||||
expect(getCellBlockNumber(cell, grid)).toBe(12)
|
||||
})
|
||||
|
||||
it('should calculate correctly for different grid sizes', () => {
|
||||
const grid = createMockGrid(5, 5)
|
||||
const cell: GridCell = { row: 2, col: 3, x: 60, y: 40, width: 20, height: 20, text: '', confidence: 1, status: 'empty' }
|
||||
|
||||
expect(getCellBlockNumber(cell, grid)).toBe(14) // row 2 * 5 cols + col 3 + 1 = 14
|
||||
})
|
||||
})
|
||||
|
||||
describe('GridOverlay', () => {
|
||||
it('should render without crashing', () => {
|
||||
render(<GridOverlay grid={createMockGrid()} />)
|
||||
expect(document.querySelector('svg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render image when imageUrl is provided', () => {
|
||||
render(<GridOverlay grid={createMockGrid()} imageUrl="https://example.com/image.jpg" />)
|
||||
const img = screen.getByAltText('Document')
|
||||
expect(img).toBeInTheDocument()
|
||||
expect(img).toHaveAttribute('src', 'https://example.com/image.jpg')
|
||||
})
|
||||
|
||||
it('should call onCellClick when a non-empty cell is clicked', () => {
|
||||
const mockOnClick = vi.fn()
|
||||
const grid = createMockGrid()
|
||||
|
||||
render(<GridOverlay grid={grid} onCellClick={mockOnClick} />)
|
||||
|
||||
// Find a recognized cell (non-empty) and click it
|
||||
const recognizedCells = document.querySelectorAll('rect')
|
||||
// Click on a cell that should be clickable (recognized status)
|
||||
recognizedCells.forEach(rect => {
|
||||
if (rect.getAttribute('fill')?.includes('34, 197, 94')) {
|
||||
fireEvent.click(rect)
|
||||
}
|
||||
})
|
||||
|
||||
// onCellClick should have been called for recognized cells
|
||||
expect(mockOnClick).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not call onCellClick for empty cells', () => {
|
||||
const mockOnClick = vi.fn()
|
||||
const emptyGrid: GridData = {
|
||||
...createMockGrid(),
|
||||
cells: [[{ row: 0, col: 0, x: 0, y: 0, width: 100, height: 100, text: '', confidence: 0, status: 'empty' }]],
|
||||
}
|
||||
|
||||
render(<GridOverlay grid={emptyGrid} onCellClick={mockOnClick} showEmpty />)
|
||||
|
||||
const cell = document.querySelector('rect')
|
||||
if (cell) {
|
||||
fireEvent.click(cell)
|
||||
}
|
||||
|
||||
expect(mockOnClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show column labels when showLabels is true', () => {
|
||||
render(<GridOverlay grid={createMockGrid()} showLabels />)
|
||||
|
||||
// Check for column type labels
|
||||
const svgTexts = document.querySelectorAll('text')
|
||||
const labels = Array.from(svgTexts).map(t => t.textContent)
|
||||
expect(labels).toContain('EN')
|
||||
expect(labels).toContain('DE')
|
||||
})
|
||||
|
||||
it('should hide empty cells when showEmpty is false', () => {
|
||||
const grid = createMockGrid()
|
||||
const emptyCellCount = grid.cells.flat().filter(c => c.status === 'empty').length
|
||||
|
||||
const { container } = render(<GridOverlay grid={grid} showEmpty={false} />)
|
||||
const allRects = container.querySelectorAll('g > rect')
|
||||
|
||||
// Should have fewer rects when empty cells are hidden
|
||||
const totalCells = grid.rows * grid.columns
|
||||
expect(allRects.length).toBeLessThan(totalCells * 2) // each cell can have multiple rects
|
||||
})
|
||||
|
||||
it('should show block numbers when showNumbers is true', () => {
|
||||
render(<GridOverlay grid={createMockGrid()} showNumbers />)
|
||||
|
||||
const svgTexts = document.querySelectorAll('text')
|
||||
const numbers = Array.from(svgTexts).map(t => t.textContent).filter(t => /^\d+$/.test(t || ''))
|
||||
|
||||
expect(numbers.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should highlight a specific block when highlightedBlockNumber is set', () => {
|
||||
const grid = createMockGrid()
|
||||
|
||||
render(<GridOverlay grid={grid} showNumbers highlightedBlockNumber={5} />)
|
||||
|
||||
// Check that there's a highlighted element (with indigo color)
|
||||
const rects = document.querySelectorAll('rect')
|
||||
const highlightedRect = Array.from(rects).find(
|
||||
rect => rect.getAttribute('stroke') === '#4f46e5'
|
||||
)
|
||||
|
||||
expect(highlightedRect).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<GridOverlay grid={createMockGrid()} className="custom-class" />)
|
||||
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should render row and column boundary lines', () => {
|
||||
render(<GridOverlay grid={createMockGrid()} />)
|
||||
|
||||
const lines = document.querySelectorAll('line')
|
||||
expect(lines.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GridStats', () => {
|
||||
const mockStats = {
|
||||
recognized: 10,
|
||||
problematic: 3,
|
||||
empty: 5,
|
||||
manual: 2,
|
||||
total: 20,
|
||||
coverage: 0.75,
|
||||
}
|
||||
|
||||
it('should display recognized count', () => {
|
||||
render(<GridStats stats={mockStats} />)
|
||||
expect(screen.getByText('Erkannt: 10')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display problematic count when greater than 0', () => {
|
||||
render(<GridStats stats={mockStats} />)
|
||||
expect(screen.getByText('Problematisch: 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not display problematic when count is 0', () => {
|
||||
render(<GridStats stats={{ ...mockStats, problematic: 0 }} />)
|
||||
expect(screen.queryByText(/Problematisch:/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display manual count when greater than 0', () => {
|
||||
render(<GridStats stats={mockStats} />)
|
||||
expect(screen.getByText('Manuell: 2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not display manual when count is 0', () => {
|
||||
render(<GridStats stats={{ ...mockStats, manual: 0 }} />)
|
||||
expect(screen.queryByText(/Manuell:/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display empty count', () => {
|
||||
render(<GridStats stats={mockStats} />)
|
||||
expect(screen.getByText('Leer: 5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display coverage percentage', () => {
|
||||
render(<GridStats stats={mockStats} />)
|
||||
expect(screen.getByText('Abdeckung: 75%')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display deskew angle when provided and non-zero', () => {
|
||||
render(<GridStats stats={mockStats} deskewAngle={2.5} />)
|
||||
expect(screen.getByText('Begradigt: 2.5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not display deskew angle when 0', () => {
|
||||
render(<GridStats stats={mockStats} deskewAngle={0} />)
|
||||
expect(screen.queryByText(/Begradigt:/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<GridStats stats={mockStats} className="custom-stats" />)
|
||||
expect(container.firstChild).toHaveClass('custom-stats')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GridLegend', () => {
|
||||
it('should display all status labels', () => {
|
||||
render(<GridLegend />)
|
||||
|
||||
expect(screen.getByText('Erkannt')).toBeInTheDocument()
|
||||
expect(screen.getByText('Problematisch')).toBeInTheDocument()
|
||||
expect(screen.getByText('Manuell korrigiert')).toBeInTheDocument()
|
||||
expect(screen.getByText('Leer')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render color indicators for each status', () => {
|
||||
const { container } = render(<GridLegend />)
|
||||
|
||||
const colorIndicators = container.querySelectorAll('.w-4.h-4.rounded')
|
||||
expect(colorIndicators.length).toBe(4)
|
||||
})
|
||||
|
||||
it('should apply custom className', () => {
|
||||
const { container } = render(<GridLegend className="custom-legend" />)
|
||||
expect(container.firstChild).toHaveClass('custom-legend')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* OCR Components
|
||||
*
|
||||
* Components for OCR grid detection and visualization.
|
||||
*/
|
||||
|
||||
export { GridOverlay, GridStats, GridLegend, getCellBlockNumber } from './GridOverlay'
|
||||
export type { GridCell, GridData, CellStatus } from './GridOverlay'
|
||||
|
||||
export { CellCorrectionDialog } from './CellCorrectionDialog'
|
||||
|
||||
export { BlockReviewPanel, BlockReviewSummary } from './BlockReviewPanel'
|
||||
export type { BlockStatus, MethodResult, BlockReviewData } from './BlockReviewPanel'
|
||||
|
||||
export { GroundTruthPanel } from './GroundTruthPanel'
|
||||
@@ -0,0 +1,100 @@
|
||||
'use client'
|
||||
|
||||
import type { ArchitectureContext as ArchitectureContextType } from './types'
|
||||
|
||||
interface ArchitectureContextProps {
|
||||
context: ArchitectureContextType
|
||||
currentStep?: string
|
||||
highlightedComponents?: string[]
|
||||
}
|
||||
|
||||
const LAYER_CONFIG = {
|
||||
frontend: { label: 'Frontend', color: 'blue', icon: '🖥️' },
|
||||
api: { label: 'API', color: 'purple', icon: '🔌' },
|
||||
service: { label: 'Service', color: 'green', icon: '⚙️' },
|
||||
database: { label: 'Datenbank', color: 'orange', icon: '🗄️' },
|
||||
}
|
||||
|
||||
export function ArchitectureContext({ context, currentStep, highlightedComponents = [] }: ArchitectureContextProps) {
|
||||
const layerConfig = LAYER_CONFIG[context.layer]
|
||||
|
||||
return (
|
||||
<div className="bg-slate-900 rounded-lg p-6 mb-6">
|
||||
<h4 className="text-white font-medium mb-4 flex items-center">
|
||||
<span className="mr-2">🏗️</span>
|
||||
Architektur-Kontext{currentStep && `: ${currentStep}`}
|
||||
</h4>
|
||||
|
||||
{/* Data Flow Visualization */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-center flex-wrap gap-2">
|
||||
{context.dataFlow.map((component, index) => {
|
||||
const isHighlighted = highlightedComponents.includes(component.toLowerCase())
|
||||
const isCurrentLayer = component.toLowerCase().includes(context.layer)
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center">
|
||||
<div className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
isHighlighted || isCurrentLayer
|
||||
? 'bg-blue-500 text-white ring-2 ring-blue-300'
|
||||
: 'bg-slate-700 text-slate-300'
|
||||
}`}>
|
||||
{component}
|
||||
{isCurrentLayer && (
|
||||
<span className="ml-2 text-xs">← Sie sind hier</span>
|
||||
)}
|
||||
</div>
|
||||
{index < context.dataFlow.length - 1 && (
|
||||
<span className="mx-2 text-slate-500">→</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Layer Info */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="bg-slate-800 rounded-lg p-4">
|
||||
<h5 className="text-slate-400 text-xs uppercase tracking-wide mb-2">Aktuelle Schicht</h5>
|
||||
<div className="flex items-center">
|
||||
<span className="text-xl mr-2">{layerConfig.icon}</span>
|
||||
<span className={`text-${layerConfig.color}-400 font-medium`}>
|
||||
{layerConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-800 rounded-lg p-4">
|
||||
<h5 className="text-slate-400 text-xs uppercase tracking-wide mb-2">Beteiligte Services</h5>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{context.services.map((service) => (
|
||||
<span
|
||||
key={service}
|
||||
className="px-2 py-1 bg-slate-700 text-slate-300 text-xs rounded"
|
||||
>
|
||||
{service}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
{context.dependencies.length > 0 && (
|
||||
<div className="pt-4 border-t border-slate-700">
|
||||
<h5 className="text-slate-400 text-xs uppercase tracking-wide mb-2">Abhaengigkeiten</h5>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{context.dependencies.map((dep) => (
|
||||
<span
|
||||
key={dep}
|
||||
className="px-2 py-1 bg-amber-900/50 text-amber-300 text-xs rounded border border-amber-700"
|
||||
>
|
||||
{dep}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import type { EducationContent } from './types'
|
||||
|
||||
interface EducationCardProps {
|
||||
content: EducationContent | undefined
|
||||
}
|
||||
|
||||
export function EducationCard({ content }: EducationCardProps) {
|
||||
if (!content) return null
|
||||
|
||||
return (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold text-blue-800 mb-4 flex items-center">
|
||||
<span className="mr-2">💡</span>
|
||||
Warum ist das wichtig?
|
||||
</h3>
|
||||
<h4 className="text-md font-medium text-blue-700 mb-3">{content.title}</h4>
|
||||
<div className="space-y-2 text-blue-900">
|
||||
{content.content.map((line, index) => (
|
||||
<p key={index} className={line.startsWith('•') ? 'ml-4' : ''}>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import type { TestResult } from './types'
|
||||
|
||||
interface TestResultCardProps {
|
||||
result: TestResult
|
||||
}
|
||||
|
||||
export function TestResultCard({ result }: TestResultCardProps) {
|
||||
const statusColors = {
|
||||
passed: 'bg-green-100 border-green-300 text-green-800',
|
||||
failed: 'bg-red-100 border-red-300 text-red-800',
|
||||
pending: 'bg-yellow-100 border-yellow-300 text-yellow-800',
|
||||
skipped: 'bg-gray-100 border-gray-300 text-gray-600',
|
||||
}
|
||||
|
||||
const statusIcons = {
|
||||
passed: '✓',
|
||||
failed: '✗',
|
||||
pending: '○',
|
||||
skipped: '−',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`border rounded-lg p-4 mb-3 ${statusColors[result.status]}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium flex items-center">
|
||||
<span className="mr-2">{statusIcons[result.status]}</span>
|
||||
{result.name}
|
||||
</h4>
|
||||
<p className="text-sm opacity-80 mt-1">{result.description}</p>
|
||||
</div>
|
||||
<span className="text-xs opacity-60">{result.duration_ms.toFixed(0)}ms</span>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium">Erwartet:</span>
|
||||
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
|
||||
{result.expected}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Erhalten:</span>
|
||||
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
|
||||
{result.actual}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
{result.error_message && (
|
||||
<div className="mt-2 text-xs text-red-700 bg-red-50 p-2 rounded">
|
||||
Fehler: {result.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import { TestResultCard } from './TestResultCard'
|
||||
import type { TestCategoryResult } from './types'
|
||||
|
||||
interface TestRunnerProps {
|
||||
category: string
|
||||
categoryResult?: TestCategoryResult
|
||||
isLoading: boolean
|
||||
onRunTests: () => void
|
||||
runButtonLabel?: string
|
||||
rerunButtonLabel?: string
|
||||
}
|
||||
|
||||
export function TestRunner({
|
||||
categoryResult,
|
||||
isLoading,
|
||||
onRunTests,
|
||||
runButtonLabel = '▶️ Tests ausfuehren',
|
||||
rerunButtonLabel = '🔄 Erneut ausfuehren',
|
||||
}: TestRunnerProps) {
|
||||
if (!categoryResult) {
|
||||
return (
|
||||
<div className="text-center py-6">
|
||||
<button
|
||||
onClick={onRunTests}
|
||||
disabled={isLoading}
|
||||
className={`px-6 py-3 rounded-lg font-medium transition-colors ${
|
||||
isLoading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-green-600 text-white hover:bg-green-700'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? '⏳ Tests laufen...' : runButtonLabel}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-gray-700">Testergebnisse</h3>
|
||||
<button
|
||||
onClick={onRunTests}
|
||||
disabled={isLoading}
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{rerunButtonLabel}
|
||||
</button>
|
||||
</div>
|
||||
{categoryResult.tests.map((test, index) => (
|
||||
<TestResultCard key={index} result={test} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import type { FullTestResults } from './types'
|
||||
|
||||
interface TestSummaryProps {
|
||||
results: FullTestResults
|
||||
}
|
||||
|
||||
export function TestSummary({ results }: TestSummaryProps) {
|
||||
const passRate = results.total_tests > 0
|
||||
? ((results.total_passed / results.total_tests) * 100).toFixed(1)
|
||||
: '0'
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h3 className="text-xl font-bold mb-4">Test-Ergebnisse</h3>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-gray-700">{results.total_tests}</div>
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
</div>
|
||||
<div className="bg-green-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-green-600">{results.total_passed}</div>
|
||||
<div className="text-sm text-green-600">Bestanden</div>
|
||||
</div>
|
||||
<div className="bg-red-50 rounded-lg p-4 text-center">
|
||||
<div className="text-3xl font-bold text-red-600">{results.total_failed}</div>
|
||||
<div className="text-sm text-red-600">Fehlgeschlagen</div>
|
||||
</div>
|
||||
<div className={`rounded-lg p-4 text-center ${
|
||||
parseFloat(passRate) >= 80 ? 'bg-green-50' : parseFloat(passRate) >= 50 ? 'bg-yellow-50' : 'bg-red-50'
|
||||
}`}>
|
||||
<div className={`text-3xl font-bold ${
|
||||
parseFloat(passRate) >= 80 ? 'text-green-600' : parseFloat(passRate) >= 50 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>{passRate}%</div>
|
||||
<div className="text-sm text-gray-500">Erfolgsrate</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Results */}
|
||||
<div className="space-y-4">
|
||||
{results.categories.map((category) => (
|
||||
<div key={category.category} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium">{category.display_name}</h4>
|
||||
<span className={`text-sm px-2 py-1 rounded ${
|
||||
category.failed === 0 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{category.passed}/{category.total} bestanden
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{category.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="mt-6 text-sm text-gray-500 text-right">
|
||||
Gesamtdauer: {(results.duration_ms / 1000).toFixed(2)}s
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
interface WizardBannerProps {
|
||||
module: string
|
||||
title: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function WizardBanner({ module, title, description }: WizardBannerProps) {
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-blue-50 to-purple-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<span className="text-2xl mr-3">🎓</span>
|
||||
<div>
|
||||
<h3 className="font-medium text-blue-800">Lern-Wizard: {title}</h3>
|
||||
<p className="text-sm text-blue-600">
|
||||
{description || 'Interaktives Onboarding mit Tests und Architektur-Erklaerungen'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={`/admin/${module}/wizard`}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Wizard starten →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
interface WizardNavigationProps {
|
||||
currentStep: number
|
||||
totalSteps: number
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
showNext?: boolean
|
||||
isLoading?: boolean
|
||||
nextLabel?: string
|
||||
prevLabel?: string
|
||||
}
|
||||
|
||||
export function WizardNavigation({
|
||||
currentStep,
|
||||
totalSteps,
|
||||
onPrev,
|
||||
onNext,
|
||||
showNext = true,
|
||||
isLoading = false,
|
||||
nextLabel = 'Weiter →',
|
||||
prevLabel = '← Zurueck',
|
||||
}: WizardNavigationProps) {
|
||||
return (
|
||||
<div className="flex justify-between mt-8 pt-6 border-t">
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={currentStep === 0 || isLoading}
|
||||
className={`px-6 py-2 rounded-lg transition-colors ${
|
||||
currentStep === 0 || isLoading
|
||||
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
{prevLabel}
|
||||
</button>
|
||||
|
||||
{showNext && currentStep < totalSteps - 1 && (
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={isLoading}
|
||||
className={`px-6 py-2 rounded-lg transition-colors ${
|
||||
isLoading
|
||||
? 'bg-blue-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? 'Bitte warten...' : nextLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, ReactNode, useCallback } from 'react'
|
||||
import type { WizardStep, WizardContextValue, TestCategoryResult, FullTestResults } from './types'
|
||||
|
||||
const WizardContext = createContext<WizardContextValue | null>(null)
|
||||
|
||||
export function useWizard(): WizardContextValue {
|
||||
const context = useContext(WizardContext)
|
||||
if (!context) {
|
||||
throw new Error('useWizard must be used within a WizardProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
interface WizardProviderProps {
|
||||
children: ReactNode
|
||||
initialSteps: WizardStep[]
|
||||
module: string
|
||||
}
|
||||
|
||||
export function WizardProvider({ children, initialSteps, module }: WizardProviderProps) {
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [steps, setSteps] = useState<WizardStep[]>(initialSteps)
|
||||
const [categoryResults, setCategoryResults] = useState<Record<string, TestCategoryResult>>({})
|
||||
const [fullResults, setFullResults] = useState<FullTestResults | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
if (currentStep < steps.length - 1) {
|
||||
setSteps((prev) =>
|
||||
prev.map((step, idx) =>
|
||||
idx === currentStep && step.status === 'pending'
|
||||
? { ...step, status: 'completed' }
|
||||
: step
|
||||
)
|
||||
)
|
||||
setCurrentStep((prev) => prev + 1)
|
||||
}
|
||||
}, [currentStep, steps.length])
|
||||
|
||||
const goToPrev = useCallback(() => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep((prev) => prev - 1)
|
||||
}
|
||||
}, [currentStep])
|
||||
|
||||
const value: WizardContextValue = {
|
||||
currentStep,
|
||||
steps,
|
||||
setCurrentStep,
|
||||
setSteps,
|
||||
goToNext,
|
||||
goToPrev,
|
||||
module,
|
||||
categoryResults,
|
||||
setCategoryResults,
|
||||
fullResults,
|
||||
setFullResults,
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
error,
|
||||
setError,
|
||||
}
|
||||
|
||||
return (
|
||||
<WizardContext.Provider value={value}>
|
||||
{children}
|
||||
</WizardContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import type { WizardStep } from './types'
|
||||
|
||||
interface WizardStepperProps {
|
||||
steps: WizardStep[]
|
||||
currentStep: number
|
||||
onStepClick: (index: number) => void
|
||||
}
|
||||
|
||||
export function WizardStepper({ steps, currentStep, onStepClick }: WizardStepperProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-8 overflow-x-auto pb-4">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<button
|
||||
onClick={() => onStepClick(index)}
|
||||
className={`flex flex-col items-center min-w-[80px] p-2 rounded-lg transition-colors ${
|
||||
index === currentStep
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: step.status === 'completed'
|
||||
? 'bg-green-100 text-green-700 cursor-pointer hover:bg-green-200'
|
||||
: step.status === 'failed'
|
||||
? 'bg-red-100 text-red-700 cursor-pointer hover:bg-red-200'
|
||||
: 'text-gray-400'
|
||||
}`}
|
||||
disabled={index > currentStep && steps[index - 1]?.status === 'pending'}
|
||||
>
|
||||
<span className="text-2xl mb-1">{step.icon}</span>
|
||||
<span className="text-xs font-medium text-center">{step.name}</span>
|
||||
{step.status === 'completed' && <span className="text-xs text-green-600">✓</span>}
|
||||
{step.status === 'failed' && <span className="text-xs text-red-600">✗</span>}
|
||||
</button>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`h-0.5 w-8 mx-1 ${
|
||||
index < currentStep ? 'bg-green-400' : 'bg-gray-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Wizard Framework Components
|
||||
// ============================
|
||||
// Wiederverwendbare Komponenten fuer Admin-Modul-Wizards
|
||||
|
||||
// Context & Provider
|
||||
export { WizardProvider, useWizard } from './WizardProvider'
|
||||
|
||||
// UI Components
|
||||
export { WizardStepper } from './WizardStepper'
|
||||
export { WizardNavigation } from './WizardNavigation'
|
||||
export { WizardBanner } from './WizardBanner'
|
||||
|
||||
// Education Components
|
||||
export { EducationCard } from './EducationCard'
|
||||
export { ArchitectureContext } from './ArchitectureContext'
|
||||
|
||||
// Test Components
|
||||
export { TestRunner } from './TestRunner'
|
||||
export { TestResultCard } from './TestResultCard'
|
||||
export { TestSummary } from './TestSummary'
|
||||
|
||||
// Types
|
||||
export type {
|
||||
WizardStep,
|
||||
StepStatus,
|
||||
TestResult,
|
||||
TestCategoryResult,
|
||||
FullTestResults,
|
||||
EducationContent,
|
||||
StepEducation,
|
||||
ModuleEducation,
|
||||
ArchitectureContext as ArchitectureContextType,
|
||||
WizardContextValue,
|
||||
} from './types'
|
||||
@@ -0,0 +1,104 @@
|
||||
// ==============================================
|
||||
// Wizard Framework Types
|
||||
// ==============================================
|
||||
|
||||
export type StepStatus = 'pending' | 'active' | 'completed' | 'failed'
|
||||
|
||||
export interface WizardStep {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
status: StepStatus
|
||||
category?: string
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
name: string
|
||||
description: string
|
||||
expected: string
|
||||
actual: string
|
||||
status: 'passed' | 'failed' | 'pending' | 'skipped'
|
||||
duration_ms: number
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
export interface ArchitectureContext {
|
||||
layer: 'frontend' | 'api' | 'service' | 'database'
|
||||
services: string[]
|
||||
dependencies: string[]
|
||||
dataFlow: string[]
|
||||
}
|
||||
|
||||
export interface TestCategoryResult {
|
||||
category: string
|
||||
display_name: string
|
||||
description: string
|
||||
why_important: string
|
||||
architecture_context?: ArchitectureContext
|
||||
tests: TestResult[]
|
||||
passed: number
|
||||
failed: number
|
||||
total: number
|
||||
duration_ms: number
|
||||
}
|
||||
|
||||
export interface FullTestResults {
|
||||
timestamp: string
|
||||
categories: TestCategoryResult[]
|
||||
total_passed: number
|
||||
total_failed: number
|
||||
total_tests: number
|
||||
duration_ms: number
|
||||
}
|
||||
|
||||
export interface EducationContent {
|
||||
title: string
|
||||
content: string[]
|
||||
}
|
||||
|
||||
export interface StepEducation {
|
||||
stepId: string
|
||||
title: string
|
||||
whyImportant: string
|
||||
whatIsTested: string[]
|
||||
architectureHighlight?: {
|
||||
layer: string
|
||||
components: string[]
|
||||
dataFlow: string
|
||||
}
|
||||
learnMoreLinks?: {
|
||||
label: string
|
||||
url: string
|
||||
}[]
|
||||
commonMistakes?: string[]
|
||||
bestPractices?: string[]
|
||||
}
|
||||
|
||||
export interface ModuleEducation {
|
||||
module: string
|
||||
overview: {
|
||||
title: string
|
||||
description: string
|
||||
businessValue: string
|
||||
complianceContext: string
|
||||
}
|
||||
steps: StepEducation[]
|
||||
}
|
||||
|
||||
export interface WizardContextValue {
|
||||
currentStep: number
|
||||
steps: WizardStep[]
|
||||
setCurrentStep: (step: number) => void
|
||||
setSteps: React.Dispatch<React.SetStateAction<WizardStep[]>>
|
||||
goToNext: () => void
|
||||
goToPrev: () => void
|
||||
module: string
|
||||
categoryResults: Record<string, TestCategoryResult>
|
||||
setCategoryResults: React.Dispatch<React.SetStateAction<Record<string, TestCategoryResult>>>
|
||||
fullResults: FullTestResults | null
|
||||
setFullResults: React.Dispatch<React.SetStateAction<FullTestResults | null>>
|
||||
isLoading: boolean
|
||||
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>
|
||||
error: string | null
|
||||
setError: React.Dispatch<React.SetStateAction<string | null>>
|
||||
}
|
||||
Reference in New Issue
Block a user