backend-lehrer (11 files): - llm_gateway/routes/schools.py (867 → 5), recording_api.py (848 → 6) - messenger_api.py (840 → 5), print_generator.py (824 → 5) - unit_analytics_api.py (751 → 5), classroom/routes/context.py (726 → 4) - llm_gateway/routes/edu_search_seeds.py (710 → 4) klausur-service (12 files): - ocr_labeling_api.py (845 → 4), metrics_db.py (833 → 4) - legal_corpus_api.py (790 → 4), page_crop.py (758 → 3) - mail/ai_service.py (747 → 4), github_crawler.py (767 → 3) - trocr_service.py (730 → 4), full_compliance_pipeline.py (723 → 4) - dsfa_rag_api.py (715 → 4), ocr_pipeline_auto.py (705 → 4) website (6 pages): - audit-checklist (867 → 8), content (806 → 6) - screen-flow (790 → 4), scraper (789 → 5) - zeugnisse (776 → 5), modules (745 → 4) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
228 lines
15 KiB
TypeScript
228 lines
15 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* Screen Flow Visualization
|
|
*
|
|
* Visualisiert alle Screens aus:
|
|
* - Studio (Port 8000): Lehrer-Oberflaeche
|
|
* - Admin (Port 3000): Admin Panel
|
|
*/
|
|
|
|
import { useCallback, useState, useMemo, useEffect } from 'react'
|
|
import ReactFlow, {
|
|
Node,
|
|
Edge,
|
|
Controls,
|
|
Background,
|
|
MiniMap,
|
|
useNodesState,
|
|
useEdgesState,
|
|
BackgroundVariant,
|
|
MarkerType,
|
|
Panel,
|
|
} from 'reactflow'
|
|
import 'reactflow/dist/style.css'
|
|
import AdminLayout from '@/components/admin/AdminLayout'
|
|
import { ScreenDefinition, FlowType } from './_components/types'
|
|
import {
|
|
STUDIO_SCREENS, STUDIO_CONNECTIONS,
|
|
ADMIN_SCREENS, ADMIN_CONNECTIONS,
|
|
STUDIO_COLORS, ADMIN_COLORS,
|
|
STUDIO_LABELS, ADMIN_LABELS,
|
|
} from './_components/screen-data'
|
|
import { findConnectedNodes, constructEmbedUrl, getNodePosition } from './_components/flow-helpers'
|
|
|
|
export default function ScreenFlowPage() {
|
|
const [flowType, setFlowType] = useState<FlowType>('studio')
|
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
|
|
const [selectedNode, setSelectedNode] = useState<string | null>(null)
|
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
|
const [previewScreen, setPreviewScreen] = useState<ScreenDefinition | null>(null)
|
|
|
|
const screens = flowType === 'studio' ? STUDIO_SCREENS : ADMIN_SCREENS
|
|
const connections = flowType === 'studio' ? STUDIO_CONNECTIONS : ADMIN_CONNECTIONS
|
|
const colors = flowType === 'studio' ? STUDIO_COLORS : ADMIN_COLORS
|
|
const labels = flowType === 'studio' ? STUDIO_LABELS : ADMIN_LABELS
|
|
const baseUrl = flowType === 'studio' ? 'http://macmini:8000' : 'http://macmini:3000'
|
|
|
|
const connectedNodes = useMemo(() => {
|
|
if (!selectedNode) return new Set<string>()
|
|
return findConnectedNodes(selectedNode, connections, 'children')
|
|
}, [selectedNode, connections])
|
|
|
|
const initialNodes = useMemo((): Node[] => {
|
|
return screens.map((screen) => {
|
|
const catColors = colors[screen.category] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
|
|
const position = getNodePosition(screen.id, screen.category, screens, flowType)
|
|
let opacity = 1
|
|
if (selectedNode) {
|
|
opacity = connectedNodes.has(screen.id) ? 1 : 0.2
|
|
} else if (selectedCategory) {
|
|
opacity = screen.category === selectedCategory ? 1 : 0.2
|
|
}
|
|
const isSelected = selectedNode === screen.id
|
|
return {
|
|
id: screen.id, type: 'default', position,
|
|
data: { label: (<div className="text-center p-1"><div className="text-lg mb-1">{screen.icon}</div><div className="font-medium text-xs leading-tight">{screen.name}</div></div>) },
|
|
style: { background: isSelected ? catColors.border : catColors.bg, color: isSelected ? 'white' : catColors.text, border: `2px solid ${catColors.border}`, borderRadius: '12px', padding: '6px', minWidth: '110px', opacity, cursor: 'pointer', boxShadow: isSelected ? `0 0 20px ${catColors.border}` : 'none' },
|
|
}
|
|
})
|
|
}, [screens, colors, flowType, selectedCategory, selectedNode, connectedNodes])
|
|
|
|
const initialEdges = useMemo((): Edge[] => {
|
|
return connections.map((conn, index) => {
|
|
const isHighlighted = selectedNode && (conn.source === selectedNode || conn.target === selectedNode)
|
|
const isInSubtree = selectedNode && connectedNodes.has(conn.source) && connectedNodes.has(conn.target)
|
|
return {
|
|
id: `e-${conn.source}-${conn.target}-${index}`, source: conn.source, target: conn.target, label: conn.label, type: 'smoothstep', animated: isHighlighted || false,
|
|
style: { stroke: isHighlighted ? '#3b82f6' : (isInSubtree ? '#94a3b8' : '#e2e8f0'), strokeWidth: isHighlighted ? 3 : 1.5, opacity: selectedNode ? (isInSubtree ? 1 : 0.15) : 1 },
|
|
labelStyle: { fontSize: 9, fill: '#64748b' }, labelBgStyle: { fill: '#f8fafc' },
|
|
markerEnd: { type: MarkerType.ArrowClosed, color: isHighlighted ? '#3b82f6' : '#94a3b8', width: 15, height: 15 },
|
|
}
|
|
})
|
|
}, [connections, selectedNode, connectedNodes])
|
|
|
|
const [nodes, setNodes, onNodesChange] = useNodesState([])
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
|
|
|
useEffect(() => { setNodes(initialNodes); setEdges(initialEdges) }, [initialNodes, initialEdges])
|
|
|
|
const handleFlowTypeChange = useCallback((newType: FlowType) => {
|
|
setFlowType(newType); setSelectedNode(null); setSelectedCategory(null); setPreviewUrl(null); setPreviewScreen(null)
|
|
}, [])
|
|
|
|
const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
|
|
const screen = screens.find(s => s.id === node.id)
|
|
if (selectedNode === node.id) { if (screen?.url) window.open(`${baseUrl}${screen.url}`, '_blank'); return }
|
|
setSelectedNode(node.id); setSelectedCategory(null)
|
|
if (screen?.url) { setPreviewUrl(constructEmbedUrl(baseUrl, screen.url)); setPreviewScreen(screen) }
|
|
}, [screens, baseUrl, selectedNode])
|
|
|
|
const onPaneClick = useCallback(() => { setSelectedNode(null); setPreviewUrl(null); setPreviewScreen(null) }, [])
|
|
const closePreview = useCallback(() => { setPreviewUrl(null); setPreviewScreen(null); setSelectedNode(null) }, [])
|
|
|
|
const categories = Object.keys(labels)
|
|
const connectedScreens = selectedNode ? screens.filter(s => connectedNodes.has(s.id)) : []
|
|
|
|
return (
|
|
<AdminLayout title="Screen Flow" description="Visualisierung aller UI-Screens und ihrer Verbindungen">
|
|
{/* Flow Type Selector */}
|
|
<div className="grid grid-cols-2 gap-4 mb-6">
|
|
{(['studio', 'admin'] as const).map(type => {
|
|
const isActive = flowType === type
|
|
const cfg = type === 'studio'
|
|
? { icon: '🎓', label: 'Studio (Port 8000)', sub: 'Lehrer-Oberflaeche', count: STUDIO_SCREENS.length, activeColor: 'border-green-500 bg-green-50', iconBg: 'bg-green-500 text-white' }
|
|
: { icon: '⚙️', label: 'Admin (Port 3000)', sub: 'Admin Panel', count: ADMIN_SCREENS.length, activeColor: 'border-purple-500 bg-purple-50', iconBg: 'bg-purple-500 text-white' }
|
|
return (
|
|
<button key={type} onClick={() => handleFlowTypeChange(type)} className={`p-6 rounded-xl border-2 transition-all ${isActive ? `${cfg.activeColor} shadow-lg` : 'border-slate-200 bg-white hover:border-slate-300'}`}>
|
|
<div className="flex items-center gap-4">
|
|
<div className={`w-14 h-14 rounded-xl flex items-center justify-center text-2xl ${isActive ? cfg.iconBg : 'bg-slate-100'}`}>{cfg.icon}</div>
|
|
<div className="text-left">
|
|
<div className="font-bold text-lg">{cfg.label}</div>
|
|
<div className="text-sm text-slate-500">{cfg.sub}</div>
|
|
<div className="text-xs text-slate-400 mt-1">{cfg.count} Screens</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-4 gap-4 mb-6">
|
|
<div className="bg-white rounded-lg shadow p-4"><div className="text-3xl font-bold text-slate-800">{screens.length}</div><div className="text-sm text-slate-500">Screens</div></div>
|
|
<div className="bg-white rounded-lg shadow p-4"><div className="text-3xl font-bold text-blue-600">{connections.length}</div><div className="text-sm text-slate-500">Verbindungen</div></div>
|
|
<div className="bg-white rounded-lg shadow p-4 col-span-2">
|
|
{selectedNode ? (
|
|
<div className="flex items-center gap-3">
|
|
<div className="text-3xl">{previewScreen?.icon}</div>
|
|
<div><div className="font-bold text-slate-800">{previewScreen?.name}</div><div className="text-sm text-slate-500">{connectedNodes.size} verbundene Screen{connectedNodes.size !== 1 ? 's' : ''}</div></div>
|
|
<button onClick={closePreview} className="ml-auto px-3 py-1 text-sm bg-slate-100 hover:bg-slate-200 rounded-lg">Zuruecksetzen</button>
|
|
</div>
|
|
) : (<div className="text-slate-500 text-sm">Klicke auf einen Screen um den Subtree und die Vorschau zu sehen</div>)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Category Filter */}
|
|
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
|
<div className="flex flex-wrap gap-2">
|
|
<button onClick={() => { setSelectedCategory(null); setSelectedNode(null); setPreviewUrl(null); setPreviewScreen(null) }} className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${selectedCategory === null && !selectedNode ? 'bg-slate-800 text-white' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'}`}>Alle ({screens.length})</button>
|
|
{categories.map((key) => {
|
|
const count = screens.filter(s => s.category === key).length
|
|
const catColors = colors[key] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
|
|
return (
|
|
<button key={key} onClick={() => { setSelectedCategory(selectedCategory === key ? null : key); setSelectedNode(null); setPreviewUrl(null); setPreviewScreen(null) }} className="px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2" style={{ background: selectedCategory === key ? catColors.border : catColors.bg, color: selectedCategory === key ? 'white' : catColors.text }}>
|
|
<span className="w-3 h-3 rounded-full" style={{ background: catColors.border }} />{labels[key]} ({count})
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Connected Screens List */}
|
|
{selectedNode && connectedScreens.length > 1 && (
|
|
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
|
<div className="text-sm font-medium text-slate-700 mb-3">Verbundene Screens:</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{connectedScreens.map((screen) => {
|
|
const catColors = colors[screen.category] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
|
|
const isCurrentNode = screen.id === selectedNode
|
|
return (
|
|
<button key={screen.id} onClick={() => { setPreviewUrl(constructEmbedUrl(baseUrl, screen.url)); setPreviewScreen(screen) }} className={`px-3 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${previewScreen?.id === screen.id ? 'ring-2 ring-blue-500' : ''}`} style={{ background: isCurrentNode ? catColors.border : catColors.bg, color: isCurrentNode ? 'white' : catColors.text }}>
|
|
<span>{screen.icon}</span>{screen.name}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Flow Diagram */}
|
|
<div className="bg-white rounded-lg shadow overflow-hidden" style={{ height: previewUrl ? '350px' : '500px' }}>
|
|
<ReactFlow nodes={nodes} edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onNodeClick={onNodeClick} onPaneClick={onPaneClick} fitView fitViewOptions={{ padding: 0.2 }} attributionPosition="bottom-left">
|
|
<Controls /><MiniMap nodeColor={(node) => { const s = screens.find(s => s.id === node.id); return s ? (colors[s.category]?.border || '#94a3b8') : '#94a3b8' }} maskColor="rgba(0, 0, 0, 0.1)" /><Background variant={BackgroundVariant.Dots} gap={12} size={1} />
|
|
<Panel position="top-left" className="bg-white/95 p-3 rounded-lg shadow-lg text-xs">
|
|
<div className="font-medium text-slate-700 mb-2">{flowType === 'studio' ? '🎓 Studio' : '⚙️ Admin'}</div>
|
|
<div className="space-y-1">{categories.slice(0, 4).map((key) => { const catColors = colors[key] || { bg: '#f1f5f9', border: '#94a3b8' }; return (<div key={key} className="flex items-center gap-2"><span className="w-3 h-3 rounded" style={{ background: catColors.bg, border: `1px solid ${catColors.border}` }} /><span className="text-slate-600">{labels[key]}</span></div>) })}</div>
|
|
<div className="mt-2 pt-2 border-t text-slate-400">Klick = Subtree + Preview<br/>Doppelklick = Oeffnen</div>
|
|
</Panel>
|
|
</ReactFlow>
|
|
</div>
|
|
|
|
{/* Iframe Preview */}
|
|
{previewUrl && (
|
|
<div className="mt-6 bg-white rounded-lg shadow overflow-hidden">
|
|
<div className="px-4 py-3 bg-slate-50 border-b flex items-center justify-between">
|
|
<div className="flex items-center gap-3"><span className="text-xl">{previewScreen?.icon}</span><div><h3 className="font-medium text-slate-700">{previewScreen?.name}</h3><p className="text-xs text-slate-500">{previewScreen?.description}</p></div></div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-slate-400 font-mono">{previewScreen?.url}</span>
|
|
<a href={`${baseUrl}${previewScreen?.url}`} target="_blank" rel="noopener noreferrer" className="px-3 py-1 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600 flex items-center gap-1"><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>Oeffnen</a>
|
|
<button onClick={closePreview} className="px-3 py-1 text-sm bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300 flex items-center gap-1"><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>Schliessen</button>
|
|
</div>
|
|
</div>
|
|
<div className="relative" style={{ height: '600px' }}><iframe src={previewUrl} className="w-full h-full border-0" title={`Preview: ${previewScreen?.name}`} /></div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Screen List */}
|
|
{!previewUrl && (
|
|
<div className="mt-6 bg-white rounded-lg shadow overflow-hidden">
|
|
<div className="px-4 py-3 bg-slate-50 border-b flex items-center justify-between"><h3 className="font-medium text-slate-700">Alle Screens ({screens.length})</h3><span className="text-xs text-slate-400">{baseUrl}</span></div>
|
|
<div className="divide-y max-h-80 overflow-y-auto">
|
|
{screens.filter(s => !selectedCategory || s.category === selectedCategory).map((screen) => {
|
|
const catColors = colors[screen.category] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
|
|
return (
|
|
<button key={screen.id} onClick={() => { setSelectedNode(screen.id); setSelectedCategory(null); setPreviewUrl(constructEmbedUrl(baseUrl, screen.url)); setPreviewScreen(screen) }} className="w-full flex items-center gap-4 p-3 hover:bg-slate-50 transition-colors text-left">
|
|
<span className="w-9 h-9 rounded-lg flex items-center justify-center text-lg" style={{ background: catColors.bg }}>{screen.icon}</span>
|
|
<div className="flex-1 min-w-0"><div className="font-medium text-slate-800 text-sm">{screen.name}</div><div className="text-xs text-slate-500 truncate">{screen.description}</div></div>
|
|
<span className="px-2 py-1 rounded text-xs font-medium shrink-0" style={{ background: catColors.bg, color: catColors.text }}>{labels[screen.category]}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</AdminLayout>
|
|
)
|
|
}
|