[split-required] Split 700-870 LOC files across all services

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>
This commit is contained in:
Benjamin Admin
2026-04-25 08:01:18 +02:00
parent b6983ab1dc
commit 34da9f4cda
106 changed files with 16500 additions and 16947 deletions

View File

@@ -0,0 +1,50 @@
'use client'
import { AuditStatistics } from './types'
import { Language } from '@/lib/compliance-i18n'
export default function AuditProgressBar({ statistics, lang }: { statistics: AuditStatistics; lang: Language }) {
const segments = [
{ key: 'compliant', count: statistics.compliant, color: 'bg-green-500', label: 'Konform' },
{ key: 'compliant_with_notes', count: statistics.compliant_with_notes, color: 'bg-yellow-500', label: 'Mit Anm.' },
{ key: 'non_compliant', count: statistics.non_compliant, color: 'bg-red-500', label: 'Nicht konform' },
{ key: 'not_applicable', count: statistics.not_applicable, color: 'bg-slate-300', label: 'N/A' },
{ key: 'pending', count: statistics.pending, color: 'bg-slate-100', label: 'Offen' },
]
return (
<div className="bg-white rounded-lg border border-slate-200 p-4 mb-6">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-slate-900">Fortschritt</h3>
<span className="text-2xl font-bold text-primary-600">
{Math.round(statistics.completion_percentage)}%
</span>
</div>
{/* Stacked Progress Bar */}
<div className="h-4 bg-slate-100 rounded-full overflow-hidden flex">
{segments.map(seg => (
seg.count > 0 && (
<div
key={seg.key}
className={`${seg.color} transition-all`}
style={{ width: `${(seg.count / statistics.total) * 100}%` }}
title={`${seg.label}: ${seg.count}`}
/>
)
))}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-4 mt-3">
{segments.map(seg => (
<div key={seg.key} className="flex items-center gap-1.5 text-sm">
<span className={`w-3 h-3 rounded ${seg.color}`} />
<span className="text-slate-600">{seg.label}:</span>
<span className="font-medium text-slate-900">{seg.count}</span>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,73 @@
'use client'
import { AuditChecklistItem, RESULT_STATUS } from './types'
import { Language } from '@/lib/compliance-i18n'
export default function ChecklistItemRow({
item,
index,
onSignOff,
lang,
}: {
item: AuditChecklistItem
index: number
onSignOff: () => void
lang: Language
}) {
const status = RESULT_STATUS[item.current_result]
return (
<div className="p-4 hover:bg-slate-50 transition-colors">
<div className="flex items-start gap-4">
{/* Status Icon */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-lg ${status.color}`}>
{status.icon}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-slate-500">{index}.</span>
<span className="font-mono text-sm text-primary-600">{item.regulation_code}</span>
<span className="font-mono text-sm text-slate-700">{item.article}</span>
{item.is_signed && (
<span className="px-1.5 py-0.5 text-xs bg-green-100 text-green-700 rounded flex items-center gap-1">
<svg className="w-3 h-3" 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>
Signiert
</span>
)}
</div>
<h4 className="text-sm font-medium text-slate-900 mb-1">{item.title}</h4>
{item.notes && (
<p className="text-xs text-slate-500 italic mb-2">&quot;{item.notes}&quot;</p>
)}
<div className="flex items-center gap-4 text-xs text-slate-500">
<span>{item.controls_mapped} Controls</span>
<span>{item.evidence_count} Nachweise</span>
{item.signed_at && (
<span>Signiert: {new Date(item.signed_at).toLocaleDateString('de-DE')}</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<span className={`px-2 py-1 text-xs font-medium rounded ${status.color}`}>
{lang === 'de' ? status.label : status.labelEn}
</span>
<button
onClick={onSignOff}
className="p-2 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors"
title="Sign-off"
>
<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>
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,158 @@
'use client'
import { useState } from 'react'
import { Regulation } from './types'
interface CreateSessionData {
name: string
description?: string
auditor_name: string
auditor_email?: string
regulation_codes?: string[]
}
export default function CreateSessionModal({
regulations,
onClose,
onCreate,
}: {
regulations: Regulation[]
onClose: () => void
onCreate: (data: CreateSessionData) => void
}) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [auditorName, setAuditorName] = useState('')
const [auditorEmail, setAuditorEmail] = useState('')
const [selectedRegs, setSelectedRegs] = useState<string[]>([])
const [submitting, setSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name || !auditorName) return
setSubmitting(true)
await onCreate({
name,
description: description || undefined,
auditor_name: auditorName,
auditor_email: auditorEmail || undefined,
regulation_codes: selectedRegs.length > 0 ? selectedRegs : undefined,
})
setSubmitting(false)
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900">Neue Audit-Session</h2>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600">
<svg className="w-6 h-6" 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>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Name der Pruefung *
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="z.B. Q1 2026 Compliance Audit"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Beschreibung
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optionale Beschreibung..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg"
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Auditor Name *
</label>
<input
type="text"
value={auditorName}
onChange={(e) => setAuditorName(e.target.value)}
placeholder="Dr. Max Mustermann"
className="w-full px-3 py-2 border border-slate-300 rounded-lg"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
E-Mail
</label>
<input
type="email"
value={auditorEmail}
onChange={(e) => setAuditorEmail(e.target.value)}
placeholder="auditor@example.com"
className="w-full px-3 py-2 border border-slate-300 rounded-lg"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Verordnungen (optional - leer = alle)
</label>
<div className="flex flex-wrap gap-2 p-2 border border-slate-300 rounded-lg max-h-32 overflow-y-auto">
{regulations.map(reg => (
<label key={reg.code} className="flex items-center gap-1.5">
<input
type="checkbox"
checked={selectedRegs.includes(reg.code)}
onChange={(e) => {
if (e.target.checked) {
setSelectedRegs([...selectedRegs, reg.code])
} else {
setSelectedRegs(selectedRegs.filter(r => r !== reg.code))
}
}}
className="rounded"
/>
<span className="text-sm text-slate-700">{reg.code}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
type="submit"
disabled={!name || !auditorName || submitting}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{submitting ? 'Erstelle...' : 'Session erstellen'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,45 @@
'use client'
import { AuditSession, SESSION_STATUS } from './types'
export default function SessionCard({ session, onClick }: { session: AuditSession; onClick: () => void }) {
const completionPercent = session.total_items > 0
? Math.round((session.completed_items / session.total_items) * 100)
: 0
return (
<button
onClick={onClick}
className="bg-white rounded-lg border border-slate-200 p-4 text-left hover:shadow-md hover:border-primary-300 transition-all"
>
<div className="flex items-start justify-between mb-3">
<h3 className="font-semibold text-slate-900">{session.name}</h3>
<span className={`px-2 py-0.5 text-xs font-medium rounded ${SESSION_STATUS[session.status].color}`}>
{SESSION_STATUS[session.status].label}
</span>
</div>
{session.description && (
<p className="text-sm text-slate-500 mb-3 line-clamp-2">{session.description}</p>
)}
<div className="mb-3">
<div className="flex justify-between text-xs text-slate-500 mb-1">
<span>{session.completed_items} / {session.total_items} Punkte</span>
<span>{completionPercent}%</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 transition-all"
style={{ width: `${completionPercent}%` }}
/>
</div>
</div>
<div className="flex items-center justify-between text-xs text-slate-500">
<span>Auditor: {session.auditor_name}</span>
<span>{new Date(session.created_at).toLocaleDateString('de-DE')}</span>
</div>
</button>
)
}

View File

@@ -0,0 +1,122 @@
'use client'
import { useState } from 'react'
import { AuditChecklistItem, RESULT_STATUS } from './types'
export default function SignOffModal({
item,
auditorName,
onClose,
onSubmit,
}: {
item: AuditChecklistItem
auditorName: string
onClose: () => void
onSubmit: (result: AuditChecklistItem['current_result'], notes: string, sign: boolean) => void
}) {
const [result, setResult] = useState<AuditChecklistItem['current_result']>(item.current_result)
const [notes, setNotes] = useState(item.notes || '')
const [sign, setSign] = useState(false)
const [submitting, setSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
await onSubmit(result, notes, sign)
setSubmitting(false)
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200">
<h2 className="text-lg font-semibold text-slate-900">Auditor Sign-off</h2>
<p className="text-sm text-slate-500 mt-1">
{item.regulation_code} {item.article} - {item.title}
</p>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Pruefungsergebnis
</label>
<div className="space-y-2">
{Object.entries(RESULT_STATUS).map(([key, { label, color }]) => (
<label key={key} className="flex items-center gap-3 p-2 rounded-lg hover:bg-slate-50 cursor-pointer">
<input
type="radio"
name="result"
value={key}
checked={result === key}
onChange={() => setResult(key as typeof result)}
className="w-4 h-4"
/>
<span className={`px-2 py-0.5 text-sm font-medium rounded ${color}`}>
{label}
</span>
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Anmerkungen
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Optionale Anmerkungen zur Pruefung..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg"
rows={3}
/>
</div>
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={sign}
onChange={(e) => setSign(e.target.checked)}
className="mt-1 rounded"
/>
<div>
<span className="font-medium text-amber-800">Digital signieren</span>
<p className="text-sm text-amber-700 mt-0.5">
Erstellt eine SHA-256 Signatur des Ergebnisses. Diese Aktion kann nicht rueckgaengig gemacht werden.
</p>
</div>
</label>
</div>
<div className="text-sm text-slate-500 pt-2 border-t border-slate-100">
<p>Auditor: <span className="font-medium text-slate-700">{auditorName}</span></p>
<p>Datum: <span className="font-medium text-slate-700">{new Date().toLocaleString('de-DE')}</span></p>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
type="submit"
disabled={submitting}
className={`px-4 py-2 rounded-lg disabled:opacity-50 ${
sign
? 'bg-amber-600 text-white hover:bg-amber-700'
: 'bg-primary-600 text-white hover:bg-primary-700'
}`}
>
{submitting ? 'Speichere...' : sign ? 'Signieren & Speichern' : 'Speichern'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,62 @@
import { Language } from '@/lib/compliance-i18n'
export interface AuditSession {
id: string
name: string
description: string | null
auditor_name: string
auditor_email: string | null
status: 'draft' | 'in_progress' | 'completed' | 'archived'
regulation_ids: string[] | null
total_items: number
completed_items: number
created_at: string
started_at: string | null
completed_at: string | null
}
export interface AuditChecklistItem {
requirement_id: string
regulation_code: string
article: string
title: string
current_result: 'compliant' | 'compliant_notes' | 'non_compliant' | 'not_applicable' | 'pending'
notes: string | null
is_signed: boolean
signed_at: string | null
signed_by: string | null
evidence_count: number
controls_mapped: number
}
export interface AuditStatistics {
total: number
compliant: number
compliant_with_notes: number
non_compliant: number
not_applicable: number
pending: number
completion_percentage: number
}
export interface Regulation {
id: string
code: string
name: string
requirement_count: number
}
export const RESULT_STATUS = {
pending: { label: 'Ausstehend', labelEn: 'Pending', color: 'bg-slate-100 text-slate-700', icon: '○' },
compliant: { label: 'Konform', labelEn: 'Compliant', color: 'bg-green-100 text-green-700', icon: '✓' },
compliant_notes: { label: 'Konform (Anm.)', labelEn: 'Compliant w/ Notes', color: 'bg-yellow-100 text-yellow-700', icon: '⚠' },
non_compliant: { label: 'Nicht konform', labelEn: 'Non-Compliant', color: 'bg-red-100 text-red-700', icon: '✗' },
not_applicable: { label: 'N/A', labelEn: 'N/A', color: 'bg-slate-50 text-slate-500', icon: '' },
}
export const SESSION_STATUS = {
draft: { label: 'Entwurf', color: 'bg-slate-100 text-slate-700' },
in_progress: { label: 'In Bearbeitung', color: 'bg-blue-100 text-blue-700' },
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700' },
archived: { label: 'Archiviert', color: 'bg-slate-50 text-slate-500' },
}

View File

@@ -0,0 +1,203 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import {
AuditSession,
AuditChecklistItem,
AuditStatistics,
Regulation,
} from './types'
import { Language } from '@/lib/compliance-i18n'
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
export function useAuditChecklist() {
const [sessions, setSessions] = useState<AuditSession[]>([])
const [selectedSession, setSelectedSession] = useState<AuditSession | null>(null)
const [checklistItems, setChecklistItems] = useState<AuditChecklistItem[]>([])
const [statistics, setStatistics] = useState<AuditStatistics | null>(null)
const [regulations, setRegulations] = useState<Regulation[]>([])
const [loading, setLoading] = useState(true)
const [loadingChecklist, setLoadingChecklist] = useState(false)
const [error, setError] = useState<string | null>(null)
// Filters
const [filterStatus, setFilterStatus] = useState<string>('all')
const [filterRegulation, setFilterRegulation] = useState<string>('all')
const [searchQuery, setSearchQuery] = useState('')
// Modal states
const [showCreateModal, setShowCreateModal] = useState(false)
const [showSignOffModal, setShowSignOffModal] = useState(false)
const [selectedItem, setSelectedItem] = useState<AuditChecklistItem | null>(null)
// Language
const [lang] = useState<Language>('de')
// Load sessions
const loadSessions = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/audit/sessions`)
if (res.ok) {
const data = await res.json()
setSessions(data.sessions || [])
} else {
console.error('Failed to load sessions:', res.status)
}
} catch (err) {
console.error('Failed to load sessions:', err)
setError('Verbindung zum Backend fehlgeschlagen')
} finally {
setLoading(false)
}
}, [])
// Load regulations for filter
const loadRegulations = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/regulations`)
if (res.ok) {
const data = await res.json()
setRegulations(data.regulations || [])
}
} catch (err) {
console.error('Failed to load regulations:', err)
}
}, [])
// Load checklist for selected session
const loadChecklist = useCallback(async (sessionId: string) => {
setLoadingChecklist(true)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/audit/checklist/${sessionId}`)
if (res.ok) {
const data = await res.json()
setChecklistItems(data.items || [])
setStatistics(data.statistics || null)
if (data.session) {
setSelectedSession(data.session)
}
} else {
console.error('Failed to load checklist:', res.status)
}
} catch (err) {
console.error('Failed to load checklist:', err)
} finally {
setLoadingChecklist(false)
}
}, [])
useEffect(() => {
loadSessions()
loadRegulations()
}, [loadSessions, loadRegulations])
useEffect(() => {
if (selectedSession) {
loadChecklist(selectedSession.id)
}
}, [selectedSession, loadChecklist])
// Filter checklist items
const filteredItems = checklistItems.filter(item => {
if (filterStatus !== 'all' && item.current_result !== filterStatus) return false
if (filterRegulation !== 'all' && item.regulation_code !== filterRegulation) return false
if (searchQuery) {
const query = searchQuery.toLowerCase()
return (
item.title.toLowerCase().includes(query) ||
item.article.toLowerCase().includes(query) ||
item.regulation_code.toLowerCase().includes(query)
)
}
return true
})
// Handle sign-off
const handleSignOff = async (
item: AuditChecklistItem,
result: AuditChecklistItem['current_result'],
notes: string,
sign: boolean
) => {
if (!selectedSession) return
try {
const res = await fetch(
`${BACKEND_URL}/api/v1/compliance/audit/checklist/${selectedSession.id}/items/${item.requirement_id}/sign-off`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ result, notes, sign }),
}
)
if (res.ok) {
await loadChecklist(selectedSession.id)
setShowSignOffModal(false)
setSelectedItem(null)
} else {
const err = await res.json()
alert(`Fehler: ${err.detail || 'Sign-off fehlgeschlagen'}`)
}
} catch (err) {
console.error('Sign-off failed:', err)
alert('Netzwerkfehler bei Sign-off')
}
}
// Create session
const handleCreateSession = async (data: {
name: string
description?: string
auditor_name: string
auditor_email?: string
regulation_codes?: string[]
}) => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/audit/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (res.ok) {
await loadSessions()
setShowCreateModal(false)
} else {
const err = await res.json()
alert(`Fehler: ${err.detail || 'Session konnte nicht erstellt werden'}`)
}
} catch (err) {
console.error('Create session failed:', err)
alert('Netzwerkfehler')
}
}
return {
sessions,
selectedSession,
setSelectedSession,
checklistItems,
filteredItems,
statistics,
regulations,
loading,
loadingChecklist,
error,
filterStatus,
setFilterStatus,
filterRegulation,
setFilterRegulation,
searchQuery,
setSearchQuery,
showCreateModal,
setShowCreateModal,
showSignOffModal,
setShowSignOffModal,
selectedItem,
setSelectedItem,
lang,
handleSignOff,
handleCreateSession,
}
}

View File

@@ -10,212 +10,29 @@
* - Fortschritt und Statistiken zu verfolgen
*/
import { useState, useEffect, useCallback } from 'react'
import Link from 'next/link'
import AdminLayout from '@/components/admin/AdminLayout'
import { getTerm, Language } from '@/lib/compliance-i18n'
// Types based on backend schemas
interface AuditSession {
id: string
name: string
description: string | null
auditor_name: string
auditor_email: string | null
status: 'draft' | 'in_progress' | 'completed' | 'archived'
regulation_ids: string[] | null
total_items: number
completed_items: number
created_at: string
started_at: string | null
completed_at: string | null
}
interface AuditChecklistItem {
requirement_id: string
regulation_code: string
article: string
title: string
current_result: 'compliant' | 'compliant_notes' | 'non_compliant' | 'not_applicable' | 'pending'
notes: string | null
is_signed: boolean
signed_at: string | null
signed_by: string | null
evidence_count: number
controls_mapped: number
}
interface AuditStatistics {
total: number
compliant: number
compliant_with_notes: number
non_compliant: number
not_applicable: number
pending: number
completion_percentage: number
}
interface Regulation {
id: string
code: string
name: string
requirement_count: number
}
// Result status configuration
const RESULT_STATUS = {
pending: { label: 'Ausstehend', labelEn: 'Pending', color: 'bg-slate-100 text-slate-700', icon: '○' },
compliant: { label: 'Konform', labelEn: 'Compliant', color: 'bg-green-100 text-green-700', icon: '✓' },
compliant_notes: { label: 'Konform (Anm.)', labelEn: 'Compliant w/ Notes', color: 'bg-yellow-100 text-yellow-700', icon: '⚠' },
non_compliant: { label: 'Nicht konform', labelEn: 'Non-Compliant', color: 'bg-red-100 text-red-700', icon: '✗' },
not_applicable: { label: 'N/A', labelEn: 'N/A', color: 'bg-slate-50 text-slate-500', icon: '' },
}
const SESSION_STATUS = {
draft: { label: 'Entwurf', color: 'bg-slate-100 text-slate-700' },
in_progress: { label: 'In Bearbeitung', color: 'bg-blue-100 text-blue-700' },
completed: { label: 'Abgeschlossen', color: 'bg-green-100 text-green-700' },
archived: { label: 'Archiviert', color: 'bg-slate-50 text-slate-500' },
}
import { RESULT_STATUS, SESSION_STATUS } from './_components/types'
import { useAuditChecklist } from './_components/useAuditChecklist'
import SessionCard from './_components/SessionCard'
import AuditProgressBar from './_components/AuditProgressBar'
import ChecklistItemRow from './_components/ChecklistItemRow'
import CreateSessionModal from './_components/CreateSessionModal'
import SignOffModal from './_components/SignOffModal'
export default function AuditChecklistPage() {
const [sessions, setSessions] = useState<AuditSession[]>([])
const [selectedSession, setSelectedSession] = useState<AuditSession | null>(null)
const [checklistItems, setChecklistItems] = useState<AuditChecklistItem[]>([])
const [statistics, setStatistics] = useState<AuditStatistics | null>(null)
const [regulations, setRegulations] = useState<Regulation[]>([])
const [loading, setLoading] = useState(true)
const [loadingChecklist, setLoadingChecklist] = useState(false)
const [error, setError] = useState<string | null>(null)
// Filters
const [filterStatus, setFilterStatus] = useState<string>('all')
const [filterRegulation, setFilterRegulation] = useState<string>('all')
const [searchQuery, setSearchQuery] = useState('')
// Modal states
const [showCreateModal, setShowCreateModal] = useState(false)
const [showSignOffModal, setShowSignOffModal] = useState(false)
const [selectedItem, setSelectedItem] = useState<AuditChecklistItem | null>(null)
// Language
const [lang] = useState<Language>('de')
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
// Load sessions
const loadSessions = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/audit/sessions`)
if (res.ok) {
const data = await res.json()
setSessions(data.sessions || [])
} else {
console.error('Failed to load sessions:', res.status)
}
} catch (err) {
console.error('Failed to load sessions:', err)
setError('Verbindung zum Backend fehlgeschlagen')
} finally {
setLoading(false)
}
}, [BACKEND_URL])
// Load regulations for filter
const loadRegulations = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/regulations`)
if (res.ok) {
const data = await res.json()
setRegulations(data.regulations || [])
}
} catch (err) {
console.error('Failed to load regulations:', err)
}
}, [BACKEND_URL])
// Load checklist for selected session
const loadChecklist = useCallback(async (sessionId: string) => {
setLoadingChecklist(true)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/audit/checklist/${sessionId}`)
if (res.ok) {
const data = await res.json()
setChecklistItems(data.items || [])
setStatistics(data.statistics || null)
// Update session with latest data
if (data.session) {
setSelectedSession(data.session)
}
} else {
console.error('Failed to load checklist:', res.status)
}
} catch (err) {
console.error('Failed to load checklist:', err)
} finally {
setLoadingChecklist(false)
}
}, [BACKEND_URL])
useEffect(() => {
loadSessions()
loadRegulations()
}, [loadSessions, loadRegulations])
useEffect(() => {
if (selectedSession) {
loadChecklist(selectedSession.id)
}
}, [selectedSession, loadChecklist])
// Filter checklist items
const filteredItems = checklistItems.filter(item => {
if (filterStatus !== 'all' && item.current_result !== filterStatus) return false
if (filterRegulation !== 'all' && item.regulation_code !== filterRegulation) return false
if (searchQuery) {
const query = searchQuery.toLowerCase()
return (
item.title.toLowerCase().includes(query) ||
item.article.toLowerCase().includes(query) ||
item.regulation_code.toLowerCase().includes(query)
)
}
return true
})
// Handle sign-off
const handleSignOff = async (
item: AuditChecklistItem,
result: AuditChecklistItem['current_result'],
notes: string,
sign: boolean
) => {
if (!selectedSession) return
try {
const res = await fetch(
`${BACKEND_URL}/api/v1/compliance/audit/checklist/${selectedSession.id}/items/${item.requirement_id}/sign-off`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ result, notes, sign }),
}
)
if (res.ok) {
// Reload checklist to get updated data
await loadChecklist(selectedSession.id)
setShowSignOffModal(false)
setSelectedItem(null)
} else {
const err = await res.json()
alert(`Fehler: ${err.detail || 'Sign-off fehlgeschlagen'}`)
}
} catch (err) {
console.error('Sign-off failed:', err)
alert('Netzwerkfehler bei Sign-off')
}
}
const {
sessions, selectedSession, setSelectedSession,
filteredItems, statistics, regulations,
loading, loadingChecklist, error,
filterStatus, setFilterStatus,
filterRegulation, setFilterRegulation,
searchQuery, setSearchQuery,
showCreateModal, setShowCreateModal,
showSignOffModal, setShowSignOffModal,
selectedItem, setSelectedItem,
lang, handleSignOff, handleCreateSession,
} = useAuditChecklist()
// Session list view when no session is selected
if (!selectedSession && !loading) {
@@ -231,7 +48,6 @@ export default function AuditChecklistPage() {
</svg>
Zurueck zu Compliance
</Link>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
@@ -244,12 +60,9 @@ export default function AuditChecklistPage() {
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">{error}</div>
)}
{/* Session Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{sessions.length === 0 ? (
<div className="col-span-full bg-white rounded-lg border border-slate-200 p-8 text-center">
@@ -267,46 +80,22 @@ export default function AuditChecklistPage() {
</div>
) : (
sessions.map(session => (
<SessionCard
key={session.id}
session={session}
onClick={() => setSelectedSession(session)}
/>
<SessionCard key={session.id} session={session} onClick={() => setSelectedSession(session)} />
))
)}
</div>
{/* Create Session Modal */}
{showCreateModal && (
<CreateSessionModal
regulations={regulations}
onClose={() => setShowCreateModal(false)}
onCreate={async (data) => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/audit/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (res.ok) {
await loadSessions()
setShowCreateModal(false)
} else {
const err = await res.json()
alert(`Fehler: ${err.detail || 'Session konnte nicht erstellt werden'}`)
}
} catch (err) {
console.error('Create session failed:', err)
alert('Netzwerkfehler')
}
}}
onCreate={handleCreateSession}
/>
)}
</AdminLayout>
)
}
// Loading state
if (loading) {
return (
<AdminLayout title="Audit Checkliste" description="Laden...">
@@ -320,7 +109,6 @@ export default function AuditChecklistPage() {
// Checklist view for selected session
return (
<AdminLayout title={selectedSession?.name || 'Audit Checkliste'} description="Pruefung durchfuehren">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<button
onClick={() => setSelectedSession(null)}
@@ -331,7 +119,6 @@ export default function AuditChecklistPage() {
</svg>
Zurueck zur Uebersicht
</button>
<div className="flex items-center gap-3">
<span className={`px-3 py-1 text-sm font-medium rounded-full ${SESSION_STATUS[selectedSession?.status || 'draft'].color}`}>
{SESSION_STATUS[selectedSession?.status || 'draft'].label}
@@ -348,10 +135,7 @@ export default function AuditChecklistPage() {
</div>
</div>
{/* Progress Bar */}
{statistics && (
<AuditProgressBar statistics={statistics} lang={lang} />
)}
{statistics && <AuditProgressBar statistics={statistics} lang={lang} />}
{/* Filters */}
<div className="bg-white rounded-lg border border-slate-200 p-4 mb-6">
@@ -424,7 +208,6 @@ export default function AuditChecklistPage() {
)}
</div>
{/* Sign-off Modal */}
{showSignOffModal && selectedItem && (
<SignOffModal
item={selectedItem}
@@ -439,429 +222,3 @@ export default function AuditChecklistPage() {
</AdminLayout>
)
}
// Session Card Component
function SessionCard({ session, onClick }: { session: AuditSession; onClick: () => void }) {
const completionPercent = session.total_items > 0
? Math.round((session.completed_items / session.total_items) * 100)
: 0
return (
<button
onClick={onClick}
className="bg-white rounded-lg border border-slate-200 p-4 text-left hover:shadow-md hover:border-primary-300 transition-all"
>
<div className="flex items-start justify-between mb-3">
<h3 className="font-semibold text-slate-900">{session.name}</h3>
<span className={`px-2 py-0.5 text-xs font-medium rounded ${SESSION_STATUS[session.status].color}`}>
{SESSION_STATUS[session.status].label}
</span>
</div>
{session.description && (
<p className="text-sm text-slate-500 mb-3 line-clamp-2">{session.description}</p>
)}
<div className="mb-3">
<div className="flex justify-between text-xs text-slate-500 mb-1">
<span>{session.completed_items} / {session.total_items} Punkte</span>
<span>{completionPercent}%</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 transition-all"
style={{ width: `${completionPercent}%` }}
/>
</div>
</div>
<div className="flex items-center justify-between text-xs text-slate-500">
<span>Auditor: {session.auditor_name}</span>
<span>{new Date(session.created_at).toLocaleDateString('de-DE')}</span>
</div>
</button>
)
}
// Progress Bar Component
function AuditProgressBar({ statistics, lang }: { statistics: AuditStatistics; lang: Language }) {
const segments = [
{ key: 'compliant', count: statistics.compliant, color: 'bg-green-500', label: 'Konform' },
{ key: 'compliant_with_notes', count: statistics.compliant_with_notes, color: 'bg-yellow-500', label: 'Mit Anm.' },
{ key: 'non_compliant', count: statistics.non_compliant, color: 'bg-red-500', label: 'Nicht konform' },
{ key: 'not_applicable', count: statistics.not_applicable, color: 'bg-slate-300', label: 'N/A' },
{ key: 'pending', count: statistics.pending, color: 'bg-slate-100', label: 'Offen' },
]
return (
<div className="bg-white rounded-lg border border-slate-200 p-4 mb-6">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold text-slate-900">Fortschritt</h3>
<span className="text-2xl font-bold text-primary-600">
{Math.round(statistics.completion_percentage)}%
</span>
</div>
{/* Stacked Progress Bar */}
<div className="h-4 bg-slate-100 rounded-full overflow-hidden flex">
{segments.map(seg => (
seg.count > 0 && (
<div
key={seg.key}
className={`${seg.color} transition-all`}
style={{ width: `${(seg.count / statistics.total) * 100}%` }}
title={`${seg.label}: ${seg.count}`}
/>
)
))}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-4 mt-3">
{segments.map(seg => (
<div key={seg.key} className="flex items-center gap-1.5 text-sm">
<span className={`w-3 h-3 rounded ${seg.color}`} />
<span className="text-slate-600">{seg.label}:</span>
<span className="font-medium text-slate-900">{seg.count}</span>
</div>
))}
</div>
</div>
)
}
// Checklist Item Row Component
function ChecklistItemRow({
item,
index,
onSignOff,
lang,
}: {
item: AuditChecklistItem
index: number
onSignOff: () => void
lang: Language
}) {
const status = RESULT_STATUS[item.current_result]
return (
<div className="p-4 hover:bg-slate-50 transition-colors">
<div className="flex items-start gap-4">
{/* Status Icon */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-lg ${status.color}`}>
{status.icon}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-medium text-slate-500">{index}.</span>
<span className="font-mono text-sm text-primary-600">{item.regulation_code}</span>
<span className="font-mono text-sm text-slate-700">{item.article}</span>
{item.is_signed && (
<span className="px-1.5 py-0.5 text-xs bg-green-100 text-green-700 rounded flex items-center gap-1">
<svg className="w-3 h-3" 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>
Signiert
</span>
)}
</div>
<h4 className="text-sm font-medium text-slate-900 mb-1">{item.title}</h4>
{item.notes && (
<p className="text-xs text-slate-500 italic mb-2">"{item.notes}"</p>
)}
<div className="flex items-center gap-4 text-xs text-slate-500">
<span>{item.controls_mapped} Controls</span>
<span>{item.evidence_count} Nachweise</span>
{item.signed_at && (
<span>Signiert: {new Date(item.signed_at).toLocaleDateString('de-DE')}</span>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<span className={`px-2 py-1 text-xs font-medium rounded ${status.color}`}>
{lang === 'de' ? status.label : status.labelEn}
</span>
<button
onClick={onSignOff}
className="p-2 text-slate-400 hover:text-primary-600 hover:bg-primary-50 rounded-lg transition-colors"
title="Sign-off"
>
<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>
</button>
</div>
</div>
</div>
)
}
// Create Session Modal
function CreateSessionModal({
regulations,
onClose,
onCreate,
}: {
regulations: Regulation[]
onClose: () => void
onCreate: (data: { name: string; description?: string; auditor_name: string; auditor_email?: string; regulation_codes?: string[] }) => void
}) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [auditorName, setAuditorName] = useState('')
const [auditorEmail, setAuditorEmail] = useState('')
const [selectedRegs, setSelectedRegs] = useState<string[]>([])
const [submitting, setSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name || !auditorName) return
setSubmitting(true)
await onCreate({
name,
description: description || undefined,
auditor_name: auditorName,
auditor_email: auditorEmail || undefined,
regulation_codes: selectedRegs.length > 0 ? selectedRegs : undefined,
})
setSubmitting(false)
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900">Neue Audit-Session</h2>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600">
<svg className="w-6 h-6" 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>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Name der Pruefung *
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="z.B. Q1 2026 Compliance Audit"
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Beschreibung
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optionale Beschreibung..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg"
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Auditor Name *
</label>
<input
type="text"
value={auditorName}
onChange={(e) => setAuditorName(e.target.value)}
placeholder="Dr. Max Mustermann"
className="w-full px-3 py-2 border border-slate-300 rounded-lg"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
E-Mail
</label>
<input
type="email"
value={auditorEmail}
onChange={(e) => setAuditorEmail(e.target.value)}
placeholder="auditor@example.com"
className="w-full px-3 py-2 border border-slate-300 rounded-lg"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Verordnungen (optional - leer = alle)
</label>
<div className="flex flex-wrap gap-2 p-2 border border-slate-300 rounded-lg max-h-32 overflow-y-auto">
{regulations.map(reg => (
<label key={reg.code} className="flex items-center gap-1.5">
<input
type="checkbox"
checked={selectedRegs.includes(reg.code)}
onChange={(e) => {
if (e.target.checked) {
setSelectedRegs([...selectedRegs, reg.code])
} else {
setSelectedRegs(selectedRegs.filter(r => r !== reg.code))
}
}}
className="rounded"
/>
<span className="text-sm text-slate-700">{reg.code}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
type="submit"
disabled={!name || !auditorName || submitting}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{submitting ? 'Erstelle...' : 'Session erstellen'}
</button>
</div>
</form>
</div>
</div>
)
}
// Sign-off Modal
function SignOffModal({
item,
auditorName,
onClose,
onSubmit,
}: {
item: AuditChecklistItem
auditorName: string
onClose: () => void
onSubmit: (result: AuditChecklistItem['current_result'], notes: string, sign: boolean) => void
}) {
const [result, setResult] = useState<AuditChecklistItem['current_result']>(item.current_result)
const [notes, setNotes] = useState(item.notes || '')
const [sign, setSign] = useState(false)
const [submitting, setSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
await onSubmit(result, notes, sign)
setSubmitting(false)
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl max-w-lg w-full mx-4 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200">
<h2 className="text-lg font-semibold text-slate-900">Auditor Sign-off</h2>
<p className="text-sm text-slate-500 mt-1">
{item.regulation_code} {item.article} - {item.title}
</p>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Pruefungsergebnis
</label>
<div className="space-y-2">
{Object.entries(RESULT_STATUS).map(([key, { label, color }]) => (
<label key={key} className="flex items-center gap-3 p-2 rounded-lg hover:bg-slate-50 cursor-pointer">
<input
type="radio"
name="result"
value={key}
checked={result === key}
onChange={() => setResult(key as typeof result)}
className="w-4 h-4"
/>
<span className={`px-2 py-0.5 text-sm font-medium rounded ${color}`}>
{label}
</span>
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Anmerkungen
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Optionale Anmerkungen zur Pruefung..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg"
rows={3}
/>
</div>
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg">
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
checked={sign}
onChange={(e) => setSign(e.target.checked)}
className="mt-1 rounded"
/>
<div>
<span className="font-medium text-amber-800">Digital signieren</span>
<p className="text-sm text-amber-700 mt-0.5">
Erstellt eine SHA-256 Signatur des Ergebnisses. Diese Aktion kann nicht rueckgaengig gemacht werden.
</p>
</div>
</label>
</div>
<div className="text-sm text-slate-500 pt-2 border-t border-slate-100">
<p>Auditor: <span className="font-medium text-slate-700">{auditorName}</span></p>
<p>Datum: <span className="font-medium text-slate-700">{new Date().toLocaleString('de-DE')}</span></p>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
type="submit"
disabled={submitting}
className={`px-4 py-2 rounded-lg disabled:opacity-50 ${
sign
? 'bg-amber-600 text-white hover:bg-amber-700'
: 'bg-primary-600 text-white hover:bg-primary-700'
}`}
>
{submitting ? 'Speichere...' : sign ? 'Signieren & Speichern' : 'Speichern'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,91 @@
'use client'
import {
ServiceModule, RiskAssessment,
SERVICE_TYPE_CONFIG, CRITICALITY_CONFIG, RELEVANCE_CONFIG,
} from './types'
interface ModuleDetailPanelProps {
module: ServiceModule;
loadingDetail: boolean;
loadingRisk: boolean;
showRiskPanel: boolean;
riskAssessment: RiskAssessment | null;
onClose: () => void;
onAssessRisk: (moduleId: string) => void;
onCloseRisk: () => void;
}
export default function ModuleDetailPanel({
module, loadingDetail, loadingRisk, showRiskPanel, riskAssessment,
onClose, onAssessRisk, onCloseRisk,
}: ModuleDetailPanelProps) {
return (
<div className="w-96 bg-white rounded-lg shadow border sticky top-6 h-fit">
<div className={`px-4 py-3 border-b ${SERVICE_TYPE_CONFIG[module.service_type]?.bgColor || 'bg-gray-100'}`}>
<div className="flex items-center justify-between">
<span className="text-lg">{SERVICE_TYPE_CONFIG[module.service_type]?.icon || '📁'}</span>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"></button>
</div>
<h3 className="font-bold text-lg mt-2">{module.display_name}</h3>
<div className="text-sm text-gray-600">{module.name}</div>
</div>
{loadingDetail ? (
<div className="p-4 text-center text-gray-500">Lade Details...</div>
) : (
<div className="p-4 space-y-4">
{module.description && (<div><div className="text-xs text-gray-500 uppercase mb-1">Beschreibung</div><div className="text-sm text-gray-700">{module.description}</div></div>)}
<div className="grid grid-cols-2 gap-2 text-sm">
{module.port && (<div><span className="text-gray-500">Port:</span><span className="ml-1 font-mono">{module.port}</span></div>)}
<div><span className="text-gray-500">Criticality:</span><span className={`ml-1 px-1.5 py-0.5 rounded text-xs ${CRITICALITY_CONFIG[module.criticality]?.bgColor || ''} ${CRITICALITY_CONFIG[module.criticality]?.color || ''}`}>{module.criticality}</span></div>
</div>
<div><div className="text-xs text-gray-500 uppercase mb-1">Tech Stack</div><div className="flex flex-wrap gap-1">{module.technology_stack.map((tech, i) => (<span key={i} className="px-2 py-0.5 bg-gray-100 text-gray-700 text-xs rounded">{tech}</span>))}</div></div>
{module.data_categories.length > 0 && (<div><div className="text-xs text-gray-500 uppercase mb-1">Daten-Kategorien</div><div className="flex flex-wrap gap-1">{module.data_categories.map((cat, i) => (<span key={i} className="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs rounded">{cat}</span>))}</div></div>)}
<div className="flex flex-wrap gap-2">
{module.processes_pii && (<span className="px-2 py-1 bg-purple-100 text-purple-700 text-xs rounded">Verarbeitet PII</span>)}
{module.ai_components && (<span className="px-2 py-1 bg-pink-100 text-pink-700 text-xs rounded">AI-Komponenten</span>)}
{module.processes_health_data && (<span className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded">Gesundheitsdaten</span>)}
</div>
{module.regulations && module.regulations.length > 0 && (
<div>
<div className="text-xs text-gray-500 uppercase mb-2">Applicable Regulations ({module.regulations.length})</div>
<div className="space-y-2">
{module.regulations.map((reg, i) => (
<div key={i} className="p-2 bg-gray-50 rounded text-sm">
<div className="flex justify-between items-start"><span className="font-medium">{reg.code}</span><span className={`px-1.5 py-0.5 rounded text-xs ${RELEVANCE_CONFIG[reg.relevance_level]?.bgColor || 'bg-gray-100'} ${RELEVANCE_CONFIG[reg.relevance_level]?.color || 'text-gray-700'}`}>{reg.relevance_level}</span></div>
<div className="text-gray-500 text-xs">{reg.name}</div>
{reg.notes && (<div className="text-gray-600 text-xs mt-1 italic">{reg.notes}</div>)}
</div>
))}
</div>
</div>
)}
{module.owner_team && (<div><div className="text-xs text-gray-500 uppercase mb-1">Owner</div><div className="text-sm text-gray-700">{module.owner_team}</div></div>)}
{module.repository_path && (<div><div className="text-xs text-gray-500 uppercase mb-1">Repository</div><code className="text-xs bg-gray-100 px-2 py-1 rounded block">{module.repository_path}</code></div>)}
<div className="pt-2 border-t">
<button onClick={() => onAssessRisk(module.id)} disabled={loadingRisk} className="w-full px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg hover:from-purple-700 hover:to-pink-700 transition disabled:opacity-50 flex items-center justify-center gap-2">
{loadingRisk ? (<><span className="animate-spin"></span>AI analysiert...</>) : (<><span>🤖</span>AI Risikobewertung</>)}
</button>
</div>
{showRiskPanel && (
<div className="mt-4 p-4 bg-gradient-to-br from-purple-50 to-pink-50 rounded-lg border border-purple-200">
<div className="flex justify-between items-center mb-3">
<h4 className="font-semibold text-purple-900 flex items-center gap-2"><span>🤖</span> AI Risikobewertung</h4>
<button onClick={onCloseRisk} className="text-purple-400 hover:text-purple-600"></button>
</div>
{loadingRisk ? (<div className="text-center py-4 text-purple-600"><div className="animate-pulse">Analysiere Compliance-Risiken...</div></div>) : riskAssessment ? (
<div className="space-y-3">
<div className="flex items-center gap-2"><span className="text-sm text-gray-600">Gesamtrisiko:</span><span className={`px-2 py-1 rounded text-sm font-medium ${riskAssessment.overall_risk === 'critical' ? 'bg-red-100 text-red-700' : riskAssessment.overall_risk === 'high' ? 'bg-orange-100 text-orange-700' : riskAssessment.overall_risk === 'medium' ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'}`}>{riskAssessment.overall_risk.toUpperCase()}</span><span className="text-xs text-gray-400">({Math.round(riskAssessment.confidence_score * 100)}% Konfidenz)</span></div>
{riskAssessment.risk_factors.length > 0 && (<div><div className="text-xs text-gray-500 uppercase mb-1">Risikofaktoren</div><div className="space-y-1">{riskAssessment.risk_factors.map((factor, i) => (<div key={i} className="flex items-center justify-between text-sm bg-white/50 rounded px-2 py-1"><span className="text-gray-700">{factor.factor}</span><span className={`text-xs px-1.5 py-0.5 rounded ${factor.severity === 'critical' || factor.severity === 'high' ? 'bg-red-100 text-red-600' : 'bg-yellow-100 text-yellow-600'}`}>{factor.severity}</span></div>))}</div></div>)}
{riskAssessment.compliance_gaps.length > 0 && (<div><div className="text-xs text-gray-500 uppercase mb-1">Compliance-Luecken</div><ul className="text-sm text-gray-700 space-y-1">{riskAssessment.compliance_gaps.map((gap, i) => (<li key={i} className="flex items-start gap-1"><span className="text-red-500"></span><span>{gap}</span></li>))}</ul></div>)}
{riskAssessment.recommendations.length > 0 && (<div><div className="text-xs text-gray-500 uppercase mb-1">Empfehlungen</div><ul className="text-sm text-gray-700 space-y-1">{riskAssessment.recommendations.map((rec, i) => (<li key={i} className="flex items-start gap-1"><span className="text-green-500"></span><span>{rec}</span></li>))}</ul></div>)}
</div>
) : (<div className="text-center py-4 text-gray-500 text-sm">Klicken Sie auf &quot;AI Risikobewertung&quot; um eine Analyse zu starten.</div>)}
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,71 @@
export interface ServiceModule {
id: string;
name: string;
display_name: string;
description: string | null;
service_type: string;
port: number | null;
technology_stack: string[];
repository_path: string | null;
docker_image: string | null;
data_categories: string[];
processes_pii: boolean;
processes_health_data: boolean;
ai_components: boolean;
criticality: string;
owner_team: string | null;
is_active: boolean;
compliance_score: number | null;
regulation_count: number;
risk_count: number;
created_at: string;
regulations?: Array<{
code: string;
name: string;
relevance_level: string;
notes: string | null;
}>;
}
export interface ModulesOverview {
total_modules: number;
modules_by_type: Record<string, number>;
modules_by_criticality: Record<string, number>;
modules_processing_pii: number;
modules_with_ai: number;
average_compliance_score: number | null;
regulations_coverage: Record<string, number>;
}
export interface RiskAssessment {
overall_risk: string;
risk_factors: Array<{ factor: string; severity: string; likelihood: string }>;
recommendations: string[];
compliance_gaps: string[];
confidence_score: number;
}
export const SERVICE_TYPE_CONFIG: Record<string, { icon: string; color: string; bgColor: string }> = {
backend: { icon: '⚙️', color: 'text-blue-700', bgColor: 'bg-blue-100' },
database: { icon: '🗄️', color: 'text-purple-700', bgColor: 'bg-purple-100' },
ai: { icon: '🤖', color: 'text-pink-700', bgColor: 'bg-pink-100' },
communication: { icon: '💬', color: 'text-green-700', bgColor: 'bg-green-100' },
storage: { icon: '📦', color: 'text-orange-700', bgColor: 'bg-orange-100' },
infrastructure: { icon: '🌐', color: 'text-gray-700', bgColor: 'bg-gray-100' },
monitoring: { icon: '📊', color: 'text-cyan-700', bgColor: 'bg-cyan-100' },
security: { icon: '🔒', color: 'text-red-700', bgColor: 'bg-red-100' },
};
export const CRITICALITY_CONFIG: Record<string, { color: string; bgColor: string }> = {
critical: { color: 'text-red-700', bgColor: 'bg-red-100' },
high: { color: 'text-orange-700', bgColor: 'bg-orange-100' },
medium: { color: 'text-yellow-700', bgColor: 'bg-yellow-100' },
low: { color: 'text-green-700', bgColor: 'bg-green-100' },
};
export const RELEVANCE_CONFIG: Record<string, { color: string; bgColor: string }> = {
critical: { color: 'text-red-700', bgColor: 'bg-red-100' },
high: { color: 'text-orange-700', bgColor: 'bg-orange-100' },
medium: { color: 'text-yellow-700', bgColor: 'bg-yellow-100' },
low: { color: 'text-green-700', bgColor: 'bg-green-100' },
};

View File

@@ -0,0 +1,110 @@
'use client'
import { useState, useEffect } from 'react'
import { ServiceModule, ModulesOverview, RiskAssessment } from './types'
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
const API_BASE = `${BACKEND_URL}/api/v1/compliance`
export function useModulesPage() {
const [modules, setModules] = useState<ServiceModule[]>([])
const [overview, setOverview] = useState<ModulesOverview | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [typeFilter, setTypeFilter] = useState<string>('all')
const [criticalityFilter, setCriticalityFilter] = useState<string>('all')
const [piiFilter, setPiiFilter] = useState<boolean | null>(null)
const [aiFilter, setAiFilter] = useState<boolean | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const [selectedModule, setSelectedModule] = useState<ServiceModule | null>(null)
const [loadingDetail, setLoadingDetail] = useState(false)
const [riskAssessment, setRiskAssessment] = useState<RiskAssessment | null>(null)
const [loadingRisk, setLoadingRisk] = useState(false)
const [showRiskPanel, setShowRiskPanel] = useState(false)
useEffect(() => { fetchModules(); fetchOverview() }, [])
const fetchModules = async () => {
try {
setLoading(true)
const params = new URLSearchParams()
if (typeFilter !== 'all') params.append('service_type', typeFilter)
if (criticalityFilter !== 'all') params.append('criticality', criticalityFilter)
if (piiFilter !== null) params.append('processes_pii', String(piiFilter))
if (aiFilter !== null) params.append('ai_components', String(aiFilter))
const url = `${API_BASE}/modules${params.toString() ? '?' + params.toString() : ''}`
const res = await fetch(url)
if (!res.ok) throw new Error('Failed to fetch modules')
const data = await res.json()
setModules(data.modules || [])
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally { setLoading(false) }
}
const fetchOverview = async () => {
try {
const res = await fetch(`${API_BASE}/modules/overview`)
if (!res.ok) throw new Error('Failed to fetch overview')
const data = await res.json()
setOverview(data)
} catch (err) { console.error('Failed to fetch overview:', err) }
}
const fetchModuleDetail = async (moduleId: string) => {
try {
setLoadingDetail(true)
const res = await fetch(`${API_BASE}/modules/${moduleId}`)
if (!res.ok) throw new Error('Failed to fetch module details')
const data = await res.json()
setSelectedModule(data)
} catch (err) { console.error('Failed to fetch module details:', err) }
finally { setLoadingDetail(false) }
}
const seedModules = async (force: boolean = false) => {
try {
const res = await fetch(`${API_BASE}/modules/seed`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ force }) })
if (!res.ok) throw new Error('Failed to seed modules')
const data = await res.json()
alert(`Seeded ${data.modules_created} modules with ${data.mappings_created} regulation mappings`)
fetchModules(); fetchOverview()
} catch (err) { alert('Failed to seed modules: ' + (err instanceof Error ? err.message : 'Unknown error')) }
}
const assessModuleRisk = async (moduleId: string) => {
setLoadingRisk(true); setShowRiskPanel(true); setRiskAssessment(null)
try {
const res = await fetch(`${API_BASE}/ai/assess-risk`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ module_id: moduleId }) })
if (res.ok) { const data = await res.json(); setRiskAssessment(data) }
else { alert('AI-Risikobewertung fehlgeschlagen') }
} catch (err) { alert('Netzwerkfehler bei AI-Risikobewertung') }
finally { setLoadingRisk(false) }
}
const filteredModules = modules.filter(m => {
if (!searchTerm) return true
const term = searchTerm.toLowerCase()
return m.name.toLowerCase().includes(term) || m.display_name.toLowerCase().includes(term) || (m.description && m.description.toLowerCase().includes(term)) || m.technology_stack.some(t => t.toLowerCase().includes(term))
})
const modulesByType = filteredModules.reduce((acc, m) => {
const type = m.service_type || 'unknown'
if (!acc[type]) acc[type] = []
acc[type].push(m)
return acc
}, {} as Record<string, ServiceModule[]>)
return {
modules, overview, loading, error,
typeFilter, setTypeFilter, criticalityFilter, setCriticalityFilter,
piiFilter, setPiiFilter, aiFilter, setAiFilter, searchTerm, setSearchTerm,
selectedModule, setSelectedModule, loadingDetail,
riskAssessment, loadingRisk, showRiskPanel, setShowRiskPanel,
filteredModules, modulesByType,
fetchModules, fetchModuleDetail, seedModules, assessModuleRisk,
}
}

View File

@@ -1,214 +1,11 @@
'use client';
import { useState, useEffect } from 'react';
// Types
interface ServiceModule {
id: string;
name: string;
display_name: string;
description: string | null;
service_type: string;
port: number | null;
technology_stack: string[];
repository_path: string | null;
docker_image: string | null;
data_categories: string[];
processes_pii: boolean;
processes_health_data: boolean;
ai_components: boolean;
criticality: string;
owner_team: string | null;
is_active: boolean;
compliance_score: number | null;
regulation_count: number;
risk_count: number;
created_at: string;
regulations?: Array<{
code: string;
name: string;
relevance_level: string;
notes: string | null;
}>;
}
interface ModulesOverview {
total_modules: number;
modules_by_type: Record<string, number>;
modules_by_criticality: Record<string, number>;
modules_processing_pii: number;
modules_with_ai: number;
average_compliance_score: number | null;
regulations_coverage: Record<string, number>;
}
// Service Type Icons and Colors
const SERVICE_TYPE_CONFIG: Record<string, { icon: string; color: string; bgColor: string }> = {
backend: { icon: '⚙️', color: 'text-blue-700', bgColor: 'bg-blue-100' },
database: { icon: '🗄️', color: 'text-purple-700', bgColor: 'bg-purple-100' },
ai: { icon: '🤖', color: 'text-pink-700', bgColor: 'bg-pink-100' },
communication: { icon: '💬', color: 'text-green-700', bgColor: 'bg-green-100' },
storage: { icon: '📦', color: 'text-orange-700', bgColor: 'bg-orange-100' },
infrastructure: { icon: '🌐', color: 'text-gray-700', bgColor: 'bg-gray-100' },
monitoring: { icon: '📊', color: 'text-cyan-700', bgColor: 'bg-cyan-100' },
security: { icon: '🔒', color: 'text-red-700', bgColor: 'bg-red-100' },
};
const CRITICALITY_CONFIG: Record<string, { color: string; bgColor: string }> = {
critical: { color: 'text-red-700', bgColor: 'bg-red-100' },
high: { color: 'text-orange-700', bgColor: 'bg-orange-100' },
medium: { color: 'text-yellow-700', bgColor: 'bg-yellow-100' },
low: { color: 'text-green-700', bgColor: 'bg-green-100' },
};
const RELEVANCE_CONFIG: Record<string, { color: string; bgColor: string }> = {
critical: { color: 'text-red-700', bgColor: 'bg-red-100' },
high: { color: 'text-orange-700', bgColor: 'bg-orange-100' },
medium: { color: 'text-yellow-700', bgColor: 'bg-yellow-100' },
low: { color: 'text-green-700', bgColor: 'bg-green-100' },
};
import { SERVICE_TYPE_CONFIG, CRITICALITY_CONFIG } from './_components/types';
import { useModulesPage } from './_components/useModulesPage';
import ModuleDetailPanel from './_components/ModuleDetailPanel';
export default function ModulesPage() {
const [modules, setModules] = useState<ServiceModule[]>([]);
const [overview, setOverview] = useState<ModulesOverview | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filters
const [typeFilter, setTypeFilter] = useState<string>('all');
const [criticalityFilter, setCriticalityFilter] = useState<string>('all');
const [piiFilter, setPiiFilter] = useState<boolean | null>(null);
const [aiFilter, setAiFilter] = useState<boolean | null>(null);
const [searchTerm, setSearchTerm] = useState('');
// Selected module for detail view
const [selectedModule, setSelectedModule] = useState<ServiceModule | null>(null);
const [loadingDetail, setLoadingDetail] = useState(false);
// AI Risk Assessment
const [riskAssessment, setRiskAssessment] = useState<{
overall_risk: string;
risk_factors: Array<{ factor: string; severity: string; likelihood: string }>;
recommendations: string[];
compliance_gaps: string[];
confidence_score: number;
} | null>(null);
const [loadingRisk, setLoadingRisk] = useState(false);
const [showRiskPanel, setShowRiskPanel] = useState(false);
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000';
const API_BASE = `${BACKEND_URL}/api/v1/compliance`;
useEffect(() => {
fetchModules();
fetchOverview();
}, []);
const fetchModules = async () => {
try {
setLoading(true);
const params = new URLSearchParams();
if (typeFilter !== 'all') params.append('service_type', typeFilter);
if (criticalityFilter !== 'all') params.append('criticality', criticalityFilter);
if (piiFilter !== null) params.append('processes_pii', String(piiFilter));
if (aiFilter !== null) params.append('ai_components', String(aiFilter));
const url = `${API_BASE}/modules${params.toString() ? '?' + params.toString() : ''}`;
const res = await fetch(url);
if (!res.ok) throw new Error('Failed to fetch modules');
const data = await res.json();
setModules(data.modules || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
const fetchOverview = async () => {
try {
const res = await fetch(`${API_BASE}/modules/overview`);
if (!res.ok) throw new Error('Failed to fetch overview');
const data = await res.json();
setOverview(data);
} catch (err) {
console.error('Failed to fetch overview:', err);
}
};
const fetchModuleDetail = async (moduleId: string) => {
try {
setLoadingDetail(true);
const res = await fetch(`${API_BASE}/modules/${moduleId}`);
if (!res.ok) throw new Error('Failed to fetch module details');
const data = await res.json();
setSelectedModule(data);
} catch (err) {
console.error('Failed to fetch module details:', err);
} finally {
setLoadingDetail(false);
}
};
const seedModules = async (force: boolean = false) => {
try {
const res = await fetch(`${API_BASE}/modules/seed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force }),
});
if (!res.ok) throw new Error('Failed to seed modules');
const data = await res.json();
alert(`Seeded ${data.modules_created} modules with ${data.mappings_created} regulation mappings`);
fetchModules();
fetchOverview();
} catch (err) {
alert('Failed to seed modules: ' + (err instanceof Error ? err.message : 'Unknown error'));
}
};
const assessModuleRisk = async (moduleId: string) => {
setLoadingRisk(true);
setShowRiskPanel(true);
setRiskAssessment(null);
try {
const res = await fetch(`${API_BASE}/ai/assess-risk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ module_id: moduleId }),
});
if (res.ok) {
const data = await res.json();
setRiskAssessment(data);
} else {
alert('AI-Risikobewertung fehlgeschlagen');
}
} catch (err) {
alert('Netzwerkfehler bei AI-Risikobewertung');
} finally {
setLoadingRisk(false);
}
};
// Filter modules by search term
const filteredModules = modules.filter(m => {
if (!searchTerm) return true;
const term = searchTerm.toLowerCase();
return (
m.name.toLowerCase().includes(term) ||
m.display_name.toLowerCase().includes(term) ||
(m.description && m.description.toLowerCase().includes(term)) ||
m.technology_stack.some(t => t.toLowerCase().includes(term))
);
});
// Group by type for visualization
const modulesByType = filteredModules.reduce((acc, m) => {
const type = m.service_type || 'unknown';
if (!acc[type]) acc[type] = [];
acc[type].push(m);
return acc;
}, {} as Record<string, ServiceModule[]>);
const mp = useModulesPage();
return (
<div className="p-6 space-y-6">
@@ -216,233 +13,72 @@ export default function ModulesPage() {
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Service Module Registry</h1>
<p className="text-gray-600 mt-1">
Alle {overview?.total_modules || 0} Breakpilot-Services mit Regulation-Mappings
</p>
<p className="text-gray-600 mt-1">Alle {mp.overview?.total_modules || 0} Breakpilot-Services mit Regulation-Mappings</p>
</div>
<div className="flex gap-2">
<button
onClick={() => seedModules(false)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
>
Seed Modules
</button>
<button
onClick={() => seedModules(true)}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
>
Force Re-Seed
</button>
<button onClick={() => mp.seedModules(false)} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition">Seed Modules</button>
<button onClick={() => mp.seedModules(true)} className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition">Force Re-Seed</button>
</div>
</div>
{/* Overview Stats */}
{overview && (
{mp.overview && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div className="bg-white rounded-lg p-4 shadow border">
<div className="text-3xl font-bold text-blue-600">{overview.total_modules}</div>
<div className="text-sm text-gray-600">Services</div>
</div>
<div className="bg-white rounded-lg p-4 shadow border">
<div className="text-3xl font-bold text-red-600">{overview.modules_by_criticality?.critical || 0}</div>
<div className="text-sm text-gray-600">Critical</div>
</div>
<div className="bg-white rounded-lg p-4 shadow border">
<div className="text-3xl font-bold text-purple-600">{overview.modules_processing_pii}</div>
<div className="text-sm text-gray-600">PII-Processing</div>
</div>
<div className="bg-white rounded-lg p-4 shadow border">
<div className="text-3xl font-bold text-pink-600">{overview.modules_with_ai}</div>
<div className="text-sm text-gray-600">AI-Komponenten</div>
</div>
<div className="bg-white rounded-lg p-4 shadow border">
<div className="text-3xl font-bold text-green-600">
{Object.keys(overview.regulations_coverage || {}).length}
</div>
<div className="text-sm text-gray-600">Regulations</div>
</div>
<div className="bg-white rounded-lg p-4 shadow border">
<div className="text-3xl font-bold text-cyan-600">
{overview.average_compliance_score !== null
? `${overview.average_compliance_score}%`
: 'N/A'}
</div>
<div className="text-sm text-gray-600">Avg. Score</div>
</div>
<div className="bg-white rounded-lg p-4 shadow border"><div className="text-3xl font-bold text-blue-600">{mp.overview.total_modules}</div><div className="text-sm text-gray-600">Services</div></div>
<div className="bg-white rounded-lg p-4 shadow border"><div className="text-3xl font-bold text-red-600">{mp.overview.modules_by_criticality?.critical || 0}</div><div className="text-sm text-gray-600">Critical</div></div>
<div className="bg-white rounded-lg p-4 shadow border"><div className="text-3xl font-bold text-purple-600">{mp.overview.modules_processing_pii}</div><div className="text-sm text-gray-600">PII-Processing</div></div>
<div className="bg-white rounded-lg p-4 shadow border"><div className="text-3xl font-bold text-pink-600">{mp.overview.modules_with_ai}</div><div className="text-sm text-gray-600">AI-Komponenten</div></div>
<div className="bg-white rounded-lg p-4 shadow border"><div className="text-3xl font-bold text-green-600">{Object.keys(mp.overview.regulations_coverage || {}).length}</div><div className="text-sm text-gray-600">Regulations</div></div>
<div className="bg-white rounded-lg p-4 shadow border"><div className="text-3xl font-bold text-cyan-600">{mp.overview.average_compliance_score !== null ? `${mp.overview.average_compliance_score}%` : 'N/A'}</div><div className="text-sm text-gray-600">Avg. Score</div></div>
</div>
)}
{/* Filters */}
<div className="bg-white rounded-lg p-4 shadow border">
<div className="flex flex-wrap gap-4 items-center">
<div>
<label className="block text-xs text-gray-500 mb-1">Service Type</label>
<select
value={typeFilter}
onChange={(e) => { setTypeFilter(e.target.value); }}
className="border rounded px-3 py-2 text-sm"
>
<option value="all">Alle Typen</option>
<option value="backend">Backend</option>
<option value="database">Database</option>
<option value="ai">AI/ML</option>
<option value="communication">Communication</option>
<option value="storage">Storage</option>
<option value="infrastructure">Infrastructure</option>
<option value="monitoring">Monitoring</option>
<option value="security">Security</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Criticality</label>
<select
value={criticalityFilter}
onChange={(e) => { setCriticalityFilter(e.target.value); }}
className="border rounded px-3 py-2 text-sm"
>
<option value="all">Alle</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">PII</label>
<select
value={piiFilter === null ? 'all' : String(piiFilter)}
onChange={(e) => {
const val = e.target.value;
setPiiFilter(val === 'all' ? null : val === 'true');
}}
className="border rounded px-3 py-2 text-sm"
>
<option value="all">Alle</option>
<option value="true">Verarbeitet PII</option>
<option value="false">Keine PII</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">AI</label>
<select
value={aiFilter === null ? 'all' : String(aiFilter)}
onChange={(e) => {
const val = e.target.value;
setAiFilter(val === 'all' ? null : val === 'true');
}}
className="border rounded px-3 py-2 text-sm"
>
<option value="all">Alle</option>
<option value="true">Mit AI</option>
<option value="false">Ohne AI</option>
</select>
</div>
<div className="flex-1">
<label className="block text-xs text-gray-500 mb-1">Suche</label>
<input
type="text"
placeholder="Service, Beschreibung, Technologie..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="border rounded px-3 py-2 text-sm w-full"
/>
</div>
<div className="pt-5">
<button
onClick={fetchModules}
className="px-4 py-2 bg-gray-100 rounded hover:bg-gray-200 transition text-sm"
>
Filter anwenden
</button>
</div>
<div><label className="block text-xs text-gray-500 mb-1">Service Type</label><select value={mp.typeFilter} onChange={(e) => mp.setTypeFilter(e.target.value)} className="border rounded px-3 py-2 text-sm"><option value="all">Alle Typen</option>{['backend','database','ai','communication','storage','infrastructure','monitoring','security'].map(t => (<option key={t} value={t}>{t.charAt(0).toUpperCase()+t.slice(1)}</option>))}</select></div>
<div><label className="block text-xs text-gray-500 mb-1">Criticality</label><select value={mp.criticalityFilter} onChange={(e) => mp.setCriticalityFilter(e.target.value)} className="border rounded px-3 py-2 text-sm"><option value="all">Alle</option>{['critical','high','medium','low'].map(c => (<option key={c} value={c}>{c.charAt(0).toUpperCase()+c.slice(1)}</option>))}</select></div>
<div><label className="block text-xs text-gray-500 mb-1">PII</label><select value={mp.piiFilter === null ? 'all' : String(mp.piiFilter)} onChange={(e) => { const val = e.target.value; mp.setPiiFilter(val === 'all' ? null : val === 'true') }} className="border rounded px-3 py-2 text-sm"><option value="all">Alle</option><option value="true">Verarbeitet PII</option><option value="false">Keine PII</option></select></div>
<div><label className="block text-xs text-gray-500 mb-1">AI</label><select value={mp.aiFilter === null ? 'all' : String(mp.aiFilter)} onChange={(e) => { const val = e.target.value; mp.setAiFilter(val === 'all' ? null : val === 'true') }} className="border rounded px-3 py-2 text-sm"><option value="all">Alle</option><option value="true">Mit AI</option><option value="false">Ohne AI</option></select></div>
<div className="flex-1"><label className="block text-xs text-gray-500 mb-1">Suche</label><input type="text" placeholder="Service, Beschreibung, Technologie..." value={mp.searchTerm} onChange={(e) => mp.setSearchTerm(e.target.value)} className="border rounded px-3 py-2 text-sm w-full" /></div>
<div className="pt-5"><button onClick={mp.fetchModules} className="px-4 py-2 bg-gray-100 rounded hover:bg-gray-200 transition text-sm">Filter anwenden</button></div>
</div>
</div>
{/* Error */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 p-4 rounded-lg">
{error}
</div>
)}
{mp.error && (<div className="bg-red-50 border border-red-200 text-red-700 p-4 rounded-lg">{mp.error}</div>)}
{mp.loading && (<div className="text-center py-12 text-gray-500">Lade Module...</div>)}
{/* Loading */}
{loading && (
<div className="text-center py-12 text-gray-500">
Lade Module...
</div>
)}
{/* Main Content - Two Column Layout */}
{!loading && (
{/* Main Content */}
{!mp.loading && (
<div className="flex gap-6">
{/* Module List */}
<div className="flex-1 space-y-4">
{Object.entries(modulesByType).map(([type, typeModules]) => (
{Object.entries(mp.modulesByType).map(([type, typeModules]) => (
<div key={type} className="bg-white rounded-lg shadow border">
<div className={`px-4 py-2 border-b ${SERVICE_TYPE_CONFIG[type]?.bgColor || 'bg-gray-100'}`}>
<span className="text-lg mr-2">{SERVICE_TYPE_CONFIG[type]?.icon || '📁'}</span>
<span className={`font-semibold ${SERVICE_TYPE_CONFIG[type]?.color || 'text-gray-700'}`}>
{type.charAt(0).toUpperCase() + type.slice(1)}
</span>
<span className={`font-semibold ${SERVICE_TYPE_CONFIG[type]?.color || 'text-gray-700'}`}>{type.charAt(0).toUpperCase() + type.slice(1)}</span>
<span className="text-gray-500 ml-2">({typeModules.length})</span>
</div>
<div className="divide-y">
{typeModules.map((module) => (
<div
key={module.id}
onClick={() => fetchModuleDetail(module.name)}
className={`p-4 cursor-pointer hover:bg-gray-50 transition ${
selectedModule?.id === module.id ? 'bg-blue-50' : ''
}`}
>
{typeModules.map((mod) => (
<div key={mod.id} onClick={() => mp.fetchModuleDetail(mod.name)} className={`p-4 cursor-pointer hover:bg-gray-50 transition ${mp.selectedModule?.id === mod.id ? 'bg-blue-50' : ''}`}>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">{module.display_name}</span>
{module.port && (
<span className="text-xs text-gray-400">:{module.port}</span>
)}
</div>
<div className="text-sm text-gray-500 mt-1">{module.name}</div>
{module.description && (
<div className="text-sm text-gray-600 mt-1 line-clamp-2">
{module.description}
</div>
)}
<div className="flex items-center gap-2"><span className="font-medium text-gray-900">{mod.display_name}</span>{mod.port && (<span className="text-xs text-gray-400">:{mod.port}</span>)}</div>
<div className="text-sm text-gray-500 mt-1">{mod.name}</div>
{mod.description && (<div className="text-sm text-gray-600 mt-1 line-clamp-2">{mod.description}</div>)}
<div className="flex flex-wrap gap-1 mt-2">
{module.technology_stack.slice(0, 4).map((tech, i) => (
<span key={i} className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded">
{tech}
</span>
))}
{module.technology_stack.length > 4 && (
<span className="px-2 py-0.5 text-gray-400 text-xs">
+{module.technology_stack.length - 4}
</span>
)}
{mod.technology_stack.slice(0, 4).map((tech, i) => (<span key={i} className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded">{tech}</span>))}
{mod.technology_stack.length > 4 && (<span className="px-2 py-0.5 text-gray-400 text-xs">+{mod.technology_stack.length - 4}</span>)}
</div>
</div>
<div className="flex flex-col items-end gap-1">
<span className={`px-2 py-0.5 text-xs rounded ${
CRITICALITY_CONFIG[module.criticality]?.bgColor || 'bg-gray-100'
} ${CRITICALITY_CONFIG[module.criticality]?.color || 'text-gray-700'}`}>
{module.criticality}
</span>
<span className={`px-2 py-0.5 text-xs rounded ${CRITICALITY_CONFIG[mod.criticality]?.bgColor || 'bg-gray-100'} ${CRITICALITY_CONFIG[mod.criticality]?.color || 'text-gray-700'}`}>{mod.criticality}</span>
<div className="flex gap-1 mt-1">
{module.processes_pii && (
<span className="px-1.5 py-0.5 bg-purple-100 text-purple-700 text-xs rounded" title="Verarbeitet PII">
PII
</span>
)}
{module.ai_components && (
<span className="px-1.5 py-0.5 bg-pink-100 text-pink-700 text-xs rounded" title="AI-Komponenten">
AI
</span>
)}
</div>
<div className="text-xs text-gray-400 mt-1">
{module.regulation_count} Regulations
{mod.processes_pii && (<span className="px-1.5 py-0.5 bg-purple-100 text-purple-700 text-xs rounded" title="Verarbeitet PII">PII</span>)}
{mod.ai_components && (<span className="px-1.5 py-0.5 bg-pink-100 text-pink-700 text-xs rounded" title="AI-Komponenten">AI</span>)}
</div>
<div className="text-xs text-gray-400 mt-1">{mod.regulation_count} Regulations</div>
</div>
</div>
</div>
@@ -450,293 +86,34 @@ export default function ModulesPage() {
</div>
</div>
))}
{filteredModules.length === 0 && !loading && (
<div className="text-center py-12 text-gray-500 bg-white rounded-lg shadow border">
Keine Module gefunden.
<button
onClick={() => seedModules(false)}
className="text-blue-600 hover:underline ml-1"
>
Jetzt seeden?
</button>
</div>
{mp.filteredModules.length === 0 && !mp.loading && (
<div className="text-center py-12 text-gray-500 bg-white rounded-lg shadow border">Keine Module gefunden.<button onClick={() => mp.seedModules(false)} className="text-blue-600 hover:underline ml-1">Jetzt seeden?</button></div>
)}
</div>
{/* Detail Panel */}
{selectedModule && (
<div className="w-96 bg-white rounded-lg shadow border sticky top-6 h-fit">
<div className={`px-4 py-3 border-b ${SERVICE_TYPE_CONFIG[selectedModule.service_type]?.bgColor || 'bg-gray-100'}`}>
<div className="flex items-center justify-between">
<span className="text-lg">{SERVICE_TYPE_CONFIG[selectedModule.service_type]?.icon || '📁'}</span>
<button
onClick={() => setSelectedModule(null)}
className="text-gray-400 hover:text-gray-600"
>
</button>
</div>
<h3 className="font-bold text-lg mt-2">{selectedModule.display_name}</h3>
<div className="text-sm text-gray-600">{selectedModule.name}</div>
</div>
{loadingDetail ? (
<div className="p-4 text-center text-gray-500">Lade Details...</div>
) : (
<div className="p-4 space-y-4">
{/* Description */}
{selectedModule.description && (
<div>
<div className="text-xs text-gray-500 uppercase mb-1">Beschreibung</div>
<div className="text-sm text-gray-700">{selectedModule.description}</div>
</div>
)}
{/* Technical Details */}
<div className="grid grid-cols-2 gap-2 text-sm">
{selectedModule.port && (
<div>
<span className="text-gray-500">Port:</span>
<span className="ml-1 font-mono">{selectedModule.port}</span>
</div>
)}
<div>
<span className="text-gray-500">Criticality:</span>
<span className={`ml-1 px-1.5 py-0.5 rounded text-xs ${
CRITICALITY_CONFIG[selectedModule.criticality]?.bgColor || ''
} ${CRITICALITY_CONFIG[selectedModule.criticality]?.color || ''}`}>
{selectedModule.criticality}
</span>
</div>
</div>
{/* Technology Stack */}
<div>
<div className="text-xs text-gray-500 uppercase mb-1">Tech Stack</div>
<div className="flex flex-wrap gap-1">
{selectedModule.technology_stack.map((tech, i) => (
<span key={i} className="px-2 py-0.5 bg-gray-100 text-gray-700 text-xs rounded">
{tech}
</span>
))}
</div>
</div>
{/* Data Categories */}
{selectedModule.data_categories.length > 0 && (
<div>
<div className="text-xs text-gray-500 uppercase mb-1">Daten-Kategorien</div>
<div className="flex flex-wrap gap-1">
{selectedModule.data_categories.map((cat, i) => (
<span key={i} className="px-2 py-0.5 bg-blue-50 text-blue-700 text-xs rounded">
{cat}
</span>
))}
</div>
</div>
)}
{/* Flags */}
<div className="flex flex-wrap gap-2">
{selectedModule.processes_pii && (
<span className="px-2 py-1 bg-purple-100 text-purple-700 text-xs rounded">
Verarbeitet PII
</span>
)}
{selectedModule.ai_components && (
<span className="px-2 py-1 bg-pink-100 text-pink-700 text-xs rounded">
AI-Komponenten
</span>
)}
{selectedModule.processes_health_data && (
<span className="px-2 py-1 bg-red-100 text-red-700 text-xs rounded">
Gesundheitsdaten
</span>
)}
</div>
{/* Regulations */}
{selectedModule.regulations && selectedModule.regulations.length > 0 && (
<div>
<div className="text-xs text-gray-500 uppercase mb-2">
Applicable Regulations ({selectedModule.regulations.length})
</div>
<div className="space-y-2">
{selectedModule.regulations.map((reg, i) => (
<div key={i} className="p-2 bg-gray-50 rounded text-sm">
<div className="flex justify-between items-start">
<span className="font-medium">{reg.code}</span>
<span className={`px-1.5 py-0.5 rounded text-xs ${
RELEVANCE_CONFIG[reg.relevance_level]?.bgColor || 'bg-gray-100'
} ${RELEVANCE_CONFIG[reg.relevance_level]?.color || 'text-gray-700'}`}>
{reg.relevance_level}
</span>
</div>
<div className="text-gray-500 text-xs">{reg.name}</div>
{reg.notes && (
<div className="text-gray-600 text-xs mt-1 italic">{reg.notes}</div>
)}
</div>
))}
</div>
</div>
)}
{/* Owner */}
{selectedModule.owner_team && (
<div>
<div className="text-xs text-gray-500 uppercase mb-1">Owner</div>
<div className="text-sm text-gray-700">{selectedModule.owner_team}</div>
</div>
)}
{/* Repository */}
{selectedModule.repository_path && (
<div>
<div className="text-xs text-gray-500 uppercase mb-1">Repository</div>
<code className="text-xs bg-gray-100 px-2 py-1 rounded block">
{selectedModule.repository_path}
</code>
</div>
)}
{/* AI Risk Assessment Button */}
<div className="pt-2 border-t">
<button
onClick={() => assessModuleRisk(selectedModule.id)}
disabled={loadingRisk}
className="w-full px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 text-white rounded-lg hover:from-purple-700 hover:to-pink-700 transition disabled:opacity-50 flex items-center justify-center gap-2"
>
{loadingRisk ? (
<>
<span className="animate-spin"></span>
AI analysiert...
</>
) : (
<>
<span>🤖</span>
AI Risikobewertung
</>
)}
</button>
</div>
{/* AI Risk Assessment Panel */}
{showRiskPanel && (
<div className="mt-4 p-4 bg-gradient-to-br from-purple-50 to-pink-50 rounded-lg border border-purple-200">
<div className="flex justify-between items-center mb-3">
<h4 className="font-semibold text-purple-900 flex items-center gap-2">
<span>🤖</span> AI Risikobewertung
</h4>
<button
onClick={() => setShowRiskPanel(false)}
className="text-purple-400 hover:text-purple-600"
>
</button>
</div>
{loadingRisk ? (
<div className="text-center py-4 text-purple-600">
<div className="animate-pulse">Analysiere Compliance-Risiken...</div>
</div>
) : riskAssessment ? (
<div className="space-y-3">
{/* Overall Risk */}
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">Gesamtrisiko:</span>
<span className={`px-2 py-1 rounded text-sm font-medium ${
riskAssessment.overall_risk === 'critical' ? 'bg-red-100 text-red-700' :
riskAssessment.overall_risk === 'high' ? 'bg-orange-100 text-orange-700' :
riskAssessment.overall_risk === 'medium' ? 'bg-yellow-100 text-yellow-700' :
'bg-green-100 text-green-700'
}`}>
{riskAssessment.overall_risk.toUpperCase()}
</span>
<span className="text-xs text-gray-400">
({Math.round(riskAssessment.confidence_score * 100)}% Konfidenz)
</span>
</div>
{/* Risk Factors */}
{riskAssessment.risk_factors.length > 0 && (
<div>
<div className="text-xs text-gray-500 uppercase mb-1">Risikofaktoren</div>
<div className="space-y-1">
{riskAssessment.risk_factors.map((factor, i) => (
<div key={i} className="flex items-center justify-between text-sm bg-white/50 rounded px-2 py-1">
<span className="text-gray-700">{factor.factor}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${
factor.severity === 'critical' || factor.severity === 'high'
? 'bg-red-100 text-red-600'
: 'bg-yellow-100 text-yellow-600'
}`}>
{factor.severity}
</span>
</div>
))}
</div>
</div>
)}
{/* Compliance Gaps */}
{riskAssessment.compliance_gaps.length > 0 && (
<div>
<div className="text-xs text-gray-500 uppercase mb-1">Compliance-Lücken</div>
<ul className="text-sm text-gray-700 space-y-1">
{riskAssessment.compliance_gaps.map((gap, i) => (
<li key={i} className="flex items-start gap-1">
<span className="text-red-500"></span>
<span>{gap}</span>
</li>
))}
</ul>
</div>
)}
{/* Recommendations */}
{riskAssessment.recommendations.length > 0 && (
<div>
<div className="text-xs text-gray-500 uppercase mb-1">Empfehlungen</div>
<ul className="text-sm text-gray-700 space-y-1">
{riskAssessment.recommendations.map((rec, i) => (
<li key={i} className="flex items-start gap-1">
<span className="text-green-500"></span>
<span>{rec}</span>
</li>
))}
</ul>
</div>
)}
</div>
) : (
<div className="text-center py-4 text-gray-500 text-sm">
Klicken Sie auf &quot;AI Risikobewertung&quot; um eine Analyse zu starten.
</div>
)}
</div>
)}
</div>
)}
</div>
{mp.selectedModule && (
<ModuleDetailPanel
module={mp.selectedModule}
loadingDetail={mp.loadingDetail}
loadingRisk={mp.loadingRisk}
showRiskPanel={mp.showRiskPanel}
riskAssessment={mp.riskAssessment}
onClose={() => mp.setSelectedModule(null)}
onAssessRisk={mp.assessModuleRisk}
onCloseRisk={() => mp.setShowRiskPanel(false)}
/>
)}
</div>
)}
{/* Regulations Coverage Overview */}
{overview && overview.regulations_coverage && Object.keys(overview.regulations_coverage).length > 0 && (
{/* Regulations Coverage */}
{mp.overview && mp.overview.regulations_coverage && Object.keys(mp.overview.regulations_coverage).length > 0 && (
<div className="bg-white rounded-lg shadow border p-4">
<h3 className="font-semibold text-gray-900 mb-4">Regulation Coverage</h3>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-3">
{Object.entries(overview.regulations_coverage)
.sort(([, a], [, b]) => b - a)
.map(([code, count]) => (
<div key={code} className="bg-gray-50 rounded p-3 text-center">
<div className="text-2xl font-bold text-blue-600">{count}</div>
<div className="text-xs text-gray-600 truncate" title={code}>{code}</div>
</div>
))}
{Object.entries(mp.overview.regulations_coverage).sort(([, a], [, b]) => b - a).map(([code, count]) => (
<div key={code} className="bg-gray-50 rounded p-3 text-center"><div className="text-2xl font-bold text-blue-600">{count}</div><div className="text-xs text-gray-600 truncate" title={code}>{code}</div></div>
))}
</div>
</div>
)}

View File

@@ -0,0 +1,162 @@
'use client'
import {
Source, ScraperStatus, ScrapeResult,
PDFDocument, PDFExtractionResult,
} from './types'
import SourceCard from './SourceCard'
interface ScraperTabsProps {
activeTab: string
sources: Source[]
pdfDocuments: PDFDocument[]
status: ScraperStatus | null
scraping: boolean
extracting: boolean
results: ScrapeResult[]
pdfResult: PDFExtractionResult | null
handleScrapeAll: () => void
handleScrapeSingle: (code: string, force: boolean) => void
handleExtractPdf: (code: string, saveToDb: boolean, force: boolean) => void
}
export default function ScraperTabs(props: ScraperTabsProps) {
const { activeTab, sources, pdfDocuments, status, scraping, extracting, results, pdfResult } = props
if (activeTab === 'sources') {
return (
<div>
<div className="flex justify-between items-center mb-6">
<div>
<h3 className="text-lg font-semibold text-slate-900">Regulierungsquellen</h3>
<p className="text-sm text-slate-500">EU-Lex, BSI-TR und deutsche Gesetze</p>
</div>
<button onClick={props.handleScrapeAll} disabled={scraping} className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
{scraping ? (<><svg className="w-4 h-4 animate-spin" 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>Laeuft...</>) : (<><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>Alle Quellen scrapen</>)}
</button>
</div>
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3 flex items-center gap-2"><span className="text-lg">🇪🇺</span> EU-Regulierungen (EUR-Lex)</h4>
<div className="grid gap-3">{sources.filter(s => s.source_type === 'eur_lex').map(source => (<SourceCard key={source.code} source={source} onScrape={props.handleScrapeSingle} scraping={scraping} />))}</div>
</div>
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3 flex items-center gap-2"><span className="text-lg">🔒</span> BSI Technical Guidelines</h4>
<div className="grid gap-3">{sources.filter(s => s.source_type === 'bsi_pdf').map(source => (<SourceCard key={source.code} source={source} onScrape={props.handleScrapeSingle} scraping={scraping} />))}</div>
</div>
</div>
</div>
)
}
if (activeTab === 'pdf') {
return (
<div>
<div className="mb-6">
<h3 className="text-lg font-semibold text-slate-900">PDF-Extraktion (PyMuPDF)</h3>
<p className="text-sm text-slate-500">Extrahiert ALLE Pruefaspekte aus BSI-TR-03161 PDFs mit Regex-Pattern-Matching</p>
</div>
<div className="space-y-4">
{pdfDocuments.map(doc => (
<div key={doc.code} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-3xl">📄</span>
<div>
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-900">{doc.code}</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${doc.available ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>{doc.available ? 'Verfuegbar' : 'Nicht gefunden'}</span>
</div>
<div className="text-sm text-slate-600">{doc.name}</div>
<div className="text-xs text-slate-500">{doc.description}</div>
<div className="text-xs text-slate-400 mt-1">Erwartete Pruefaspekte: {doc.expected_aspects}</div>
</div>
</div>
<div className="flex gap-2">
<button onClick={() => props.handleExtractPdf(doc.code, true, false)} disabled={extracting || !doc.available} className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
{extracting ? (<><svg className="w-4 h-4 animate-spin" 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>Extrahiere...</>) : (<><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>Extrahieren</>)}
</button>
<button onClick={() => props.handleExtractPdf(doc.code, true, true)} disabled={extracting || !doc.available} className="px-3 py-2 bg-orange-100 text-orange-700 rounded-lg hover:bg-orange-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" title="Force: Loescht vorhandene und extrahiert neu">Force</button>
</div>
</div>
</div>
))}
</div>
{pdfResult && (
<div className="mt-6 bg-green-50 rounded-lg p-4 border border-green-200">
<h4 className="font-semibold text-green-800 mb-3">Letztes Extraktions-Ergebnis</h4>
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="text-center p-3 bg-white rounded-lg"><div className="text-2xl font-bold text-green-700">{pdfResult.total_aspects}</div><div className="text-sm text-slate-500">Pruefaspekte gefunden</div></div>
<div className="text-center p-3 bg-white rounded-lg"><div className="text-2xl font-bold text-blue-700">{pdfResult.requirements_created}</div><div className="text-sm text-slate-500">Requirements erstellt</div></div>
<div className="text-center p-3 bg-white rounded-lg"><div className="text-2xl font-bold text-slate-700">{Object.keys(pdfResult.statistics.by_category || {}).length}</div><div className="text-sm text-slate-500">Kategorien</div></div>
</div>
{pdfResult.statistics.by_category && Object.keys(pdfResult.statistics.by_category).length > 0 && (
<div><h5 className="text-sm font-medium text-slate-700 mb-2">Nach Kategorie:</h5><div className="flex flex-wrap gap-2">{Object.entries(pdfResult.statistics.by_category).map(([cat, count]) => (<span key={cat} className="px-2 py-1 bg-white rounded text-xs text-slate-600">{cat}: <strong>{count}</strong></span>))}</div></div>
)}
</div>
)}
<div className="mt-6 bg-blue-50 rounded-lg p-4 border border-blue-200">
<h4 className="font-semibold text-blue-800 mb-2">Wie funktioniert die PDF-Extraktion?</h4>
<ul className="text-sm text-blue-700 space-y-1">
<li>- <strong>PyMuPDF (fitz)</strong> liest den PDF-Text</li>
<li>- <strong>Regex-Pattern</strong> finden Aspekte wie O.Auth_1, O.Sess_2, T.Network_1</li>
<li>- <strong>Kontextanalyse</strong> extrahiert Titel, Kategorie und Anforderungsstufe (MUSS/SOLL/KANN)</li>
<li>- <strong>Automatische Speicherung</strong> erstellt Requirements in der Datenbank</li>
</ul>
</div>
</div>
)
}
if (activeTab === 'status' && status) {
return (
<div className="space-y-6">
<div className="bg-slate-50 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<div><h3 className="text-lg font-semibold text-slate-900">Scraper-Status</h3><p className="text-sm text-slate-500">Letzter Lauf: {status.stats.last_run ? new Date(status.stats.last_run).toLocaleString('de-DE') : 'Noch nie'}</p></div>
<div className={`px-3 py-1.5 rounded-full text-sm font-medium ${status.status === 'running' ? 'bg-blue-100 text-blue-700' : status.status === 'error' ? 'bg-red-100 text-red-700' : status.status === 'completed' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'}`}>
{status.status === 'running' ? 'Laeuft' : status.status === 'error' ? 'Fehler' : status.status === 'completed' ? 'Abgeschlossen' : 'Bereit'}
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="text-center p-4 bg-white rounded-lg"><div className="text-2xl font-bold text-slate-900">{status.stats.sources_processed}</div><div className="text-sm text-slate-500">Quellen verarbeitet</div></div>
<div className="text-center p-4 bg-white rounded-lg"><div className="text-2xl font-bold text-green-600">{status.stats.requirements_extracted}</div><div className="text-sm text-slate-500">Anforderungen extrahiert</div></div>
<div className="text-center p-4 bg-white rounded-lg"><div className="text-2xl font-bold text-red-600">{status.stats.errors}</div><div className="text-sm text-slate-500">Fehler</div></div>
</div>
{status.last_error && (<div className="mt-4 p-3 bg-red-50 rounded-lg text-sm text-red-700"><strong>Letzter Fehler:</strong> {status.last_error}</div>)}
</div>
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h4 className="font-semibold text-slate-900 mb-4">Wie funktioniert der Scraper?</h4>
<div className="space-y-3 text-sm text-slate-600">
{[{ n: '1', t: 'EUR-Lex Abruf', d: 'Holt HTML-Version der EU-Verordnung, extrahiert Artikel und Absaetze' }, { n: '2', t: 'BSI-TR Parsing', d: 'Extrahiert Pruefaspekte (O.Auth_1, O.Sess_1, etc.) aus den TR-Dokumenten' }, { n: '3', t: 'Datenbank-Speicherung', d: 'Jede Anforderung wird als Requirement in der Compliance-DB gespeichert' }].map(s => (
<div key={s.n} className="flex items-start gap-3"><div className="w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold">{s.n}</div><div><strong>{s.t}</strong>: {s.d}</div></div>
))}
<div className="flex items-start gap-3"><div className="w-6 h-6 bg-green-100 rounded-full flex items-center justify-center text-green-600 font-bold"></div><div><strong>Audit-Workspace</strong>: Anforderungen koennen mit Implementierungsdetails angereichert werden</div></div>
</div>
</div>
</div>
)
}
// logs tab
return (
<div>
<h3 className="text-lg font-semibold text-slate-900 mb-4">Letzte Ergebnisse</h3>
{results.length === 0 ? (
<div className="text-center py-12 text-slate-500">Keine Ergebnisse vorhanden. Starte einen Scrape-Vorgang.</div>
) : (
<div className="space-y-2">
{results.map((result, idx) => (
<div key={idx} className={`p-3 rounded-lg flex items-center justify-between ${result.error ? 'bg-red-50' : result.reason ? 'bg-yellow-50' : 'bg-green-50'}`}>
<div className="flex items-center gap-3">
<span className="text-lg">{result.error ? '❌' : result.reason ? '⏭️' : '✅'}</span>
<span className="font-medium">{result.code}</span>
<span className="text-sm text-slate-500">{result.error || result.reason || `${result.requirements_extracted} Anforderungen`}</span>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,73 @@
'use client'
import { Source, regulationTypeBadge, sourceTypeBadge } from './types'
export default function SourceCard({
source,
onScrape,
scraping,
}: {
source: Source
onScrape: (code: string, force: boolean) => void
scraping: boolean
}) {
const regType = regulationTypeBadge[source.regulation_type] || regulationTypeBadge.industry_standard
const srcType = sourceTypeBadge[source.source_type] || sourceTypeBadge.manual
return (
<div className="bg-white border border-slate-200 rounded-lg p-4 hover:shadow-sm transition-shadow">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">{regType.icon}</span>
<div>
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-900">{source.code}</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${regType.color}`}>
{regType.label}
</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${srcType.color}`}>
{srcType.label}
</span>
</div>
<div className="text-sm text-slate-500 truncate max-w-md" title={source.url}>
{source.url.length > 60 ? source.url.substring(0, 60) + '...' : source.url}
</div>
</div>
</div>
<div className="flex items-center gap-3">
{source.has_data ? (
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm font-medium">
{source.requirement_count} Anforderungen
</span>
) : (
<span className="px-3 py-1 bg-gray-100 text-gray-500 rounded-full text-sm">
Keine Daten
</span>
)}
<div className="flex gap-1">
<button
onClick={() => onScrape(source.code, false)}
disabled={scraping}
className="px-3 py-1.5 text-sm bg-slate-100 text-slate-700 rounded hover:bg-slate-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Scrapen (ueberspringt vorhandene)"
>
Scrapen
</button>
{source.has_data && (
<button
onClick={() => onScrape(source.code, true)}
disabled={scraping}
className="px-3 py-1.5 text-sm bg-orange-100 text-orange-700 rounded hover:bg-orange-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Force: Loescht vorhandene Daten und scraped neu"
>
Force
</button>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,65 @@
export interface Source {
code: string
url: string
source_type: string
regulation_type: string
has_data: boolean
requirement_count: number
}
export interface ScraperStatus {
status: 'idle' | 'running' | 'completed' | 'error'
current_source: string | null
last_error: string | null
stats: {
sources_processed: number
requirements_extracted: number
errors: number
last_run: string | null
}
known_sources: string[]
}
export interface ScrapeResult {
code: string
status: string
requirements_extracted?: number
reason?: string
error?: string
}
export interface PDFDocument {
code: string
name: string
description: string
expected_aspects: string
available: boolean
}
export interface PDFExtractionResult {
success: boolean
source_document: string
total_aspects: number
requirements_created: number
statistics: {
by_category: Record<string, number>
by_requirement_level: Record<string, number>
}
}
export const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
export const sourceTypeBadge: Record<string, { label: string; color: string }> = {
eur_lex: { label: 'EUR-Lex', color: 'bg-blue-100 text-blue-800' },
bsi_pdf: { label: 'BSI PDF', color: 'bg-green-100 text-green-800' },
gesetze_im_internet: { label: 'Gesetze', color: 'bg-yellow-100 text-yellow-800' },
manual: { label: 'Manuell', color: 'bg-gray-100 text-gray-800' },
}
export const regulationTypeBadge: Record<string, { label: string; color: string; icon: string }> = {
eu_regulation: { label: 'EU-Verordnung', color: 'bg-indigo-100 text-indigo-800', icon: '🇪🇺' },
eu_directive: { label: 'EU-Richtlinie', color: 'bg-purple-100 text-purple-800', icon: '📜' },
de_law: { label: 'DE-Gesetz', color: 'bg-yellow-100 text-yellow-800', icon: '🇩🇪' },
bsi_standard: { label: 'BSI-Standard', color: 'bg-green-100 text-green-800', icon: '🔒' },
industry_standard: { label: 'Standard', color: 'bg-gray-100 text-gray-800', icon: '📋' },
}

View File

@@ -0,0 +1,106 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import {
Source, ScraperStatus, ScrapeResult,
PDFDocument, PDFExtractionResult, BACKEND_URL,
} from './types'
export function useComplianceScraper() {
const [activeTab, setActiveTab] = useState<'sources' | 'pdf' | 'status' | 'logs'>('sources')
const [sources, setSources] = useState<Source[]>([])
const [pdfDocuments, setPdfDocuments] = useState<PDFDocument[]>([])
const [status, setStatus] = useState<ScraperStatus | null>(null)
const [loading, setLoading] = useState(true)
const [scraping, setScraping] = useState(false)
const [extracting, setExtracting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [results, setResults] = useState<ScrapeResult[]>([])
const [pdfResult, setPdfResult] = useState<PDFExtractionResult | null>(null)
const fetchSources = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/scraper/sources`)
if (res.ok) { const data = await res.json(); setSources(data.sources || []) }
} catch (err) { console.error('Failed to fetch sources:', err) }
}, [])
const fetchPdfDocuments = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/scraper/pdf-documents`)
if (res.ok) { const data = await res.json(); setPdfDocuments(data.documents || []) }
} catch (err) { console.error('Failed to fetch PDF documents:', err) }
}, [])
const fetchStatus = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/scraper/status`)
if (res.ok) { const data = await res.json(); setStatus(data) }
} catch (err) { console.error('Failed to fetch status:', err) }
}, [])
useEffect(() => {
const loadData = async () => {
setLoading(true)
await Promise.all([fetchSources(), fetchStatus(), fetchPdfDocuments()])
setLoading(false)
}
loadData()
}, [fetchSources, fetchStatus, fetchPdfDocuments])
useEffect(() => {
if (scraping) { const interval = setInterval(fetchStatus, 2000); return () => clearInterval(interval) }
}, [scraping, fetchStatus])
const handleScrapeAll = async () => {
setScraping(true); setError(null); setSuccess(null); setResults([])
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/scraper/scrape-all`, { method: 'POST' })
if (!res.ok) { const data = await res.json(); throw new Error(data.detail || 'Scraping fehlgeschlagen') }
const data = await res.json()
setResults([...data.results.success, ...data.results.failed, ...data.results.skipped])
setSuccess(`Scraping abgeschlossen: ${data.results.success.length} erfolgreich, ${data.results.skipped.length} uebersprungen, ${data.results.failed.length} fehlgeschlagen`)
await fetchSources()
} catch (err: any) { setError(err.message) }
finally { setScraping(false) }
}
const handleScrapeSingle = async (code: string, force: boolean = false) => {
setScraping(true); setError(null); setSuccess(null)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/scraper/scrape/${code}?force=${force}`, { method: 'POST' })
if (!res.ok) { const data = await res.json(); throw new Error(data.detail || 'Scraping fehlgeschlagen') }
const data = await res.json()
if (data.status === 'skipped') { setSuccess(`${code}: Bereits vorhanden (${data.requirement_count} Anforderungen)`) }
else { setSuccess(`${code}: ${data.requirements_extracted} Anforderungen extrahiert`) }
await fetchSources()
} catch (err: any) { setError(err.message) }
finally { setScraping(false) }
}
const handleExtractPdf = async (code: string, saveToDb: boolean = true, force: boolean = false) => {
setExtracting(true); setError(null); setSuccess(null); setPdfResult(null)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/scraper/extract-pdf`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ document_code: code, save_to_db: saveToDb, force }),
})
if (!res.ok) { const data = await res.json(); throw new Error(data.detail || 'PDF-Extraktion fehlgeschlagen') }
const data: PDFExtractionResult = await res.json()
setPdfResult(data)
if (data.success) { setSuccess(`${code}: ${data.total_aspects} Pruefaspekte extrahiert, ${data.requirements_created} Requirements erstellt`) }
await fetchSources()
} catch (err: any) { setError(err.message) }
finally { setExtracting(false) }
}
useEffect(() => { if (success) { const timer = setTimeout(() => setSuccess(null), 5000); return () => clearTimeout(timer) } }, [success])
useEffect(() => { if (error) { const timer = setTimeout(() => setError(null), 10000); return () => clearTimeout(timer) } }, [error])
return {
activeTab, setActiveTab, sources, pdfDocuments, status,
loading, scraping, extracting, error, success, results, pdfResult,
handleScrapeAll, handleScrapeSingle, handleExtractPdf,
}
}

View File

@@ -7,283 +7,20 @@
* - EUR-Lex regulations (GDPR, AI Act, CRA, NIS2, etc.)
* - BSI Technical Guidelines (TR-03161)
* - German laws
*
* Similar pattern to edu-search and zeugnisse-crawler.
*/
import { useState, useEffect, useCallback } from 'react'
import AdminLayout from '@/components/admin/AdminLayout'
import SystemInfoSection, { SYSTEM_INFO_CONFIGS } from '@/components/admin/SystemInfoSection'
// Types
interface Source {
code: string
url: string
source_type: string
regulation_type: string
has_data: boolean
requirement_count: number
}
interface ScraperStatus {
status: 'idle' | 'running' | 'completed' | 'error'
current_source: string | null
last_error: string | null
stats: {
sources_processed: number
requirements_extracted: number
errors: number
last_run: string | null
}
known_sources: string[]
}
interface ScrapeResult {
code: string
status: string
requirements_extracted?: number
reason?: string
error?: string
}
interface PDFDocument {
code: string
name: string
description: string
expected_aspects: string
available: boolean
}
interface PDFExtractionResult {
success: boolean
source_document: string
total_aspects: number
requirements_created: number
statistics: {
by_category: Record<string, number>
by_requirement_level: Record<string, number>
}
}
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
// Source type badges
const sourceTypeBadge: Record<string, { label: string; color: string }> = {
eur_lex: { label: 'EUR-Lex', color: 'bg-blue-100 text-blue-800' },
bsi_pdf: { label: 'BSI PDF', color: 'bg-green-100 text-green-800' },
gesetze_im_internet: { label: 'Gesetze', color: 'bg-yellow-100 text-yellow-800' },
manual: { label: 'Manuell', color: 'bg-gray-100 text-gray-800' },
}
// Regulation type badges
const regulationTypeBadge: Record<string, { label: string; color: string; icon: string }> = {
eu_regulation: { label: 'EU-Verordnung', color: 'bg-indigo-100 text-indigo-800', icon: '🇪🇺' },
eu_directive: { label: 'EU-Richtlinie', color: 'bg-purple-100 text-purple-800', icon: '📜' },
de_law: { label: 'DE-Gesetz', color: 'bg-yellow-100 text-yellow-800', icon: '🇩🇪' },
bsi_standard: { label: 'BSI-Standard', color: 'bg-green-100 text-green-800', icon: '🔒' },
industry_standard: { label: 'Standard', color: 'bg-gray-100 text-gray-800', icon: '📋' },
}
import { useComplianceScraper } from './_components/useComplianceScraper'
import ScraperTabs from './_components/ScraperTabs'
export default function ComplianceScraperPage() {
const [activeTab, setActiveTab] = useState<'sources' | 'pdf' | 'status' | 'logs'>('sources')
const [sources, setSources] = useState<Source[]>([])
const [pdfDocuments, setPdfDocuments] = useState<PDFDocument[]>([])
const [status, setStatus] = useState<ScraperStatus | null>(null)
const [loading, setLoading] = useState(true)
const [scraping, setScraping] = useState(false)
const [extracting, setExtracting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [results, setResults] = useState<ScrapeResult[]>([])
const [pdfResult, setPdfResult] = useState<PDFExtractionResult | null>(null)
const scraper = useComplianceScraper()
// Fetch sources
const fetchSources = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/scraper/sources`)
if (res.ok) {
const data = await res.json()
setSources(data.sources || [])
}
} catch (err) {
console.error('Failed to fetch sources:', err)
}
}, [])
// Fetch PDF documents
const fetchPdfDocuments = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/scraper/pdf-documents`)
if (res.ok) {
const data = await res.json()
setPdfDocuments(data.documents || [])
}
} catch (err) {
console.error('Failed to fetch PDF documents:', err)
}
}, [])
// Fetch status
const fetchStatus = useCallback(async () => {
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/scraper/status`)
if (res.ok) {
const data = await res.json()
setStatus(data)
}
} catch (err) {
console.error('Failed to fetch status:', err)
}
}, [])
// Initial load
useEffect(() => {
const loadData = async () => {
setLoading(true)
await Promise.all([fetchSources(), fetchStatus(), fetchPdfDocuments()])
setLoading(false)
}
loadData()
}, [fetchSources, fetchStatus, fetchPdfDocuments])
// Poll status while scraping
useEffect(() => {
if (scraping) {
const interval = setInterval(fetchStatus, 2000)
return () => clearInterval(interval)
}
}, [scraping, fetchStatus])
// Scrape all sources
const handleScrapeAll = async () => {
setScraping(true)
setError(null)
setSuccess(null)
setResults([])
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/scraper/scrape-all`, {
method: 'POST',
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.detail || 'Scraping fehlgeschlagen')
}
const data = await res.json()
setResults([
...data.results.success,
...data.results.failed,
...data.results.skipped,
])
setSuccess(`Scraping abgeschlossen: ${data.results.success.length} erfolgreich, ${data.results.skipped.length} uebersprungen, ${data.results.failed.length} fehlgeschlagen`)
// Refresh sources
await fetchSources()
} catch (err: any) {
setError(err.message)
} finally {
setScraping(false)
}
}
// Scrape single source
const handleScrapeSingle = async (code: string, force: boolean = false) => {
setScraping(true)
setError(null)
setSuccess(null)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/scraper/scrape/${code}?force=${force}`, {
method: 'POST',
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.detail || 'Scraping fehlgeschlagen')
}
const data = await res.json()
if (data.status === 'skipped') {
setSuccess(`${code}: Bereits vorhanden (${data.requirement_count} Anforderungen)`)
} else {
setSuccess(`${code}: ${data.requirements_extracted} Anforderungen extrahiert`)
}
// Refresh sources
await fetchSources()
} catch (err: any) {
setError(err.message)
} finally {
setScraping(false)
}
}
// Extract PDF
const handleExtractPdf = async (code: string, saveToDb: boolean = true, force: boolean = false) => {
setExtracting(true)
setError(null)
setSuccess(null)
setPdfResult(null)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/scraper/extract-pdf`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
document_code: code,
save_to_db: saveToDb,
force: force,
}),
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.detail || 'PDF-Extraktion fehlgeschlagen')
}
const data: PDFExtractionResult = await res.json()
setPdfResult(data)
if (data.success) {
setSuccess(`${code}: ${data.total_aspects} Pruefaspekte extrahiert, ${data.requirements_created} Requirements erstellt`)
}
// Refresh sources
await fetchSources()
} catch (err: any) {
setError(err.message)
} finally {
setExtracting(false)
}
}
// Clear messages
useEffect(() => {
if (success) {
const timer = setTimeout(() => setSuccess(null), 5000)
return () => clearTimeout(timer)
}
}, [success])
useEffect(() => {
if (error) {
const timer = setTimeout(() => setError(null), 10000)
return () => clearTimeout(timer)
}
}, [error])
// Stats cards
const StatsCard = ({ title, value, subtitle, icon }: { title: string; value: number | string; subtitle?: string; icon: string }) => (
<div className="bg-white rounded-lg shadow-sm p-5 border border-slate-200">
<div className="flex items-center">
<div className="flex-shrink-0">
<span className="text-2xl">{icon}</span>
</div>
<div className="flex-shrink-0"><span className="text-2xl">{icon}</span></div>
<div className="ml-4">
<p className="text-sm font-medium text-slate-500">{title}</p>
<p className="text-2xl font-semibold text-slate-900">{value}</p>
@@ -294,12 +31,8 @@ export default function ComplianceScraperPage() {
)
return (
<AdminLayout
title="Compliance Scraper"
description="Extrahiert Anforderungen aus EU-Regulierungen, BSI-Standards und Gesetzen"
>
{/* Loading */}
{loading && (
<AdminLayout title="Compliance Scraper" description="Extrahiert Anforderungen aus EU-Regulierungen, BSI-Standards und Gesetzen">
{scraper.loading && (
<div className="flex items-center justify-center py-12">
<svg className="w-8 h-8 animate-spin text-primary-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
@@ -309,481 +42,77 @@ export default function ComplianceScraperPage() {
</div>
)}
{!loading && (
{!scraper.loading && (
<>
{/* Messages */}
{error && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
{error}
</div>
{scraper.error && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">{scraper.error}</div>
)}
{success && (
<div className="mb-4 bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg">
{success}
</div>
{scraper.success && (
<div className="mb-4 bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded-lg">{scraper.success}</div>
)}
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<StatsCard
title="Bekannte Quellen"
value={sources.length}
icon="📚"
/>
<StatsCard
title="Mit Daten"
value={sources.filter(s => s.has_data).length}
subtitle={`${sources.length - sources.filter(s => s.has_data).length} noch zu scrapen`}
icon="✅"
/>
<StatsCard
title="Anforderungen gesamt"
value={sources.reduce((acc, s) => acc + s.requirement_count, 0)}
icon="📋"
/>
<StatsCard
title="Letzter Lauf"
value={status?.stats.last_run ? new Date(status.stats.last_run).toLocaleDateString('de-DE') : 'Nie'}
subtitle={status?.stats.errors ? `${status.stats.errors} Fehler` : undefined}
icon="🕐"
/>
<StatsCard title="Bekannte Quellen" value={scraper.sources.length} icon="📚" />
<StatsCard title="Mit Daten" value={scraper.sources.filter(s => s.has_data).length} subtitle={`${scraper.sources.length - scraper.sources.filter(s => s.has_data).length} noch zu scrapen`} icon="✅" />
<StatsCard title="Anforderungen gesamt" value={scraper.sources.reduce((acc, s) => acc + s.requirement_count, 0)} icon="📋" />
<StatsCard title="Letzter Lauf" value={scraper.status?.stats.last_run ? new Date(scraper.status.stats.last_run).toLocaleDateString('de-DE') : 'Nie'} subtitle={scraper.status?.stats.errors ? `${scraper.status.stats.errors} Fehler` : undefined} icon="🕐" />
</div>
{/* Scraper Status Bar */}
{(scraping || status?.status === 'running') && (
{(scraper.scraping || scraper.status?.status === 'running') && (
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-600 border-t-transparent mr-3" />
<div>
<p className="font-medium text-blue-800">Scraper laeuft</p>
{status?.current_source && (
<p className="text-sm text-blue-600">Aktuell: {status.current_source}</p>
)}
{scraper.status?.current_source && (<p className="text-sm text-blue-600">Aktuell: {scraper.status.current_source}</p>)}
</div>
</div>
</div>
)}
{/* Tabs */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 mb-6">
<div className="border-b border-slate-200">
<nav className="flex -mb-px">
{[
{ id: 'sources', name: 'Quellen', icon: '📚' },
{ id: 'pdf', name: 'PDF-Extraktion', icon: '📄' },
{ id: 'status', name: 'Status', icon: '📊' },
{ id: 'logs', name: 'Ergebnisse', icon: '📝' },
{ id: 'sources' as const, name: 'Quellen', icon: '📚' },
{ id: 'pdf' as const, name: 'PDF-Extraktion', icon: '📄' },
{ id: 'status' as const, name: 'Status', icon: '📊' },
{ id: 'logs' as const, name: 'Ergebnisse', icon: '📝' },
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary-600 text-primary-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
}`}
>
<span className="mr-2">{tab.icon}</span>
{tab.name}
<button key={tab.id} onClick={() => scraper.setActiveTab(tab.id)} className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${scraper.activeTab === tab.id ? 'border-primary-600 text-primary-600' : 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'}`}>
<span className="mr-2">{tab.icon}</span>{tab.name}
</button>
))}
</nav>
</div>
<div className="p-6">
{/* Sources Tab */}
{activeTab === 'sources' && (
<div>
{/* Header */}
<div className="flex justify-between items-center mb-6">
<div>
<h3 className="text-lg font-semibold text-slate-900">Regulierungsquellen</h3>
<p className="text-sm text-slate-500">EU-Lex, BSI-TR und deutsche Gesetze</p>
</div>
<button
onClick={handleScrapeAll}
disabled={scraping}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{scraping ? (
<>
<svg className="w-4 h-4 animate-spin" 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>
Laeuft...
</>
) : (
<>
<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>
Alle Quellen scrapen
</>
)}
</button>
</div>
{/* Sources by Type */}
<div className="space-y-6">
{/* EU Regulations */}
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3 flex items-center gap-2">
<span className="text-lg">🇪🇺</span> EU-Regulierungen (EUR-Lex)
</h4>
<div className="grid gap-3">
{sources.filter(s => s.source_type === 'eur_lex').map(source => (
<SourceCard key={source.code} source={source} onScrape={handleScrapeSingle} scraping={scraping} />
))}
</div>
</div>
{/* BSI Standards */}
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3 flex items-center gap-2">
<span className="text-lg">🔒</span> BSI Technical Guidelines
</h4>
<div className="grid gap-3">
{sources.filter(s => s.source_type === 'bsi_pdf').map(source => (
<SourceCard key={source.code} source={source} onScrape={handleScrapeSingle} scraping={scraping} />
))}
</div>
</div>
</div>
</div>
)}
{/* PDF Extraction Tab */}
{activeTab === 'pdf' && (
<div>
<div className="mb-6">
<h3 className="text-lg font-semibold text-slate-900">PDF-Extraktion (PyMuPDF)</h3>
<p className="text-sm text-slate-500">
Extrahiert ALLE Pruefaspekte aus BSI-TR-03161 PDFs mit Regex-Pattern-Matching
</p>
</div>
{/* PDF Documents */}
<div className="space-y-4">
{pdfDocuments.map(doc => (
<div key={doc.code} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-3xl">📄</span>
<div>
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-900">{doc.code}</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
doc.available ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{doc.available ? 'Verfuegbar' : 'Nicht gefunden'}
</span>
</div>
<div className="text-sm text-slate-600">{doc.name}</div>
<div className="text-xs text-slate-500">{doc.description}</div>
<div className="text-xs text-slate-400 mt-1">
Erwartete Pruefaspekte: {doc.expected_aspects}
</div>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => handleExtractPdf(doc.code, true, false)}
disabled={extracting || !doc.available}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{extracting ? (
<>
<svg className="w-4 h-4 animate-spin" 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>
Extrahiere...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Extrahieren
</>
)}
</button>
<button
onClick={() => handleExtractPdf(doc.code, true, true)}
disabled={extracting || !doc.available}
className="px-3 py-2 bg-orange-100 text-orange-700 rounded-lg hover:bg-orange-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Force: Loescht vorhandene und extrahiert neu"
>
Force
</button>
</div>
</div>
</div>
))}
</div>
{/* Last Extraction Result */}
{pdfResult && (
<div className="mt-6 bg-green-50 rounded-lg p-4 border border-green-200">
<h4 className="font-semibold text-green-800 mb-3">Letztes Extraktions-Ergebnis</h4>
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="text-center p-3 bg-white rounded-lg">
<div className="text-2xl font-bold text-green-700">{pdfResult.total_aspects}</div>
<div className="text-sm text-slate-500">Pruefaspekte gefunden</div>
</div>
<div className="text-center p-3 bg-white rounded-lg">
<div className="text-2xl font-bold text-blue-700">{pdfResult.requirements_created}</div>
<div className="text-sm text-slate-500">Requirements erstellt</div>
</div>
<div className="text-center p-3 bg-white rounded-lg">
<div className="text-2xl font-bold text-slate-700">{Object.keys(pdfResult.statistics.by_category || {}).length}</div>
<div className="text-sm text-slate-500">Kategorien</div>
</div>
</div>
{/* Category Breakdown */}
{pdfResult.statistics.by_category && Object.keys(pdfResult.statistics.by_category).length > 0 && (
<div>
<h5 className="text-sm font-medium text-slate-700 mb-2">Nach Kategorie:</h5>
<div className="flex flex-wrap gap-2">
{Object.entries(pdfResult.statistics.by_category).map(([cat, count]) => (
<span key={cat} className="px-2 py-1 bg-white rounded text-xs text-slate-600">
{cat}: <strong>{count}</strong>
</span>
))}
</div>
</div>
)}
</div>
)}
{/* Info Box */}
<div className="mt-6 bg-blue-50 rounded-lg p-4 border border-blue-200">
<h4 className="font-semibold text-blue-800 mb-2">Wie funktioniert die PDF-Extraktion?</h4>
<ul className="text-sm text-blue-700 space-y-1">
<li> <strong>PyMuPDF (fitz)</strong> liest den PDF-Text</li>
<li> <strong>Regex-Pattern</strong> finden Aspekte wie O.Auth_1, O.Sess_2, T.Network_1</li>
<li> <strong>Kontextanalyse</strong> extrahiert Titel, Kategorie und Anforderungsstufe (MUSS/SOLL/KANN)</li>
<li> <strong>Automatische Speicherung</strong> erstellt Requirements in der Datenbank</li>
</ul>
</div>
</div>
)}
{/* Status Tab */}
{activeTab === 'status' && status && (
<div className="space-y-6">
{/* Current Status */}
<div className="bg-slate-50 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-slate-900">Scraper-Status</h3>
<p className="text-sm text-slate-500">
Letzter Lauf: {status.stats.last_run ? new Date(status.stats.last_run).toLocaleString('de-DE') : 'Noch nie'}
</p>
</div>
<div className={`px-3 py-1.5 rounded-full text-sm font-medium ${
status.status === 'running' ? 'bg-blue-100 text-blue-700' :
status.status === 'error' ? 'bg-red-100 text-red-700' :
status.status === 'completed' ? 'bg-green-100 text-green-700' :
'bg-gray-100 text-gray-700'
}`}>
{status.status === 'running' ? '🔄 Laeuft' :
status.status === 'error' ? '❌ Fehler' :
status.status === 'completed' ? '✅ Abgeschlossen' :
'⏸️ Bereit'}
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="text-center p-4 bg-white rounded-lg">
<div className="text-2xl font-bold text-slate-900">{status.stats.sources_processed}</div>
<div className="text-sm text-slate-500">Quellen verarbeitet</div>
</div>
<div className="text-center p-4 bg-white rounded-lg">
<div className="text-2xl font-bold text-green-600">{status.stats.requirements_extracted}</div>
<div className="text-sm text-slate-500">Anforderungen extrahiert</div>
</div>
<div className="text-center p-4 bg-white rounded-lg">
<div className="text-2xl font-bold text-red-600">{status.stats.errors}</div>
<div className="text-sm text-slate-500">Fehler</div>
</div>
</div>
{status.last_error && (
<div className="mt-4 p-3 bg-red-50 rounded-lg text-sm text-red-700">
<strong>Letzter Fehler:</strong> {status.last_error}
</div>
)}
</div>
{/* Process Description */}
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h4 className="font-semibold text-slate-900 mb-4">Wie funktioniert der Scraper?</h4>
<div className="space-y-3 text-sm text-slate-600">
<div className="flex items-start gap-3">
<div className="w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold">1</div>
<div>
<strong>EUR-Lex Abruf</strong>: Holt HTML-Version der EU-Verordnung, extrahiert Artikel und Absaetze
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold">2</div>
<div>
<strong>BSI-TR Parsing</strong>: Extrahiert Pruefaspekte (O.Auth_1, O.Sess_1, etc.) aus den TR-Dokumenten
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center text-blue-600 font-bold">3</div>
<div>
<strong>Datenbank-Speicherung</strong>: Jede Anforderung wird als Requirement in der Compliance-DB gespeichert
</div>
</div>
<div className="flex items-start gap-3">
<div className="w-6 h-6 bg-green-100 rounded-full flex items-center justify-center text-green-600 font-bold"></div>
<div>
<strong>Audit-Workspace</strong>: Anforderungen koennen mit Implementierungsdetails angereichert werden
</div>
</div>
</div>
</div>
</div>
)}
{/* Results Tab */}
{activeTab === 'logs' && (
<div>
<h3 className="text-lg font-semibold text-slate-900 mb-4">Letzte Ergebnisse</h3>
{results.length === 0 ? (
<div className="text-center py-12 text-slate-500">
Keine Ergebnisse vorhanden. Starte einen Scrape-Vorgang.
</div>
) : (
<div className="space-y-2">
{results.map((result, idx) => (
<div
key={idx}
className={`p-3 rounded-lg flex items-center justify-between ${
result.error ? 'bg-red-50' :
result.reason ? 'bg-yellow-50' :
'bg-green-50'
}`}
>
<div className="flex items-center gap-3">
<span className="text-lg">
{result.error ? '❌' : result.reason ? '⏭️' : '✅'}
</span>
<span className="font-medium">{result.code}</span>
<span className="text-sm text-slate-500">
{result.error || result.reason || `${result.requirements_extracted} Anforderungen`}
</span>
</div>
</div>
))}
</div>
)}
</div>
)}
<ScraperTabs
activeTab={scraper.activeTab}
sources={scraper.sources}
pdfDocuments={scraper.pdfDocuments}
status={scraper.status}
scraping={scraper.scraping}
extracting={scraper.extracting}
results={scraper.results}
pdfResult={scraper.pdfResult}
handleScrapeAll={scraper.handleScrapeAll}
handleScrapeSingle={scraper.handleScrapeSingle}
handleExtractPdf={scraper.handleExtractPdf}
/>
</div>
</div>
</>
)}
{/* System Info Section */}
<div className="mt-8 border-t border-slate-200 pt-8">
<SystemInfoSection config={SYSTEM_INFO_CONFIGS.complianceScraper || {
title: 'Compliance Scraper',
description: 'Regulation & Requirements Extraction Service',
version: '1.0.0',
features: [
'EUR-Lex HTML Parsing',
'BSI-TR PDF Extraction',
'Automatic Requirement Mapping',
'Incremental Updates',
],
technicalDetails: {
'Backend': 'Python/FastAPI',
'HTTP Client': 'httpx async',
'HTML Parser': 'BeautifulSoup4',
'PDF Parser': 'PyMuPDF (optional)',
'Database': 'PostgreSQL',
},
features: ['EUR-Lex HTML Parsing', 'BSI-TR PDF Extraction', 'Automatic Requirement Mapping', 'Incremental Updates'],
technicalDetails: { 'Backend': 'Python/FastAPI', 'HTTP Client': 'httpx async', 'HTML Parser': 'BeautifulSoup4', 'PDF Parser': 'PyMuPDF (optional)', 'Database': 'PostgreSQL' },
}} />
</div>
</AdminLayout>
)
}
// Source Card Component
function SourceCard({
source,
onScrape,
scraping
}: {
source: Source
onScrape: (code: string, force: boolean) => void
scraping: boolean
}) {
const regType = regulationTypeBadge[source.regulation_type] || regulationTypeBadge.industry_standard
const srcType = sourceTypeBadge[source.source_type] || sourceTypeBadge.manual
return (
<div className="bg-white border border-slate-200 rounded-lg p-4 hover:shadow-sm transition-shadow">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-2xl">{regType.icon}</span>
<div>
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-900">{source.code}</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${regType.color}`}>
{regType.label}
</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${srcType.color}`}>
{srcType.label}
</span>
</div>
<div className="text-sm text-slate-500 truncate max-w-md" title={source.url}>
{source.url.length > 60 ? source.url.substring(0, 60) + '...' : source.url}
</div>
</div>
</div>
<div className="flex items-center gap-3">
{source.has_data ? (
<span className="px-3 py-1 bg-green-100 text-green-700 rounded-full text-sm font-medium">
{source.requirement_count} Anforderungen
</span>
) : (
<span className="px-3 py-1 bg-gray-100 text-gray-500 rounded-full text-sm">
Keine Daten
</span>
)}
<div className="flex gap-1">
<button
onClick={() => onScrape(source.code, false)}
disabled={scraping}
className="px-3 py-1.5 text-sm bg-slate-100 text-slate-700 rounded hover:bg-slate-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Scrapen (ueberspringt vorhandene)"
>
Scrapen
</button>
{source.has_data && (
<button
onClick={() => onScrape(source.code, true)}
disabled={scraping}
className="px-3 py-1.5 text-sm bg-orange-100 text-orange-700 rounded hover:bg-orange-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Force: Loescht vorhandene Daten und scraped neu"
>
Force
</button>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,184 @@
'use client'
import { WebsiteContent, FeatureContent } from '@/lib/content-types'
import HeroEditor from './HeroEditor'
interface ContentEditorTabsProps {
activeTab: string
content: WebsiteContent
isRTL: boolean
t: (key: string) => string
updateHero: (field: any, value: string) => void
updateFeature: (index: number, field: keyof FeatureContent, value: string) => void
updateFAQ: (index: number, field: 'question' | 'answer', value: string | string[]) => void
addFAQ: () => void
removeFAQ: (index: number) => void
updatePricing: (index: number, field: string, value: string | number | boolean) => void
updateTrust: (key: 'item1' | 'item2' | 'item3', field: 'value' | 'label', value: string) => void
updateTestimonial: (field: 'quote' | 'author' | 'role', value: string) => void
}
export default function ContentEditorTabs(props: ContentEditorTabsProps) {
const { activeTab, content, isRTL, t } = props
const dir = isRTL ? 'rtl' : 'ltr'
if (activeTab === 'hero') {
return <HeroEditor content={content} isRTL={isRTL} t={t} updateHero={props.updateHero} />
}
if (activeTab === 'features') {
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-slate-900">{t('admin_tab_features')}</h2>
{content.features.map((feature, index) => (
<div key={feature.id} className="border border-slate-200 rounded-lg p-4">
<div className="grid gap-4">
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Icon</label>
<input type="text" value={feature.icon} onChange={(e) => props.updateFeature(index, 'icon', e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-2xl text-center" />
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Titel</label>
<input type="text" value={feature.title} onChange={(e) => props.updateFeature(index, 'title', e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" dir={dir} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea value={feature.description} onChange={(e) => props.updateFeature(index, 'description', e.target.value)} rows={2} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" dir={dir} />
</div>
</div>
</div>
))}
</div>
)
}
if (activeTab === 'faq') {
return (
<div className="space-y-6">
<div className={`flex items-center justify-between ${isRTL ? 'flex-row-reverse' : ''}`}>
<h2 className="text-xl font-semibold text-slate-900">{t('admin_tab_faq')}</h2>
<button onClick={props.addFAQ} className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors">{t('admin_add_faq')}</button>
</div>
{content.faq.map((item, index) => (
<div key={index} className="border border-slate-200 rounded-lg p-4">
<div className={`flex items-start justify-between gap-4 ${isRTL ? 'flex-row-reverse' : ''}`}>
<div className="flex-1 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">{t('admin_question')} {index + 1}</label>
<input type="text" value={item.question} onChange={(e) => props.updateFAQ(index, 'question', e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" dir={dir} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">{t('admin_answer')}</label>
<textarea value={item.answer.join('\n')} onChange={(e) => props.updateFAQ(index, 'answer', e.target.value)} rows={4} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono text-sm" dir={dir} />
</div>
</div>
<button onClick={() => props.removeFAQ(index)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Frage entfernen">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
)
}
if (activeTab === 'pricing') {
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-slate-900">{t('admin_tab_pricing')}</h2>
{content.pricing.map((plan, index) => (
<div key={plan.id} className="border border-slate-200 rounded-lg p-4">
<div className="grid gap-4">
<div className="grid grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
<input type="text" value={plan.name} onChange={(e) => props.updatePricing(index, 'name', e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" dir={dir} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Preis (EUR)</label>
<input type="number" step="0.01" value={plan.price} onChange={(e) => props.updatePricing(index, 'price', e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Intervall</label>
<input type="text" value={plan.interval} onChange={(e) => props.updatePricing(index, 'interval', e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" dir={dir} />
</div>
<div className="flex items-end">
<label className="flex items-center gap-2">
<input type="checkbox" checked={plan.popular || false} onChange={(e) => props.updatePricing(index, 'popular', e.target.checked)} className="w-4 h-4 text-primary-600 rounded" />
<span className="text-sm text-slate-700">{t('pricing_popular')}</span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<input type="text" value={plan.description} onChange={(e) => props.updatePricing(index, 'description', e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" dir={dir} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">{t('pricing_tasks')}</label>
<input type="text" value={plan.features.tasks} onChange={(e) => props.updatePricing(index, 'features.tasks', e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" dir={dir} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Aufgaben-Beschreibung</label>
<input type="text" value={plan.features.taskDescription} onChange={(e) => props.updatePricing(index, 'features.taskDescription', e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" dir={dir} />
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Features (eine pro Zeile)</label>
<textarea value={plan.features.included.join('\n')} onChange={(e) => props.updatePricing(index, 'features.included', e.target.value)} rows={4} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono text-sm" dir={dir} />
</div>
</div>
</div>
))}
</div>
)
}
// 'other' tab
return (
<div className="space-y-8">
<div>
<h2 className="text-xl font-semibold text-slate-900 mb-4">Trust Indicators</h2>
<div className="grid grid-cols-3 gap-4">
{(['item1', 'item2', 'item3'] as const).map((key, index) => (
<div key={key} className="border border-slate-200 rounded-lg p-4">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Wert {index + 1}</label>
<input type="text" value={content.trust[key].value} onChange={(e) => props.updateTrust(key, 'value', e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" dir={dir} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Label {index + 1}</label>
<input type="text" value={content.trust[key].label} onChange={(e) => props.updateTrust(key, 'label', e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" dir={dir} />
</div>
</div>
</div>
))}
</div>
</div>
<div>
<h2 className="text-xl font-semibold text-slate-900 mb-4">Testimonial</h2>
<div className="border border-slate-200 rounded-lg p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Zitat</label>
<textarea value={content.testimonial.quote} onChange={(e) => props.updateTestimonial('quote', e.target.value)} rows={3} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" dir={dir} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Autor</label>
<input type="text" value={content.testimonial.author} onChange={(e) => props.updateTestimonial('author', e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" dir={dir} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rolle</label>
<input type="text" value={content.testimonial.role} onChange={(e) => props.updateTestimonial('role', e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" dir={dir} />
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,106 @@
'use client'
import { WebsiteContent, HeroContent } from '@/lib/content-types'
interface HeroEditorProps {
content: WebsiteContent
isRTL: boolean
t: (key: string) => string
updateHero: (field: keyof HeroContent, value: string) => void
}
export default function HeroEditor({ content, isRTL, t, updateHero }: HeroEditorProps) {
const dir = isRTL ? 'rtl' : 'ltr'
return (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-slate-900">{t('admin_tab_hero')} Section</h2>
<div className="grid gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Badge</label>
<input
type="text"
value={content.hero.badge}
onChange={(e) => updateHero('badge', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={dir}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Titel (vor Highlight)</label>
<input
type="text"
value={content.hero.title}
onChange={(e) => updateHero('title', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={dir}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Highlight 1</label>
<input
type="text"
value={content.hero.titleHighlight1}
onChange={(e) => updateHero('titleHighlight1', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={dir}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Highlight 2</label>
<input
type="text"
value={content.hero.titleHighlight2}
onChange={(e) => updateHero('titleHighlight2', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={dir}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Untertitel</label>
<textarea
value={content.hero.subtitle}
onChange={(e) => updateHero('subtitle', e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={dir}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">CTA Primaer</label>
<input
type="text"
value={content.hero.ctaPrimary}
onChange={(e) => updateHero('ctaPrimary', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={dir}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">CTA Sekundaer</label>
<input
type="text"
value={content.hero.ctaSecondary}
onChange={(e) => updateHero('ctaSecondary', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={dir}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">CTA Hinweis</label>
<input
type="text"
value={content.hero.ctaHint}
onChange={(e) => updateHero('ctaHint', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={dir}
/>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,74 @@
'use client'
import { RefObject } from 'react'
interface LivePreviewPanelProps {
activeTab: string
iframeRef: RefObject<HTMLIFrameElement | null>
}
export default function LivePreviewPanel({ activeTab, iframeRef }: LivePreviewPanelProps) {
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
{/* Preview Header */}
<div className="bg-slate-50 border-b border-slate-200 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-400"></div>
<div className="w-3 h-3 rounded-full bg-yellow-400"></div>
<div className="w-3 h-3 rounded-full bg-green-400"></div>
</div>
<span className="text-xs text-slate-500 ml-2">localhost:3000</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-600 bg-slate-200 px-2 py-1 rounded">
{activeTab === 'hero' && 'Hero Section'}
{activeTab === 'features' && 'Features'}
{activeTab === 'faq' && 'FAQ'}
{activeTab === 'pricing' && 'Pricing'}
{activeTab === 'other' && 'Trust & Testimonial'}
</span>
<button
onClick={() => iframeRef.current?.contentWindow?.location.reload()}
className="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded transition-colors"
title="Preview neu laden"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
{/* Preview Frame */}
<div className="relative h-[calc(100vh-340px)] bg-slate-100">
<iframe
ref={iframeRef}
src={`/?preview=true&section=${activeTab}#${activeTab}`}
className="w-full h-full border-0 scale-75 origin-top-left"
style={{
width: '133.33%',
height: '133.33%',
transform: 'scale(0.75)',
transformOrigin: 'top left',
}}
title="Website Preview"
sandbox="allow-same-origin allow-scripts"
/>
<div className="absolute bottom-4 left-4 right-4 bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 text-sm">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>
Du bearbeitest: <strong>
{activeTab === 'hero' && 'Hero Section (Startbereich)'}
{activeTab === 'features' && 'Features (Funktionen)'}
{activeTab === 'faq' && 'FAQ (Haeufige Fragen)'}
{activeTab === 'pricing' && 'Pricing (Preise)'}
{activeTab === 'other' && 'Trust & Testimonial'}
</strong>
</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
export const ADMIN_KEY = 'breakpilot-admin-2024'
export const SECTION_MAP: Record<string, { selector: string; scrollTo: string }> = {
hero: { selector: '#hero', scrollTo: 'hero' },
features: { selector: '#features', scrollTo: 'features' },
faq: { selector: '#faq', scrollTo: 'faq' },
pricing: { selector: '#pricing', scrollTo: 'pricing' },
other: { selector: '#trust', scrollTo: 'trust' },
}
export type ContentTab = 'hero' | 'features' | 'faq' | 'pricing' | 'other'

View File

@@ -0,0 +1,173 @@
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { WebsiteContent, HeroContent, FeatureContent } from '@/lib/content-types'
import { useLanguage } from '@/lib/LanguageContext'
import { ADMIN_KEY, SECTION_MAP, ContentTab } from './types'
export function useContentEditor() {
const { language, setLanguage, t, isRTL } = useLanguage()
const [content, setContent] = useState<WebsiteContent | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [activeTab, setActiveTab] = useState<ContentTab>('hero')
const [showPreview, setShowPreview] = useState(true)
const iframeRef = useRef<HTMLIFrameElement>(null)
const scrollToSection = useCallback((tab: string) => {
if (!iframeRef.current?.contentWindow) return
const section = SECTION_MAP[tab]
if (section) {
try {
iframeRef.current.contentWindow.postMessage(
{ type: 'scrollTo', section: section.scrollTo },
'*'
)
} catch {
// Same-origin policy - fallback
}
}
}, [])
useEffect(() => {
scrollToSection(activeTab)
}, [activeTab, scrollToSection])
useEffect(() => {
loadContent()
}, [])
async function loadContent() {
try {
const res = await fetch('/api/content')
if (res.ok) {
const data = await res.json()
setContent(data)
} else {
setMessage({ type: 'error', text: t('admin_error') })
}
} catch (error) {
setMessage({ type: 'error', text: t('admin_error') })
} finally {
setLoading(false)
}
}
async function saveChanges() {
if (!content) return
setSaving(true)
setMessage(null)
try {
const res = await fetch('/api/content', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-admin-key': ADMIN_KEY,
},
body: JSON.stringify(content),
})
if (res.ok) {
setMessage({ type: 'success', text: t('admin_saved') })
} else {
const error = await res.json()
setMessage({ type: 'error', text: error.error || t('admin_error') })
}
} catch (error) {
setMessage({ type: 'error', text: t('admin_error') })
} finally {
setSaving(false)
}
}
function updateHero(field: keyof HeroContent, value: string) {
if (!content) return
setContent({ ...content, hero: { ...content.hero, [field]: value } })
}
function updateFeature(index: number, field: keyof FeatureContent, value: string) {
if (!content) return
const newFeatures = [...content.features]
newFeatures[index] = { ...newFeatures[index], [field]: value }
setContent({ ...content, features: newFeatures })
}
function updateFAQ(index: number, field: 'question' | 'answer', value: string | string[]) {
if (!content) return
const newFAQ = [...content.faq]
if (field === 'answer' && typeof value === 'string') {
newFAQ[index] = { ...newFAQ[index], answer: value.split('\n') }
} else if (field === 'question' && typeof value === 'string') {
newFAQ[index] = { ...newFAQ[index], question: value }
}
setContent({ ...content, faq: newFAQ })
}
function addFAQ() {
if (!content) return
setContent({
...content,
faq: [...content.faq, { question: 'Neue Frage?', answer: ['Antwort hier...'] }],
})
}
function removeFAQ(index: number) {
if (!content) return
const newFAQ = content.faq.filter((_, i) => i !== index)
setContent({ ...content, faq: newFAQ })
}
function updatePricing(index: number, field: string, value: string | number | boolean) {
if (!content) return
const newPricing = [...content.pricing]
if (field === 'price') {
newPricing[index] = { ...newPricing[index], price: Number(value) }
} else if (field === 'popular') {
newPricing[index] = { ...newPricing[index], popular: Boolean(value) }
} else if (field.startsWith('features.')) {
const subField = field.replace('features.', '')
if (subField === 'included' && typeof value === 'string') {
newPricing[index] = {
...newPricing[index],
features: { ...newPricing[index].features, included: value.split('\n') },
}
} else {
newPricing[index] = {
...newPricing[index],
features: { ...newPricing[index].features, [subField]: value },
}
}
} else {
newPricing[index] = { ...newPricing[index], [field]: value }
}
setContent({ ...content, pricing: newPricing })
}
function updateTrust(key: 'item1' | 'item2' | 'item3', field: 'value' | 'label', value: string) {
if (!content) return
setContent({
...content,
trust: { ...content.trust, [key]: { ...content.trust[key], [field]: value } },
})
}
function updateTestimonial(field: 'quote' | 'author' | 'role', value: string) {
if (!content) return
setContent({
...content,
testimonial: { ...content.testimonial, [field]: value },
})
}
return {
language, setLanguage, t, isRTL,
content, loading, saving, message,
activeTab, setActiveTab,
showPreview, setShowPreview,
iframeRef,
saveChanges,
updateHero, updateFeature, updateFAQ,
addFAQ, removeFAQ, updatePricing,
updateTrust, updateTestimonial,
}
}

View File

@@ -4,233 +4,53 @@
* Admin Panel fuer Website-Content
*
* Erlaubt das Bearbeiten aller Website-Texte:
* - Hero Section
* - Features
* - FAQ
* - Pricing
* - Trust Indicators
* - Testimonial
* - Hero Section, Features, FAQ, Pricing, Trust Indicators, Testimonial
*
* NEU: Live-Preview der Website zeigt Kontext beim Bearbeiten
*/
import { useState, useEffect, useRef, useCallback } from 'react'
import { WebsiteContent, HeroContent, FeatureContent, FAQItem, PricingPlan } from '@/lib/content-types'
import { useLanguage } from '@/lib/LanguageContext'
import LanguageSelector from '@/components/LanguageSelector'
import AdminLayout from '@/components/admin/AdminLayout'
// Admin Key (in Produktion via Login)
const ADMIN_KEY = 'breakpilot-admin-2024'
// Mapping von Tabs zu Website-Sektionen (CSS Selektoren und Scroll-Positionen)
const SECTION_MAP: Record<string, { selector: string; scrollTo: string }> = {
hero: { selector: '#hero', scrollTo: 'hero' },
features: { selector: '#features', scrollTo: 'features' },
faq: { selector: '#faq', scrollTo: 'faq' },
pricing: { selector: '#pricing', scrollTo: 'pricing' },
other: { selector: '#trust', scrollTo: 'trust' },
}
import { useContentEditor } from './_components/useContentEditor'
import ContentEditorTabs from './_components/ContentEditorTabs'
import LivePreviewPanel from './_components/LivePreviewPanel'
export default function AdminPage() {
const { language, setLanguage, t, isRTL } = useLanguage()
const [content, setContent] = useState<WebsiteContent | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [activeTab, setActiveTab] = useState<'hero' | 'features' | 'faq' | 'pricing' | 'other'>('hero')
const [showPreview, setShowPreview] = useState(true)
const iframeRef = useRef<HTMLIFrameElement>(null)
const editor = useContentEditor()
// Scrollt die Preview zur entsprechenden Sektion
const scrollToSection = useCallback((tab: string) => {
if (!iframeRef.current?.contentWindow) return
const section = SECTION_MAP[tab]
if (section) {
try {
iframeRef.current.contentWindow.postMessage(
{ type: 'scrollTo', section: section.scrollTo },
'*'
)
} catch {
// Same-origin policy - fallback
}
}
}, [])
// Bei Tab-Wechsel zur Sektion scrollen
useEffect(() => {
scrollToSection(activeTab)
}, [activeTab, scrollToSection])
// Content laden
useEffect(() => {
loadContent()
}, [])
async function loadContent() {
try {
const res = await fetch('/api/content')
if (res.ok) {
const data = await res.json()
setContent(data)
} else {
setMessage({ type: 'error', text: t('admin_error') })
}
} catch (error) {
setMessage({ type: 'error', text: t('admin_error') })
} finally {
setLoading(false)
}
}
async function saveChanges() {
if (!content) return
setSaving(true)
setMessage(null)
try {
const res = await fetch('/api/content', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-admin-key': ADMIN_KEY,
},
body: JSON.stringify(content),
})
if (res.ok) {
setMessage({ type: 'success', text: t('admin_saved') })
} else {
const error = await res.json()
setMessage({ type: 'error', text: error.error || t('admin_error') })
}
} catch (error) {
setMessage({ type: 'error', text: t('admin_error') })
} finally {
setSaving(false)
}
}
// Hero Section updaten
function updateHero(field: keyof HeroContent, value: string) {
if (!content) return
setContent({
...content,
hero: { ...content.hero, [field]: value },
})
}
// Feature updaten
function updateFeature(index: number, field: keyof FeatureContent, value: string) {
if (!content) return
const newFeatures = [...content.features]
newFeatures[index] = { ...newFeatures[index], [field]: value }
setContent({ ...content, features: newFeatures })
}
// FAQ updaten
function updateFAQ(index: number, field: 'question' | 'answer', value: string | string[]) {
if (!content) return
const newFAQ = [...content.faq]
if (field === 'answer' && typeof value === 'string') {
// Split by newlines for array
newFAQ[index] = { ...newFAQ[index], answer: value.split('\n') }
} else if (field === 'question' && typeof value === 'string') {
newFAQ[index] = { ...newFAQ[index], question: value }
}
setContent({ ...content, faq: newFAQ })
}
// FAQ hinzufuegen
function addFAQ() {
if (!content) return
setContent({
...content,
faq: [...content.faq, { question: 'Neue Frage?', answer: ['Antwort hier...'] }],
})
}
// FAQ entfernen
function removeFAQ(index: number) {
if (!content) return
const newFAQ = content.faq.filter((_, i) => i !== index)
setContent({ ...content, faq: newFAQ })
}
// Pricing updaten
function updatePricing(index: number, field: string, value: string | number | boolean) {
if (!content) return
const newPricing = [...content.pricing]
if (field === 'price') {
newPricing[index] = { ...newPricing[index], price: Number(value) }
} else if (field === 'popular') {
newPricing[index] = { ...newPricing[index], popular: Boolean(value) }
} else if (field.startsWith('features.')) {
const subField = field.replace('features.', '')
if (subField === 'included' && typeof value === 'string') {
newPricing[index] = {
...newPricing[index],
features: {
...newPricing[index].features,
included: value.split('\n'),
},
}
} else {
newPricing[index] = {
...newPricing[index],
features: {
...newPricing[index].features,
[subField]: value,
},
}
}
} else {
newPricing[index] = { ...newPricing[index], [field]: value }
}
setContent({ ...content, pricing: newPricing })
}
if (loading) {
if (editor.loading) {
return (
<AdminLayout title="Übersetzungen" description="Website Content & Sprachen">
<AdminLayout title="Uebersetzungen" description="Website Content & Sprachen">
<div className="flex items-center justify-center py-12">
<div className="text-xl text-slate-600">{t('admin_loading')}</div>
<div className="text-xl text-slate-600">{editor.t('admin_loading')}</div>
</div>
</AdminLayout>
)
}
if (!content) {
if (!editor.content) {
return (
<AdminLayout title="Übersetzungen" description="Website Content & Sprachen">
<AdminLayout title="Uebersetzungen" description="Website Content & Sprachen">
<div className="flex items-center justify-center py-12">
<div className="text-xl text-red-600">{t('admin_error')}</div>
<div className="text-xl text-red-600">{editor.t('admin_error')}</div>
</div>
</AdminLayout>
)
}
return (
<AdminLayout title="Übersetzungen" description="Website Content & Sprachen">
<div className={isRTL ? 'rtl' : ''} dir={isRTL ? 'rtl' : 'ltr'}>
<AdminLayout title="Uebersetzungen" description="Website Content & Sprachen">
<div className={editor.isRTL ? 'rtl' : ''} dir={editor.isRTL ? 'rtl' : 'ltr'}>
{/* Toolbar */}
<div className={`bg-white rounded-xl border border-slate-200 p-4 mb-6 flex items-center justify-between ${isRTL ? 'flex-row-reverse' : ''}`}>
<div className={`bg-white rounded-xl border border-slate-200 p-4 mb-6 flex items-center justify-between ${editor.isRTL ? 'flex-row-reverse' : ''}`}>
<div className="flex items-center gap-4">
<LanguageSelector
currentLanguage={language}
onLanguageChange={setLanguage}
/>
{/* Preview Toggle */}
<LanguageSelector currentLanguage={editor.language} onLanguageChange={editor.setLanguage} />
<button
onClick={() => setShowPreview(!showPreview)}
onClick={() => editor.setShowPreview(!editor.showPreview)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
showPreview
? 'bg-blue-100 text-blue-700'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
editor.showPreview ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
title={showPreview ? 'Preview ausblenden' : 'Preview einblenden'}
title={editor.showPreview ? 'Preview ausblenden' : 'Preview einblenden'}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
@@ -239,565 +59,65 @@ export default function AdminPage() {
Live-Preview
</button>
</div>
<div className={`flex items-center gap-4 ${isRTL ? 'flex-row-reverse' : ''}`}>
{message && (
<span
className={`px-3 py-1 rounded text-sm ${
message.type === 'success'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{message.text}
<div className={`flex items-center gap-4 ${editor.isRTL ? 'flex-row-reverse' : ''}`}>
{editor.message && (
<span className={`px-3 py-1 rounded text-sm ${
editor.message.type === 'success' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}`}>
{editor.message.text}
</span>
)}
<button
onClick={saveChanges}
disabled={saving}
onClick={editor.saveChanges}
disabled={editor.saving}
className="bg-primary-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
{saving ? t('admin_saving') : t('admin_save')}
{editor.saving ? editor.t('admin_saving') : editor.t('admin_save')}
</button>
</div>
</div>
{/* Tabs */}
<div className="mb-6">
<div className={`flex gap-1 bg-slate-100 p-1 rounded-lg w-fit ${isRTL ? 'flex-row-reverse' : ''}`}>
<div className={`flex gap-1 bg-slate-100 p-1 rounded-lg w-fit ${editor.isRTL ? 'flex-row-reverse' : ''}`}>
{(['hero', 'features', 'faq', 'pricing', 'other'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
onClick={() => editor.setActiveTab(tab)}
className={`px-4 py-2 text-sm font-medium rounded-md transition-colors ${
activeTab === tab
? 'bg-white text-slate-900 shadow-sm'
: 'text-slate-600 hover:text-slate-900'
editor.activeTab === tab ? 'bg-white text-slate-900 shadow-sm' : 'text-slate-600 hover:text-slate-900'
}`}
>
{tab === 'hero' && t('admin_tab_hero')}
{tab === 'features' && t('admin_tab_features')}
{tab === 'faq' && t('admin_tab_faq')}
{tab === 'pricing' && t('admin_tab_pricing')}
{tab === 'other' && t('admin_tab_other')}
{tab === 'hero' && editor.t('admin_tab_hero')}
{tab === 'features' && editor.t('admin_tab_features')}
{tab === 'faq' && editor.t('admin_tab_faq')}
{tab === 'pricing' && editor.t('admin_tab_pricing')}
{tab === 'other' && editor.t('admin_tab_other')}
</button>
))}
</div>
</div>
{/* Split Layout: Editor + Preview */}
<div className={`grid gap-6 ${showPreview ? 'grid-cols-2' : 'grid-cols-1'}`}>
{/* Editor Panel */}
<div className={`grid gap-6 ${editor.showPreview ? 'grid-cols-2' : 'grid-cols-1'}`}>
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6 max-h-[calc(100vh-280px)] overflow-y-auto">
{/* Hero Tab */}
{activeTab === 'hero' && (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-slate-900">{t('admin_tab_hero')} Section</h2>
<div className="grid gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Badge</label>
<input
type="text"
value={content.hero.badge}
onChange={(e) => updateHero('badge', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Titel (vor Highlight)
</label>
<input
type="text"
value={content.hero.title}
onChange={(e) => updateHero('title', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Highlight 1
</label>
<input
type="text"
value={content.hero.titleHighlight1}
onChange={(e) => updateHero('titleHighlight1', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Highlight 2
</label>
<input
type="text"
value={content.hero.titleHighlight2}
onChange={(e) => updateHero('titleHighlight2', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Untertitel</label>
<textarea
value={content.hero.subtitle}
onChange={(e) => updateHero('subtitle', e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
CTA Primaer
</label>
<input
type="text"
value={content.hero.ctaPrimary}
onChange={(e) => updateHero('ctaPrimary', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
CTA Sekundaer
</label>
<input
type="text"
value={content.hero.ctaSecondary}
onChange={(e) => updateHero('ctaSecondary', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">CTA Hinweis</label>
<input
type="text"
value={content.hero.ctaHint}
onChange={(e) => updateHero('ctaHint', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
</div>
</div>
</div>
)}
{/* Features Tab */}
{activeTab === 'features' && (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-slate-900">{t('admin_tab_features')}</h2>
{content.features.map((feature, index) => (
<div key={feature.id} className="border border-slate-200 rounded-lg p-4">
<div className="grid gap-4">
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Icon</label>
<input
type="text"
value={feature.icon}
onChange={(e) => updateFeature(index, 'icon', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-2xl text-center"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Titel</label>
<input
type="text"
value={feature.title}
onChange={(e) => updateFeature(index, 'title', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Beschreibung
</label>
<textarea
value={feature.description}
onChange={(e) => updateFeature(index, 'description', e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
</div>
</div>
))}
</div>
)}
{/* FAQ Tab */}
{activeTab === 'faq' && (
<div className="space-y-6">
<div className={`flex items-center justify-between ${isRTL ? 'flex-row-reverse' : ''}`}>
<h2 className="text-xl font-semibold text-slate-900">{t('admin_tab_faq')}</h2>
<button
onClick={addFAQ}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors"
>
{t('admin_add_faq')}
</button>
</div>
{content.faq.map((item, index) => (
<div key={index} className="border border-slate-200 rounded-lg p-4">
<div className={`flex items-start justify-between gap-4 ${isRTL ? 'flex-row-reverse' : ''}`}>
<div className="flex-1 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
{t('admin_question')} {index + 1}
</label>
<input
type="text"
value={item.question}
onChange={(e) => updateFAQ(index, 'question', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
{t('admin_answer')}
</label>
<textarea
value={item.answer.join('\n')}
onChange={(e) => updateFAQ(index, 'answer', e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono text-sm"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
</div>
<button
onClick={() => removeFAQ(index)}
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Frage entfernen"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
)}
{/* Pricing Tab */}
{activeTab === 'pricing' && (
<div className="space-y-6">
<h2 className="text-xl font-semibold text-slate-900">{t('admin_tab_pricing')}</h2>
{content.pricing.map((plan, index) => (
<div key={plan.id} className="border border-slate-200 rounded-lg p-4">
<div className="grid gap-4">
<div className="grid grid-cols-4 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
<input
type="text"
value={plan.name}
onChange={(e) => updatePricing(index, 'name', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Preis (EUR)
</label>
<input
type="number"
step="0.01"
value={plan.price}
onChange={(e) => updatePricing(index, 'price', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Intervall
</label>
<input
type="text"
value={plan.interval}
onChange={(e) => updatePricing(index, 'interval', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
<div className="flex items-end">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={plan.popular || false}
onChange={(e) => updatePricing(index, 'popular', e.target.checked)}
className="w-4 h-4 text-primary-600 rounded"
/>
<span className="text-sm text-slate-700">{t('pricing_popular')}</span>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Beschreibung
</label>
<input
type="text"
value={plan.description}
onChange={(e) => updatePricing(index, 'description', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
{t('pricing_tasks')}
</label>
<input
type="text"
value={plan.features.tasks}
onChange={(e) => updatePricing(index, 'features.tasks', e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Aufgaben-Beschreibung
</label>
<input
type="text"
value={plan.features.taskDescription}
onChange={(e) =>
updatePricing(index, 'features.taskDescription', e.target.value)
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Features (eine pro Zeile)
</label>
<textarea
value={plan.features.included.join('\n')}
onChange={(e) => updatePricing(index, 'features.included', e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 font-mono text-sm"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
</div>
</div>
))}
</div>
)}
{/* Other Tab */}
{activeTab === 'other' && (
<div className="space-y-8">
{/* Trust Indicators */}
<div>
<h2 className="text-xl font-semibold text-slate-900 mb-4">Trust Indicators</h2>
<div className="grid grid-cols-3 gap-4">
{(['item1', 'item2', 'item3'] as const).map((key, index) => (
<div key={key} className="border border-slate-200 rounded-lg p-4">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Wert {index + 1}
</label>
<input
type="text"
value={content.trust[key].value}
onChange={(e) =>
setContent({
...content,
trust: {
...content.trust,
[key]: { ...content.trust[key], value: e.target.value },
},
})
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Label {index + 1}
</label>
<input
type="text"
value={content.trust[key].label}
onChange={(e) =>
setContent({
...content,
trust: {
...content.trust,
[key]: { ...content.trust[key], label: e.target.value },
},
})
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
</div>
</div>
))}
</div>
</div>
{/* Testimonial */}
<div>
<h2 className="text-xl font-semibold text-slate-900 mb-4">Testimonial</h2>
<div className="border border-slate-200 rounded-lg p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Zitat</label>
<textarea
value={content.testimonial.quote}
onChange={(e) =>
setContent({
...content,
testimonial: { ...content.testimonial, quote: e.target.value },
})
}
rows={3}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Autor</label>
<input
type="text"
value={content.testimonial.author}
onChange={(e) =>
setContent({
...content,
testimonial: { ...content.testimonial, author: e.target.value },
})
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rolle</label>
<input
type="text"
value={content.testimonial.role}
onChange={(e) =>
setContent({
...content,
testimonial: { ...content.testimonial, role: e.target.value },
})
}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
dir={isRTL ? 'rtl' : 'ltr'}
/>
</div>
</div>
</div>
</div>
</div>
)}
<ContentEditorTabs
activeTab={editor.activeTab}
content={editor.content}
isRTL={editor.isRTL}
t={editor.t}
updateHero={editor.updateHero}
updateFeature={editor.updateFeature}
updateFAQ={editor.updateFAQ}
addFAQ={editor.addFAQ}
removeFAQ={editor.removeFAQ}
updatePricing={editor.updatePricing}
updateTrust={editor.updateTrust}
updateTestimonial={editor.updateTestimonial}
/>
</div>
{/* Live Preview Panel */}
{showPreview && (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
{/* Preview Header */}
<div className="bg-slate-50 border-b border-slate-200 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-400"></div>
<div className="w-3 h-3 rounded-full bg-yellow-400"></div>
<div className="w-3 h-3 rounded-full bg-green-400"></div>
</div>
<span className="text-xs text-slate-500 ml-2">localhost:3000</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-600 bg-slate-200 px-2 py-1 rounded">
{activeTab === 'hero' && 'Hero Section'}
{activeTab === 'features' && 'Features'}
{activeTab === 'faq' && 'FAQ'}
{activeTab === 'pricing' && 'Pricing'}
{activeTab === 'other' && 'Trust & Testimonial'}
</span>
<button
onClick={() => iframeRef.current?.contentWindow?.location.reload()}
className="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded transition-colors"
title="Preview neu laden"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
{/* Preview Frame */}
<div className="relative h-[calc(100vh-340px)] bg-slate-100">
<iframe
ref={iframeRef}
src={`/?preview=true&section=${activeTab}#${activeTab}`}
className="w-full h-full border-0 scale-75 origin-top-left"
style={{
width: '133.33%',
height: '133.33%',
transform: 'scale(0.75)',
transformOrigin: 'top left',
}}
title="Website Preview"
sandbox="allow-same-origin allow-scripts"
/>
{/* Sektion-Indikator */}
<div className="absolute bottom-4 left-4 right-4 bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg flex items-center gap-2 text-sm">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>
Du bearbeitest: <strong>
{activeTab === 'hero' && 'Hero Section (Startbereich)'}
{activeTab === 'features' && 'Features (Funktionen)'}
{activeTab === 'faq' && 'FAQ (Häufige Fragen)'}
{activeTab === 'pricing' && 'Pricing (Preise)'}
{activeTab === 'other' && 'Trust & Testimonial'}
</strong>
</span>
</div>
</div>
</div>
{editor.showPreview && (
<LivePreviewPanel activeTab={editor.activeTab} iframeRef={editor.iframeRef} />
)}
</div>
</div>

View File

@@ -0,0 +1,98 @@
import { ScreenDefinition, ConnectionDef, FlowType } from './types'
/**
* Find all connected nodes recursively from a start node.
*/
export function findConnectedNodes(
startNodeId: string,
connections: ConnectionDef[],
direction: 'children' | 'parents' | 'both' = 'children'
): Set<string> {
const connected = new Set<string>()
connected.add(startNodeId)
const queue = [startNodeId]
while (queue.length > 0) {
const current = queue.shift()!
connections.forEach(conn => {
if ((direction === 'children' || direction === 'both') && conn.source === current) {
if (!connected.has(conn.target)) {
connected.add(conn.target)
queue.push(conn.target)
}
}
if ((direction === 'parents' || direction === 'both') && conn.target === current) {
if (!connected.has(conn.source)) {
connected.add(conn.source)
queue.push(conn.source)
}
}
})
}
return connected
}
/**
* Construct an embeddable URL from a base URL and screen URL.
*/
export function constructEmbedUrl(baseUrl: string, url: string | undefined): string | null {
if (!url) return null
const hashIndex = url.indexOf('#')
if (hashIndex !== -1) {
const basePart = url.substring(0, hashIndex)
const hashPart = url.substring(hashIndex)
const separator = basePart.includes('?') ? '&' : '?'
return `${baseUrl}${basePart}${separator}embed=true${hashPart}`
} else {
const separator = url.includes('?') ? '&' : '?'
return `${baseUrl}${url}${separator}embed=true`
}
}
/**
* Calculate node position based on category and index within that category.
*/
export function getNodePosition(
id: string,
category: string,
screens: ScreenDefinition[],
flowType: FlowType
) {
const studioPositions: Record<string, { x: number; y: number }> = {
navigation: { x: 400, y: 50 },
content: { x: 50, y: 250 },
communication: { x: 750, y: 250 },
school: { x: 50, y: 500 },
admin: { x: 750, y: 500 },
ai: { x: 400, y: 380 },
}
const adminPositions: Record<string, { x: number; y: number }> = {
overview: { x: 400, y: 30 },
infrastructure: { x: 50, y: 150 },
compliance: { x: 700, y: 150 },
ai: { x: 50, y: 350 },
communication: { x: 400, y: 350 },
security: { x: 700, y: 350 },
content: { x: 50, y: 550 },
game: { x: 400, y: 550 },
misc: { x: 700, y: 550 },
}
const positions = flowType === 'studio' ? studioPositions : adminPositions
const base = positions[category] || { x: 400, y: 300 }
const categoryScreens = screens.filter(s => s.category === category)
const categoryIndex = categoryScreens.findIndex(s => s.id === id)
const cols = Math.ceil(Math.sqrt(categoryScreens.length + 1))
const row = Math.floor(categoryIndex / cols)
const col = categoryIndex % cols
return {
x: base.x + col * 160,
y: base.y + row * 90,
}
}

View File

@@ -0,0 +1,173 @@
import { ScreenDefinition, ConnectionDef } from './types'
// ============================================
// STUDIO SCREENS (Port 8000)
// ============================================
export const STUDIO_SCREENS: ScreenDefinition[] = [
{ id: 'lehrer-dashboard', name: 'Mein Dashboard', description: 'Hauptuebersicht mit Widgets', category: 'navigation', icon: '🏠', url: '/app#lehrer-dashboard' },
{ id: 'lehrer-onboarding', name: 'Erste Schritte', description: 'Onboarding & Schnellstart', category: 'navigation', icon: '🚀', url: '/app#lehrer-onboarding' },
{ id: 'hilfe', name: 'Dokumentation', description: 'Hilfe & Anleitungen', category: 'navigation', icon: '📚', url: '/app#hilfe' },
{ id: 'worksheets', name: 'Arbeitsblaetter Studio', description: 'Lernmaterialien erstellen', category: 'content', icon: '📝', url: '/app#worksheets' },
{ id: 'content-creator', name: 'Content Creator', description: 'Inhalte erstellen', category: 'content', icon: '✨', url: '/app#content-creator' },
{ id: 'content-feed', name: 'Content Feed', description: 'Inhalte durchsuchen', category: 'content', icon: '📰', url: '/app#content-feed' },
{ id: 'unit-creator', name: 'Unit Creator', description: 'Lerneinheiten erstellen', category: 'content', icon: '📦', url: '/app#unit-creator' },
{ id: 'letters', name: 'Briefe & Vorlagen', description: 'Brief-Generator', category: 'content', icon: '✉️', url: '/app#letters' },
{ id: 'correction', name: 'Korrektur', description: 'Arbeiten korrigieren', category: 'content', icon: '✏️', url: '/app#correction' },
{ id: 'klausur-korrektur', name: 'Abiturklausuren', description: 'KI-gestuetzte Klausurkorrektur', category: 'content', icon: '📋', url: '/app#klausur-korrektur' },
{ id: 'jitsi', name: 'Videokonferenz', description: 'Jitsi Meet Integration', category: 'communication', icon: '🎥', url: '/app#jitsi' },
{ id: 'messenger', name: 'Messenger', description: 'Matrix E2EE Chat', category: 'communication', icon: '💬', url: '/app#messenger' },
{ id: 'mail', name: 'Unified Inbox', description: 'E-Mail Verwaltung', category: 'communication', icon: '📧', url: '/app#mail' },
{ id: 'school-classes', name: 'Klassen', description: 'Klassenverwaltung', category: 'school', icon: '👥', url: '/app#school-classes' },
{ id: 'school-exams', name: 'Pruefungen', description: 'Pruefungsverwaltung', category: 'school', icon: '📝', url: '/app#school-exams' },
{ id: 'school-grades', name: 'Noten', description: 'Notenverwaltung', category: 'school', icon: '📊', url: '/app#school-grades' },
{ id: 'school-gradebook', name: 'Notenbuch', description: 'Digitales Notenbuch', category: 'school', icon: '📖', url: '/app#school-gradebook' },
{ id: 'school-certificates', name: 'Zeugnisse', description: 'Zeugniserstellung', category: 'school', icon: '🎓', url: '/app#school-certificates' },
{ id: 'companion', name: 'Begleiter & Stunde', description: 'KI-Unterrichtsassistent', category: 'ai', icon: '🤖', url: '/app#companion' },
{ id: 'alerts', name: 'Alerts', description: 'News & Benachrichtigungen', category: 'ai', icon: '🔔', url: '/app#alerts' },
{ id: 'admin', name: 'Einstellungen', description: 'Systemeinstellungen', category: 'admin', icon: '⚙️', url: '/app#admin' },
{ id: 'rbac-admin', name: 'Rollen & Rechte', description: 'Berechtigungsverwaltung', category: 'admin', icon: '🔐', url: '/app#rbac-admin' },
{ id: 'abitur-docs-admin', name: 'Abitur Dokumente', description: 'Erwartungshorizonte', category: 'admin', icon: '📄', url: '/app#abitur-docs-admin' },
{ id: 'system-info', name: 'System Info', description: 'Systeminformationen', category: 'admin', icon: '💻', url: '/app#system-info' },
{ id: 'workflow', name: 'Workflow', description: 'Automatisierungen', category: 'admin', icon: '⚡', url: '/app#workflow' },
]
export const STUDIO_CONNECTIONS: ConnectionDef[] = [
{ source: 'lehrer-onboarding', target: 'worksheets', label: 'Arbeitsblaetter' },
{ source: 'lehrer-onboarding', target: 'klausur-korrektur', label: 'Abiturklausuren' },
{ source: 'lehrer-onboarding', target: 'correction', label: 'Korrektur' },
{ source: 'lehrer-onboarding', target: 'letters', label: 'Briefe' },
{ source: 'lehrer-onboarding', target: 'school-classes', label: 'Klassen' },
{ source: 'lehrer-onboarding', target: 'jitsi', label: 'Meet' },
{ source: 'lehrer-onboarding', target: 'hilfe', label: 'Doku' },
{ source: 'lehrer-onboarding', target: 'admin', label: 'Settings' },
{ source: 'lehrer-dashboard', target: 'worksheets' },
{ source: 'lehrer-dashboard', target: 'correction' },
{ source: 'lehrer-dashboard', target: 'jitsi' },
{ source: 'lehrer-dashboard', target: 'letters' },
{ source: 'lehrer-dashboard', target: 'messenger' },
{ source: 'lehrer-dashboard', target: 'klausur-korrektur' },
{ source: 'lehrer-dashboard', target: 'companion' },
{ source: 'lehrer-dashboard', target: 'alerts' },
{ source: 'lehrer-dashboard', target: 'mail' },
{ source: 'lehrer-dashboard', target: 'school-classes' },
{ source: 'lehrer-dashboard', target: 'lehrer-onboarding', label: 'Sidebar' },
{ source: 'school-classes', target: 'school-exams' },
{ source: 'school-classes', target: 'school-grades' },
{ source: 'school-grades', target: 'school-gradebook' },
{ source: 'school-gradebook', target: 'school-certificates' },
{ source: 'worksheets', target: 'content-creator' },
{ source: 'worksheets', target: 'unit-creator' },
{ source: 'content-creator', target: 'content-feed' },
{ source: 'klausur-korrektur', target: 'abitur-docs-admin' },
{ source: 'admin', target: 'rbac-admin' },
{ source: 'admin', target: 'system-info' },
{ source: 'admin', target: 'workflow' },
]
// ============================================
// ADMIN SCREENS (Port 3000)
// ============================================
export const ADMIN_SCREENS: ScreenDefinition[] = [
{ id: 'admin-dashboard', name: 'Dashboard', description: 'Uebersicht & Statistiken', category: 'overview', icon: '🏠', url: '/admin' },
{ id: 'admin-onboarding', name: 'Onboarding', description: 'Lern-Wizards fuer alle Module', category: 'overview', icon: '📖', url: '/admin/onboarding' },
{ id: 'admin-gpu', name: 'GPU Infrastruktur', description: 'vast.ai GPU Management', category: 'infrastructure', icon: '🖥️', url: '/admin/gpu' },
{ id: 'admin-middleware', name: 'Middleware', description: 'Middleware Stack & Test', category: 'infrastructure', icon: '🔧', url: '/admin/middleware' },
{ id: 'admin-mac-mini', name: 'Mac Mini', description: 'Headless Mac Mini Control', category: 'infrastructure', icon: '🍎', url: '/admin/mac-mini' },
{ id: 'admin-consent', name: 'Consent Verwaltung', description: 'Rechtliche Dokumente', category: 'compliance', icon: '📄', url: '/admin/consent' },
{ id: 'admin-dsr', name: 'Datenschutzanfragen', description: 'DSGVO Art. 15-21', category: 'compliance', icon: '🔒', url: '/admin/dsr' },
{ id: 'admin-dsms', name: 'DSMS', description: 'Datenschutz-Management', category: 'compliance', icon: '🛡️', url: '/admin/dsms' },
{ id: 'admin-compliance', name: 'Compliance', description: 'GRC & Audit', category: 'compliance', icon: '✅', url: '/admin/compliance' },
{ id: 'admin-docs-audit', name: 'DSGVO-Audit', description: 'Audit-Dokumentation', category: 'compliance', icon: '📋', url: '/admin/docs/audit' },
{ id: 'admin-rag', name: 'Daten & RAG', description: 'Training Data & RAG', category: 'ai', icon: '🗄️', url: '/admin/rag' },
{ id: 'admin-ocr-labeling', name: 'OCR-Labeling', description: 'Handschrift-Training', category: 'ai', icon: '🏷️', url: '/admin/ocr-labeling' },
{ id: 'admin-magic-help', name: 'Magic Help (TrOCR)', description: 'Handschrift-OCR', category: 'ai', icon: '✨', url: '/admin/magic-help' },
{ id: 'admin-companion', name: 'Companion Dev', description: 'Lesson-Modus Entwicklung', category: 'ai', icon: '📚', url: '/admin/companion' },
{ id: 'admin-communication', name: 'Kommunikation', description: 'Matrix & Jitsi Monitoring', category: 'communication', icon: '💬', url: '/admin/communication' },
{ id: 'admin-alerts', name: 'Alerts Monitoring', description: 'Google Alerts & Feeds', category: 'communication', icon: '🔔', url: '/admin/alerts' },
{ id: 'admin-mail', name: 'Unified Inbox', description: 'E-Mail & KI-Analyse', category: 'communication', icon: '📧', url: '/admin/mail' },
{ id: 'admin-security', name: 'Security', description: 'DevSecOps Dashboard', category: 'security', icon: '🔐', url: '/admin/security' },
{ id: 'admin-sbom', name: 'SBOM', description: 'Software Bill of Materials', category: 'security', icon: '📦', url: '/admin/sbom' },
{ id: 'admin-screen-flow', name: 'Screen Flow', description: 'UI Verbindungen', category: 'security', icon: '🔀', url: '/admin/screen-flow' },
{ id: 'admin-content', name: 'Uebersetzungen', description: 'Website Content', category: 'content', icon: '🌍', url: '/admin/content' },
{ id: 'admin-edu-search', name: 'Education Search', description: 'Bildungsquellen & Crawler', category: 'content', icon: '🔍', url: '/admin/edu-search' },
{ id: 'admin-staff-search', name: 'Personensuche', description: 'Uni-Mitarbeiter', category: 'content', icon: '👤', url: '/admin/staff-search' },
{ id: 'admin-uni-crawler', name: 'Uni-Crawler', description: 'Universitaets-Crawling', category: 'content', icon: '🕷️', url: '/admin/uni-crawler' },
{ id: 'admin-game', name: 'Breakpilot Drive', description: 'Lernspiel Klasse 2-6', category: 'game', icon: '🎮', url: '/admin/game' },
{ id: 'admin-unity-bridge', name: 'Unity Bridge', description: 'Unity Editor Steuerung', category: 'game', icon: '⚡', url: '/admin/unity-bridge' },
{ id: 'admin-backlog', name: 'Production Backlog', description: 'Go-Live Checkliste', category: 'misc', icon: '📝', url: '/admin/backlog' },
{ id: 'admin-brandbook', name: 'Brandbook', description: 'Corporate Design', category: 'misc', icon: '🎨', url: '/admin/brandbook' },
{ id: 'admin-docs', name: 'Developer Docs', description: 'API & Architektur', category: 'misc', icon: '📖', url: '/admin/docs' },
{ id: 'admin-pca-platform', name: 'PCA Platform', description: 'Bot-Erkennung', category: 'misc', icon: '💰', url: '/admin/pca-platform' },
]
export const ADMIN_CONNECTIONS: ConnectionDef[] = [
{ source: 'admin-dashboard', target: 'admin-onboarding' },
{ source: 'admin-dashboard', target: 'admin-security' },
{ source: 'admin-dashboard', target: 'admin-compliance' },
{ source: 'admin-onboarding', target: 'admin-gpu' },
{ source: 'admin-onboarding', target: 'admin-consent' },
{ source: 'admin-consent', target: 'admin-dsr' },
{ source: 'admin-dsr', target: 'admin-dsms' },
{ source: 'admin-dsms', target: 'admin-compliance' },
{ source: 'admin-compliance', target: 'admin-docs-audit' },
{ source: 'admin-rag', target: 'admin-ocr-labeling' },
{ source: 'admin-ocr-labeling', target: 'admin-magic-help' },
{ source: 'admin-magic-help', target: 'admin-companion' },
{ source: 'admin-security', target: 'admin-sbom' },
{ source: 'admin-sbom', target: 'admin-screen-flow' },
{ source: 'admin-communication', target: 'admin-alerts' },
{ source: 'admin-alerts', target: 'admin-mail' },
{ source: 'admin-gpu', target: 'admin-middleware' },
{ source: 'admin-middleware', target: 'admin-mac-mini' },
{ source: 'admin-game', target: 'admin-unity-bridge' },
{ source: 'admin-edu-search', target: 'admin-staff-search' },
{ source: 'admin-staff-search', target: 'admin-uni-crawler' },
]
// ============================================
// CATEGORY COLORS & LABELS
// ============================================
export const STUDIO_COLORS: Record<string, { bg: string; border: string; text: string }> = {
navigation: { bg: '#dbeafe', border: '#3b82f6', text: '#1e40af' },
content: { bg: '#dcfce7', border: '#22c55e', text: '#166534' },
communication: { bg: '#fef3c7', border: '#f59e0b', text: '#92400e' },
school: { bg: '#fce7f3', border: '#ec4899', text: '#9d174d' },
admin: { bg: '#f3e8ff', border: '#a855f7', text: '#6b21a8' },
ai: { bg: '#cffafe', border: '#06b6d4', text: '#0e7490' },
}
export const ADMIN_COLORS: Record<string, { bg: string; border: string; text: string }> = {
overview: { bg: '#dbeafe', border: '#3b82f6', text: '#1e40af' },
infrastructure: { bg: '#fef3c7', border: '#f59e0b', text: '#92400e' },
compliance: { bg: '#dcfce7', border: '#22c55e', text: '#166534' },
ai: { bg: '#cffafe', border: '#06b6d4', text: '#0e7490' },
communication: { bg: '#fce7f3', border: '#ec4899', text: '#9d174d' },
security: { bg: '#fee2e2', border: '#ef4444', text: '#991b1b' },
content: { bg: '#f3e8ff', border: '#a855f7', text: '#6b21a8' },
game: { bg: '#fef9c3', border: '#eab308', text: '#713f12' },
misc: { bg: '#f1f5f9', border: '#64748b', text: '#334155' },
}
export const STUDIO_LABELS: Record<string, string> = {
navigation: 'Navigation',
content: 'Content & Tools',
communication: 'Kommunikation',
school: 'Schulverwaltung',
admin: 'Administration',
ai: 'KI & Assistent',
}
export const ADMIN_LABELS: Record<string, string> = {
overview: 'Uebersicht',
infrastructure: 'Infrastruktur',
compliance: 'DSGVO & Compliance',
ai: 'KI & LLM',
communication: 'Kommunikation',
security: 'Security & DevOps',
content: 'Content & Suche',
game: 'Game & Unity',
misc: 'Sonstiges',
}

View File

@@ -0,0 +1,16 @@
export interface ScreenDefinition {
id: string
name: string
description: string
category: string
icon: string
url?: string
}
export interface ConnectionDef {
source: string
target: string
label?: string
}
export type FlowType = 'studio' | 'admin'

View File

@@ -4,7 +4,7 @@
* Screen Flow Visualization
*
* Visualisiert alle Screens aus:
* - Studio (Port 8000): Lehrer-Oberfläche
* - Studio (Port 8000): Lehrer-Oberflaeche
* - Admin (Port 3000): Admin Panel
*/
@@ -17,310 +17,20 @@ import ReactFlow, {
MiniMap,
useNodesState,
useEdgesState,
Connection,
BackgroundVariant,
MarkerType,
Panel,
} from 'reactflow'
import 'reactflow/dist/style.css'
import AdminLayout from '@/components/admin/AdminLayout'
// ============================================
// TYPES
// ============================================
interface ScreenDefinition {
id: string
name: string
description: string
category: string
icon: string
url?: string
}
interface ConnectionDef {
source: string
target: string
label?: string
}
type FlowType = 'studio' | 'admin'
// ============================================
// STUDIO SCREENS (Port 8000)
// ============================================
const STUDIO_SCREENS: ScreenDefinition[] = [
{ id: 'lehrer-dashboard', name: 'Mein Dashboard', description: 'Hauptübersicht mit Widgets', category: 'navigation', icon: '🏠', url: '/app#lehrer-dashboard' },
{ id: 'lehrer-onboarding', name: 'Erste Schritte', description: 'Onboarding & Schnellstart', category: 'navigation', icon: '🚀', url: '/app#lehrer-onboarding' },
{ id: 'hilfe', name: 'Dokumentation', description: 'Hilfe & Anleitungen', category: 'navigation', icon: '📚', url: '/app#hilfe' },
{ id: 'worksheets', name: 'Arbeitsblätter Studio', description: 'Lernmaterialien erstellen', category: 'content', icon: '📝', url: '/app#worksheets' },
{ id: 'content-creator', name: 'Content Creator', description: 'Inhalte erstellen', category: 'content', icon: '✨', url: '/app#content-creator' },
{ id: 'content-feed', name: 'Content Feed', description: 'Inhalte durchsuchen', category: 'content', icon: '📰', url: '/app#content-feed' },
{ id: 'unit-creator', name: 'Unit Creator', description: 'Lerneinheiten erstellen', category: 'content', icon: '📦', url: '/app#unit-creator' },
{ id: 'letters', name: 'Briefe & Vorlagen', description: 'Brief-Generator', category: 'content', icon: '✉️', url: '/app#letters' },
{ id: 'correction', name: 'Korrektur', description: 'Arbeiten korrigieren', category: 'content', icon: '✏️', url: '/app#correction' },
{ id: 'klausur-korrektur', name: 'Abiturklausuren', description: 'KI-gestützte Klausurkorrektur', category: 'content', icon: '📋', url: '/app#klausur-korrektur' },
{ id: 'jitsi', name: 'Videokonferenz', description: 'Jitsi Meet Integration', category: 'communication', icon: '🎥', url: '/app#jitsi' },
{ id: 'messenger', name: 'Messenger', description: 'Matrix E2EE Chat', category: 'communication', icon: '💬', url: '/app#messenger' },
{ id: 'mail', name: 'Unified Inbox', description: 'E-Mail Verwaltung', category: 'communication', icon: '📧', url: '/app#mail' },
{ id: 'school-classes', name: 'Klassen', description: 'Klassenverwaltung', category: 'school', icon: '👥', url: '/app#school-classes' },
{ id: 'school-exams', name: 'Prüfungen', description: 'Prüfungsverwaltung', category: 'school', icon: '📝', url: '/app#school-exams' },
{ id: 'school-grades', name: 'Noten', description: 'Notenverwaltung', category: 'school', icon: '📊', url: '/app#school-grades' },
{ id: 'school-gradebook', name: 'Notenbuch', description: 'Digitales Notenbuch', category: 'school', icon: '📖', url: '/app#school-gradebook' },
{ id: 'school-certificates', name: 'Zeugnisse', description: 'Zeugniserstellung', category: 'school', icon: '🎓', url: '/app#school-certificates' },
{ id: 'companion', name: 'Begleiter & Stunde', description: 'KI-Unterrichtsassistent', category: 'ai', icon: '🤖', url: '/app#companion' },
{ id: 'alerts', name: 'Alerts', description: 'News & Benachrichtigungen', category: 'ai', icon: '🔔', url: '/app#alerts' },
{ id: 'admin', name: 'Einstellungen', description: 'Systemeinstellungen', category: 'admin', icon: '⚙️', url: '/app#admin' },
{ id: 'rbac-admin', name: 'Rollen & Rechte', description: 'Berechtigungsverwaltung', category: 'admin', icon: '🔐', url: '/app#rbac-admin' },
{ id: 'abitur-docs-admin', name: 'Abitur Dokumente', description: 'Erwartungshorizonte', category: 'admin', icon: '📄', url: '/app#abitur-docs-admin' },
{ id: 'system-info', name: 'System Info', description: 'Systeminformationen', category: 'admin', icon: '💻', url: '/app#system-info' },
{ id: 'workflow', name: 'Workflow', description: 'Automatisierungen', category: 'admin', icon: '⚡', url: '/app#workflow' },
]
const STUDIO_CONNECTIONS: ConnectionDef[] = [
{ source: 'lehrer-onboarding', target: 'worksheets', label: 'Arbeitsblätter' },
{ source: 'lehrer-onboarding', target: 'klausur-korrektur', label: 'Abiturklausuren' },
{ source: 'lehrer-onboarding', target: 'correction', label: 'Korrektur' },
{ source: 'lehrer-onboarding', target: 'letters', label: 'Briefe' },
{ source: 'lehrer-onboarding', target: 'school-classes', label: 'Klassen' },
{ source: 'lehrer-onboarding', target: 'jitsi', label: 'Meet' },
{ source: 'lehrer-onboarding', target: 'hilfe', label: 'Doku' },
{ source: 'lehrer-onboarding', target: 'admin', label: 'Settings' },
{ source: 'lehrer-dashboard', target: 'worksheets' },
{ source: 'lehrer-dashboard', target: 'correction' },
{ source: 'lehrer-dashboard', target: 'jitsi' },
{ source: 'lehrer-dashboard', target: 'letters' },
{ source: 'lehrer-dashboard', target: 'messenger' },
{ source: 'lehrer-dashboard', target: 'klausur-korrektur' },
{ source: 'lehrer-dashboard', target: 'companion' },
{ source: 'lehrer-dashboard', target: 'alerts' },
{ source: 'lehrer-dashboard', target: 'mail' },
{ source: 'lehrer-dashboard', target: 'school-classes' },
{ source: 'lehrer-dashboard', target: 'lehrer-onboarding', label: 'Sidebar' },
{ source: 'school-classes', target: 'school-exams' },
{ source: 'school-classes', target: 'school-grades' },
{ source: 'school-grades', target: 'school-gradebook' },
{ source: 'school-gradebook', target: 'school-certificates' },
{ source: 'worksheets', target: 'content-creator' },
{ source: 'worksheets', target: 'unit-creator' },
{ source: 'content-creator', target: 'content-feed' },
{ source: 'klausur-korrektur', target: 'abitur-docs-admin' },
{ source: 'admin', target: 'rbac-admin' },
{ source: 'admin', target: 'system-info' },
{ source: 'admin', target: 'workflow' },
]
// ============================================
// ADMIN SCREENS (Port 3000)
// ============================================
const ADMIN_SCREENS: ScreenDefinition[] = [
{ id: 'admin-dashboard', name: 'Dashboard', description: 'Übersicht & Statistiken', category: 'overview', icon: '🏠', url: '/admin' },
{ id: 'admin-onboarding', name: 'Onboarding', description: 'Lern-Wizards für alle Module', category: 'overview', icon: '📖', url: '/admin/onboarding' },
{ id: 'admin-gpu', name: 'GPU Infrastruktur', description: 'vast.ai GPU Management', category: 'infrastructure', icon: '🖥️', url: '/admin/gpu' },
{ id: 'admin-middleware', name: 'Middleware', description: 'Middleware Stack & Test', category: 'infrastructure', icon: '🔧', url: '/admin/middleware' },
{ id: 'admin-mac-mini', name: 'Mac Mini', description: 'Headless Mac Mini Control', category: 'infrastructure', icon: '🍎', url: '/admin/mac-mini' },
{ id: 'admin-consent', name: 'Consent Verwaltung', description: 'Rechtliche Dokumente', category: 'compliance', icon: '📄', url: '/admin/consent' },
{ id: 'admin-dsr', name: 'Datenschutzanfragen', description: 'DSGVO Art. 15-21', category: 'compliance', icon: '🔒', url: '/admin/dsr' },
{ id: 'admin-dsms', name: 'DSMS', description: 'Datenschutz-Management', category: 'compliance', icon: '🛡️', url: '/admin/dsms' },
{ id: 'admin-compliance', name: 'Compliance', description: 'GRC & Audit', category: 'compliance', icon: '✅', url: '/admin/compliance' },
{ id: 'admin-docs-audit', name: 'DSGVO-Audit', description: 'Audit-Dokumentation', category: 'compliance', icon: '📋', url: '/admin/docs/audit' },
{ id: 'admin-rag', name: 'Daten & RAG', description: 'Training Data & RAG', category: 'ai', icon: '🗄️', url: '/admin/rag' },
{ id: 'admin-ocr-labeling', name: 'OCR-Labeling', description: 'Handschrift-Training', category: 'ai', icon: '🏷️', url: '/admin/ocr-labeling' },
{ id: 'admin-magic-help', name: 'Magic Help (TrOCR)', description: 'Handschrift-OCR', category: 'ai', icon: '✨', url: '/admin/magic-help' },
{ id: 'admin-companion', name: 'Companion Dev', description: 'Lesson-Modus Entwicklung', category: 'ai', icon: '📚', url: '/admin/companion' },
{ id: 'admin-communication', name: 'Kommunikation', description: 'Matrix & Jitsi Monitoring', category: 'communication', icon: '💬', url: '/admin/communication' },
{ id: 'admin-alerts', name: 'Alerts Monitoring', description: 'Google Alerts & Feeds', category: 'communication', icon: '🔔', url: '/admin/alerts' },
{ id: 'admin-mail', name: 'Unified Inbox', description: 'E-Mail & KI-Analyse', category: 'communication', icon: '📧', url: '/admin/mail' },
{ id: 'admin-security', name: 'Security', description: 'DevSecOps Dashboard', category: 'security', icon: '🔐', url: '/admin/security' },
{ id: 'admin-sbom', name: 'SBOM', description: 'Software Bill of Materials', category: 'security', icon: '📦', url: '/admin/sbom' },
{ id: 'admin-screen-flow', name: 'Screen Flow', description: 'UI Verbindungen', category: 'security', icon: '🔀', url: '/admin/screen-flow' },
{ id: 'admin-content', name: 'Übersetzungen', description: 'Website Content', category: 'content', icon: '🌍', url: '/admin/content' },
{ id: 'admin-edu-search', name: 'Education Search', description: 'Bildungsquellen & Crawler', category: 'content', icon: '🔍', url: '/admin/edu-search' },
{ id: 'admin-staff-search', name: 'Personensuche', description: 'Uni-Mitarbeiter', category: 'content', icon: '👤', url: '/admin/staff-search' },
{ id: 'admin-uni-crawler', name: 'Uni-Crawler', description: 'Universitäts-Crawling', category: 'content', icon: '🕷️', url: '/admin/uni-crawler' },
{ id: 'admin-game', name: 'Breakpilot Drive', description: 'Lernspiel Klasse 2-6', category: 'game', icon: '🎮', url: '/admin/game' },
{ id: 'admin-unity-bridge', name: 'Unity Bridge', description: 'Unity Editor Steuerung', category: 'game', icon: '⚡', url: '/admin/unity-bridge' },
{ id: 'admin-backlog', name: 'Production Backlog', description: 'Go-Live Checkliste', category: 'misc', icon: '📝', url: '/admin/backlog' },
{ id: 'admin-brandbook', name: 'Brandbook', description: 'Corporate Design', category: 'misc', icon: '🎨', url: '/admin/brandbook' },
{ id: 'admin-docs', name: 'Developer Docs', description: 'API & Architektur', category: 'misc', icon: '📖', url: '/admin/docs' },
{ id: 'admin-pca-platform', name: 'PCA Platform', description: 'Bot-Erkennung', category: 'misc', icon: '💰', url: '/admin/pca-platform' },
]
const ADMIN_CONNECTIONS: ConnectionDef[] = [
{ source: 'admin-dashboard', target: 'admin-onboarding' },
{ source: 'admin-dashboard', target: 'admin-security' },
{ source: 'admin-dashboard', target: 'admin-compliance' },
{ source: 'admin-onboarding', target: 'admin-gpu' },
{ source: 'admin-onboarding', target: 'admin-consent' },
{ source: 'admin-consent', target: 'admin-dsr' },
{ source: 'admin-dsr', target: 'admin-dsms' },
{ source: 'admin-dsms', target: 'admin-compliance' },
{ source: 'admin-compliance', target: 'admin-docs-audit' },
{ source: 'admin-rag', target: 'admin-ocr-labeling' },
{ source: 'admin-ocr-labeling', target: 'admin-magic-help' },
{ source: 'admin-magic-help', target: 'admin-companion' },
{ source: 'admin-security', target: 'admin-sbom' },
{ source: 'admin-sbom', target: 'admin-screen-flow' },
{ source: 'admin-communication', target: 'admin-alerts' },
{ source: 'admin-alerts', target: 'admin-mail' },
{ source: 'admin-gpu', target: 'admin-middleware' },
{ source: 'admin-middleware', target: 'admin-mac-mini' },
{ source: 'admin-game', target: 'admin-unity-bridge' },
{ source: 'admin-edu-search', target: 'admin-staff-search' },
{ source: 'admin-staff-search', target: 'admin-uni-crawler' },
]
// ============================================
// CATEGORY COLORS
// ============================================
const STUDIO_COLORS: Record<string, { bg: string; border: string; text: string }> = {
navigation: { bg: '#dbeafe', border: '#3b82f6', text: '#1e40af' },
content: { bg: '#dcfce7', border: '#22c55e', text: '#166534' },
communication: { bg: '#fef3c7', border: '#f59e0b', text: '#92400e' },
school: { bg: '#fce7f3', border: '#ec4899', text: '#9d174d' },
admin: { bg: '#f3e8ff', border: '#a855f7', text: '#6b21a8' },
ai: { bg: '#cffafe', border: '#06b6d4', text: '#0e7490' },
}
const ADMIN_COLORS: Record<string, { bg: string; border: string; text: string }> = {
overview: { bg: '#dbeafe', border: '#3b82f6', text: '#1e40af' },
infrastructure: { bg: '#fef3c7', border: '#f59e0b', text: '#92400e' },
compliance: { bg: '#dcfce7', border: '#22c55e', text: '#166534' },
ai: { bg: '#cffafe', border: '#06b6d4', text: '#0e7490' },
communication: { bg: '#fce7f3', border: '#ec4899', text: '#9d174d' },
security: { bg: '#fee2e2', border: '#ef4444', text: '#991b1b' },
content: { bg: '#f3e8ff', border: '#a855f7', text: '#6b21a8' },
game: { bg: '#fef9c3', border: '#eab308', text: '#713f12' },
misc: { bg: '#f1f5f9', border: '#64748b', text: '#334155' },
}
const STUDIO_LABELS: Record<string, string> = {
navigation: 'Navigation',
content: 'Content & Tools',
communication: 'Kommunikation',
school: 'Schulverwaltung',
admin: 'Administration',
ai: 'KI & Assistent',
}
const ADMIN_LABELS: Record<string, string> = {
overview: 'Übersicht',
infrastructure: 'Infrastruktur',
compliance: 'DSGVO & Compliance',
ai: 'KI & LLM',
communication: 'Kommunikation',
security: 'Security & DevOps',
content: 'Content & Suche',
game: 'Game & Unity',
misc: 'Sonstiges',
}
// ============================================
// HELPER: Find all connected nodes (recursive)
// ============================================
function findConnectedNodes(
startNodeId: string,
connections: ConnectionDef[],
direction: 'children' | 'parents' | 'both' = 'children'
): Set<string> {
const connected = new Set<string>()
connected.add(startNodeId)
const queue = [startNodeId]
while (queue.length > 0) {
const current = queue.shift()!
connections.forEach(conn => {
if ((direction === 'children' || direction === 'both') && conn.source === current) {
if (!connected.has(conn.target)) {
connected.add(conn.target)
queue.push(conn.target)
}
}
if ((direction === 'parents' || direction === 'both') && conn.target === current) {
if (!connected.has(conn.source)) {
connected.add(conn.source)
queue.push(conn.source)
}
}
})
}
return connected
}
// ============================================
// HELPER: Construct embed URL
// ============================================
function constructEmbedUrl(baseUrl: string, url: string | undefined): string | null {
if (!url) return null
const hashIndex = url.indexOf('#')
if (hashIndex !== -1) {
const basePart = url.substring(0, hashIndex)
const hashPart = url.substring(hashIndex)
const separator = basePart.includes('?') ? '&' : '?'
return `${baseUrl}${basePart}${separator}embed=true${hashPart}`
} else {
const separator = url.includes('?') ? '&' : '?'
return `${baseUrl}${url}${separator}embed=true`
}
}
// ============================================
// LAYOUT HELPERS
// ============================================
const getNodePosition = (
id: string,
category: string,
screens: ScreenDefinition[],
flowType: FlowType
) => {
const studioPositions: Record<string, { x: number; y: number }> = {
navigation: { x: 400, y: 50 },
content: { x: 50, y: 250 },
communication: { x: 750, y: 250 },
school: { x: 50, y: 500 },
admin: { x: 750, y: 500 },
ai: { x: 400, y: 380 },
}
const adminPositions: Record<string, { x: number; y: number }> = {
overview: { x: 400, y: 30 },
infrastructure: { x: 50, y: 150 },
compliance: { x: 700, y: 150 },
ai: { x: 50, y: 350 },
communication: { x: 400, y: 350 },
security: { x: 700, y: 350 },
content: { x: 50, y: 550 },
game: { x: 400, y: 550 },
misc: { x: 700, y: 550 },
}
const positions = flowType === 'studio' ? studioPositions : adminPositions
const base = positions[category] || { x: 400, y: 300 }
const categoryScreens = screens.filter(s => s.category === category)
const categoryIndex = categoryScreens.findIndex(s => s.id === id)
const cols = Math.ceil(Math.sqrt(categoryScreens.length + 1))
const row = Math.floor(categoryIndex / cols)
const col = categoryIndex % cols
return {
x: base.x + col * 160,
y: base.y + row * 90,
}
}
// ============================================
// MAIN COMPONENT
// ============================================
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')
@@ -329,82 +39,44 @@ export default function ScreenFlowPage() {
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const [previewScreen, setPreviewScreen] = useState<ScreenDefinition | null>(null)
// Get data based on flow type
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'
// Calculate connected nodes
const connectedNodes = useMemo(() => {
if (!selectedNode) return new Set<string>()
return findConnectedNodes(selectedNode, connections, 'children')
}, [selectedNode, connections])
// Create nodes with useMemo
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)
// Determine opacity
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',
},
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])
// Create edges with useMemo
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' },
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 },
}
})
@@ -413,196 +85,74 @@ export default function ScreenFlowPage() {
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
// Update nodes/edges when dependencies change
useEffect(() => {
setNodes(initialNodes)
setEdges(initialEdges)
}, [initialNodes, initialEdges])
useEffect(() => { setNodes(initialNodes); setEdges(initialEdges) }, [initialNodes, initialEdges])
// Reset when flow type changes
const handleFlowTypeChange = useCallback((newType: FlowType) => {
setFlowType(newType)
setSelectedNode(null)
setSelectedCategory(null)
setPreviewUrl(null)
setPreviewScreen(null)
setFlowType(newType); setSelectedNode(null); setSelectedCategory(null); setPreviewUrl(null); setPreviewScreen(null)
}, [])
// Handle node click
const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
const screen = screens.find(s => s.id === node.id)
if (selectedNode === node.id) {
// Double-click: open in new tab
if (screen?.url) {
window.open(`${baseUrl}${screen.url}`, '_blank')
}
return
}
setSelectedNode(node.id)
setSelectedCategory(null)
if (screen?.url) {
const embedUrl = constructEmbedUrl(baseUrl, screen.url)
setPreviewUrl(embedUrl)
setPreviewScreen(screen)
}
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])
// Handle background click - deselect
const onPaneClick = useCallback(() => {
setSelectedNode(null)
setPreviewUrl(null)
setPreviewScreen(null)
}, [])
// Close preview
const closePreview = useCallback(() => {
setPreviewUrl(null)
setPreviewScreen(null)
setSelectedNode(null)
}, [])
// Stats
const stats = {
totalScreens: screens.length,
totalConnections: connections.length,
connectedCount: connectedNodes.size,
}
const onPaneClick = useCallback(() => { setSelectedNode(null); setPreviewUrl(null); setPreviewScreen(null) }, [])
const closePreview = useCallback(() => { setPreviewUrl(null); setPreviewScreen(null); setSelectedNode(null) }, [])
const categories = Object.keys(labels)
// Connected screens list
const connectedScreens = selectedNode
? screens.filter(s => connectedNodes.has(s.id))
: []
const connectedScreens = selectedNode ? screens.filter(s => connectedNodes.has(s.id)) : []
return (
<AdminLayout
title="Screen Flow"
description="Visualisierung aller UI-Screens und ihrer Verbindungen"
>
<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">
<button
onClick={() => handleFlowTypeChange('studio')}
className={`p-6 rounded-xl border-2 transition-all ${
flowType === 'studio'
? 'border-green-500 bg-green-50 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 ${
flowType === 'studio' ? 'bg-green-500 text-white' : 'bg-slate-100'
}`}>
🎓
</div>
<div className="text-left">
<div className="font-bold text-lg">Studio (Port 8000)</div>
<div className="text-sm text-slate-500">Lehrer-Oberfläche</div>
<div className="text-xs text-slate-400 mt-1">{STUDIO_SCREENS.length} Screens</div>
</div>
</div>
</button>
<button
onClick={() => handleFlowTypeChange('admin')}
className={`p-6 rounded-xl border-2 transition-all ${
flowType === 'admin'
? 'border-purple-500 bg-purple-50 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 ${
flowType === 'admin' ? 'bg-purple-500 text-white' : 'bg-slate-100'
}`}>
</div>
<div className="text-left">
<div className="font-bold text-lg">Admin (Port 3000)</div>
<div className="text-sm text-slate-500">Admin Panel</div>
<div className="text-xs text-slate-400 mt-1">{ADMIN_SCREENS.length} Screens</div>
</div>
</div>
</button>
{(['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 & Selection Info */}
{/* 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">{stats.totalScreens}</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">{stats.totalConnections}</div>
<div className="text-sm text-slate-500">Verbindungen</div>
</div>
<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">
{stats.connectedCount} verbundene Screen{stats.connectedCount !== 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"
>
Zurücksetzen
</button>
<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 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>
<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 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>
)
})}
@@ -618,23 +168,8 @@ export default function ScreenFlowPage() {
const catColors = colors[screen.category] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
const isCurrentNode = screen.id === selectedNode
return (
<button
key={screen.id}
onClick={() => {
const embedUrl = constructEmbedUrl(baseUrl, screen.url)
setPreviewUrl(embedUrl)
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 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>
)
})}
@@ -644,50 +179,12 @@ export default function ScreenFlowPage() {
{/* 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 screen = screens.find(s => s.id === node.id)
const catColors = screen ? colors[screen.category] : null
return catColors?.border || '#94a3b8'
}}
maskColor="rgba(0, 0, 0, 0.1)"
/>
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
<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 = Öffnen
</div>
<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>
@@ -696,92 +193,32 @@ export default function ScreenFlowPage() {
{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-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>
Öffnen
</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>
Schließen
</button>
<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 className="relative" style={{ height: '600px' }}><iframe src={previewUrl} className="w-full h-full border-0" title={`Preview: ${previewScreen?.name}`} /></div>
</div>
)}
{/* Screen List (when no preview) */}
{/* 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="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)
const embedUrl = constructEmbedUrl(baseUrl, screen.url)
setPreviewUrl(embedUrl)
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>
)
})}
{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>
)}

View File

@@ -0,0 +1,96 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { Message, BUNDESLAENDER, COMMON_QUESTIONS } from './types'
export default function ChatInterface({ messages, onSendMessage, isLoading, bundesland }: {
messages: Message[]
onSendMessage: (message: string) => void
isLoading: boolean
bundesland: string | null
}) {
const [input, setInput] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (input.trim() && !isLoading) {
onSendMessage(input.trim())
setInput('')
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit(e)
}
}
return (
<div className="flex flex-col h-full">
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{messages.length === 0 ? (
<div className="text-center py-12">
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-100 to-purple-100 dark:from-blue-900/30 dark:to-purple-900/30 rounded-full flex items-center justify-center"><span className="text-4xl">💬</span></div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-3">Stellen Sie eine Frage</h3>
<p className="text-gray-500 dark:text-gray-400 mb-6 max-w-md mx-auto">
Der Zeugnis-Assistent beantwortet Ihre Fragen basierend auf den offiziellen Verordnungen {bundesland ? `fuer ${BUNDESLAENDER.find(b => b.code === bundesland)?.name}` : ''}.
</p>
<div className="flex flex-wrap justify-center gap-2">
{COMMON_QUESTIONS.slice(0, 3).map((q, i) => (
<button key={i} onClick={() => onSendMessage(q)} className="px-4 py-2 text-sm bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition">{q}</button>
))}
</div>
</div>
) : (
messages.map((message) => (
<div key={message.id} className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[80%] rounded-2xl p-4 ${message.role === 'user' ? 'bg-blue-600 text-white' : 'bg-white dark:bg-gray-800 shadow-lg border border-gray-200 dark:border-gray-700'}`}>
<p className={`${message.role === 'user' ? 'text-white' : 'text-gray-900 dark:text-white'}`}>{message.content}</p>
{message.sources && message.sources.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Quellen:</p>
<div className="space-y-2">
{message.sources.map((source) => (
<a key={source.id} href={source.url} target="_blank" rel="noopener noreferrer" className="block p-2 bg-gray-50 dark:bg-gray-900 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition">
<p className="text-sm font-medium text-blue-600 dark:text-blue-400">{source.title}</p>
<p className="text-xs text-gray-500">{source.bundesland_name} - {source.doc_type}</p>
</a>
))}
</div>
</div>
)}
</div>
</div>
))
)}
{isLoading && (
<div className="flex justify-start">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }} />
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }} />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="p-4 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<form onSubmit={handleSubmit} className="flex gap-3">
<textarea ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} placeholder="Stellen Sie Ihre Frage..." rows={1} className="flex-1 px-4 py-3 bg-gray-100 dark:bg-gray-900 border-0 rounded-xl resize-none focus:ring-2 focus:ring-blue-500 text-gray-900 dark:text-white placeholder-gray-500" />
<button type="submit" disabled={!input.trim() || isLoading} className="px-6 py-3 bg-blue-600 text-white rounded-xl hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" /></svg>
</button>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,87 @@
'use client'
import { useState } from 'react'
import { UserPreferences, BUNDESLAENDER, SCHULFORMEN } from './types'
export default function OnboardingWizard({ onComplete }: { onComplete: (prefs: Partial<UserPreferences>) => void }) {
const [step, setStep] = useState(0)
const [bundesland, setBundesland] = useState<string | null>(null)
const [schulform, setSchulform] = useState<string | null>(null)
const steps = [
{ title: 'Willkommen beim Zeugnis-Assistenten', subtitle: 'Ihr intelligenter Helfer fuer alle Fragen rund um Zeugnisse' },
{ title: 'In welchem Bundesland unterrichten Sie?', subtitle: 'Wir zeigen Ihnen die passenden Verordnungen' },
{ title: 'An welcher Schulform?', subtitle: 'So koennen wir die Informationen noch besser anpassen' },
{ title: 'Alles eingerichtet!', subtitle: 'Sie koennen jetzt loslegen' },
]
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gradient-to-br from-blue-600 to-purple-700">
<div className="bg-white dark:bg-gray-800 rounded-3xl shadow-2xl w-full max-w-2xl overflow-hidden">
<div className="h-2 bg-gray-100 dark:bg-gray-700">
<div className="h-full bg-gradient-to-r from-blue-500 to-purple-500 transition-all duration-500" style={{ width: `${((step + 1) / steps.length) * 100}%` }} />
</div>
<div className="p-8">
{step === 0 && (
<div className="text-center py-8">
<div className="w-24 h-24 mx-auto mb-6 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center"><span className="text-5xl">📋</span></div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-3">{steps[0].title}</h2>
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8">{steps[0].subtitle}</p>
<div className="grid grid-cols-3 gap-4 text-center mb-8">
<div className="p-4"><span className="text-3xl">🔍</span><p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Schnelle Suche in Verordnungen</p></div>
<div className="p-4"><span className="text-3xl">💬</span><p className="mt-2 text-sm text-gray-600 dark:text-gray-400">KI-gestuetzte Antworten</p></div>
<div className="p-4"><span className="text-3xl">📚</span><p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Alle 16 Bundeslaender</p></div>
</div>
</div>
)}
{step === 1 && (
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2 text-center">{steps[1].title}</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6 text-center">{steps[1].subtitle}</p>
<div className="grid grid-cols-4 gap-3 max-h-80 overflow-y-auto">
{BUNDESLAENDER.map((bl) => (
<button key={bl.code} onClick={() => setBundesland(bl.code)} className={`p-4 rounded-xl border-2 transition-all text-center hover:scale-105 ${bundesland === bl.code ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 shadow-lg' : 'border-gray-200 dark:border-gray-700 hover:border-blue-300'}`}>
<span className="text-2xl">{bl.emoji}</span><p className="mt-2 text-sm font-medium text-gray-900 dark:text-white">{bl.name}</p>
</button>
))}
</div>
</div>
)}
{step === 2 && (
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2 text-center">{steps[2].title}</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6 text-center">{steps[2].subtitle}</p>
<div className="grid grid-cols-2 gap-4">
{SCHULFORMEN.map((sf) => (
<button key={sf.id} onClick={() => setSchulform(sf.id)} className={`p-6 rounded-xl border-2 transition-all hover:scale-105 ${schulform === sf.id ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 shadow-lg' : 'border-gray-200 dark:border-gray-700 hover:border-blue-300'}`}>
<span className="text-3xl">{sf.icon}</span><p className="mt-3 text-lg font-medium text-gray-900 dark:text-white">{sf.name}</p>
</button>
))}
</div>
</div>
)}
{step === 3 && (
<div className="text-center py-8">
<div className="w-24 h-24 mx-auto mb-6 bg-gradient-to-br from-green-400 to-emerald-600 rounded-full flex items-center justify-center"><span className="text-5xl"></span></div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-3">{steps[3].title}</h2>
<p className="text-lg text-gray-600 dark:text-gray-400 mb-6">{steps[3].subtitle}</p>
<div className="bg-gray-50 dark:bg-gray-900 rounded-xl p-6 text-left max-w-md mx-auto">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">Ihre Einstellungen:</p>
<div className="space-y-2">
<div className="flex items-center gap-3"><span className="text-xl">{BUNDESLAENDER.find(b => b.code === bundesland)?.emoji}</span><span className="font-medium text-gray-900 dark:text-white">{BUNDESLAENDER.find(b => b.code === bundesland)?.name}</span></div>
<div className="flex items-center gap-3"><span className="text-xl">{SCHULFORMEN.find(s => s.id === schulform)?.icon}</span><span className="font-medium text-gray-900 dark:text-white">{SCHULFORMEN.find(s => s.id === schulform)?.name}</span></div>
</div>
</div>
</div>
)}
</div>
<div className="px-8 py-6 bg-gray-50 dark:bg-gray-900 flex justify-between">
<button onClick={() => step > 0 && setStep(step - 1)} className={`px-6 py-2 text-gray-600 dark:text-gray-400 ${step === 0 ? 'invisible' : ''}`}>Zurueck</button>
<button onClick={() => { if (step < 3) { setStep(step + 1) } else { onComplete({ bundesland, schulform, hasSeenWizard: true }) } }} disabled={step === 1 && !bundesland || step === 2 && !schulform} className="px-8 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-medium rounded-xl shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed">
{step === 3 ? 'Loslegen' : 'Weiter'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,48 @@
'use client'
import { SearchResult } from './types'
export default function SearchResults({ results, onSelect }: {
results: SearchResult[]
onSelect: (result: SearchResult) => void
}) {
if (results.length === 0) {
return (
<div className="text-center py-12">
<span className="text-4xl">🔍</span>
<p className="mt-4 text-gray-500 dark:text-gray-400">Keine Ergebnisse gefunden</p>
</div>
)
}
return (
<div className="space-y-4">
{results.map((result) => (
<button
key={result.id}
onClick={() => onSelect(result)}
className="w-full text-left p-4 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md hover:border-blue-300 transition"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium text-gray-900 dark:text-white mb-1">{result.title}</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">{result.bundesland_name} - {result.doc_type}</p>
<p className="text-sm text-gray-600 dark:text-gray-300 line-clamp-2">{result.snippet}</p>
</div>
<div className="ml-4 flex items-center gap-2">
<span className={`px-2 py-1 text-xs rounded-full ${
result.relevance_score > 0.8
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: result.relevance_score > 0.5
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
}`}>
{Math.round(result.relevance_score * 100)}%
</span>
</div>
</div>
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,65 @@
export interface SearchResult {
id: string
title: string
bundesland: string
bundesland_name: string
doc_type: string
snippet: string
relevance_score: number
url: string
last_updated: string
}
export interface Message {
id: string
role: 'user' | 'assistant' | 'system'
content: string
sources?: SearchResult[]
timestamp: Date
}
export interface UserPreferences {
bundesland: string | null
schulform: string | null
hasSeenWizard: boolean
favorites: string[]
recentSearches: string[]
}
export const BUNDESLAENDER = [
{ code: 'bw', name: 'Baden-Wuerttemberg', emoji: '🏰' },
{ code: 'by', name: 'Bayern', emoji: '🦁' },
{ code: 'be', name: 'Berlin', emoji: '🐻' },
{ code: 'bb', name: 'Brandenburg', emoji: '🦅' },
{ code: 'hb', name: 'Bremen', emoji: '🔑' },
{ code: 'hh', name: 'Hamburg', emoji: '⚓' },
{ code: 'he', name: 'Hessen', emoji: '🦁' },
{ code: 'mv', name: 'Mecklenburg-Vorpommern', emoji: '🐂' },
{ code: 'ni', name: 'Niedersachsen', emoji: '🐴' },
{ code: 'nw', name: 'Nordrhein-Westfalen', emoji: '🏛️' },
{ code: 'rp', name: 'Rheinland-Pfalz', emoji: '🍇' },
{ code: 'sl', name: 'Saarland', emoji: '⚒️' },
{ code: 'sn', name: 'Sachsen', emoji: '⚔️' },
{ code: 'st', name: 'Sachsen-Anhalt', emoji: '🏰' },
{ code: 'sh', name: 'Schleswig-Holstein', emoji: '🌊' },
{ code: 'th', name: 'Thueringen', emoji: '🌲' },
]
export const SCHULFORMEN = [
{ id: 'grundschule', name: 'Grundschule', icon: '🎒' },
{ id: 'hauptschule', name: 'Hauptschule', icon: '📚' },
{ id: 'realschule', name: 'Realschule', icon: '📖' },
{ id: 'gymnasium', name: 'Gymnasium', icon: '🎓' },
{ id: 'gesamtschule', name: 'Gesamtschule', icon: '🏫' },
{ id: 'foerderschule', name: 'Foerderschule', icon: '💚' },
{ id: 'berufsschule', name: 'Berufsschule', icon: '🔧' },
]
export const COMMON_QUESTIONS = [
'Wie formuliere ich eine Bemerkung zur Arbeits- und Sozialverhalten?',
'Welche Noten duerfen im Zeugnis stehen?',
'Wann sind Zeugniskonferenzen durchzufuehren?',
'Wie gehe ich mit Fehlzeiten um?',
'Welche Unterschriften sind erforderlich?',
'Wie werden Versetzungsentscheidungen dokumentiert?',
]

View File

@@ -1,502 +1,14 @@
'use client'
import { useState, useEffect, useRef } from 'react'
// ============================================================================
// TYPES
// ============================================================================
interface SearchResult {
id: string
title: string
bundesland: string
bundesland_name: string
doc_type: string
snippet: string
relevance_score: number
url: string
last_updated: string
}
interface Message {
id: string
role: 'user' | 'assistant' | 'system'
content: string
sources?: SearchResult[]
timestamp: Date
}
interface UserPreferences {
bundesland: string | null
schulform: string | null
hasSeenWizard: boolean
favorites: string[]
recentSearches: string[]
}
// ============================================================================
// CONSTANTS
// ============================================================================
const BUNDESLAENDER = [
{ code: 'bw', name: 'Baden-Württemberg', emoji: '🏰' },
{ code: 'by', name: 'Bayern', emoji: '🦁' },
{ code: 'be', name: 'Berlin', emoji: '🐻' },
{ code: 'bb', name: 'Brandenburg', emoji: '🦅' },
{ code: 'hb', name: 'Bremen', emoji: '🔑' },
{ code: 'hh', name: 'Hamburg', emoji: '⚓' },
{ code: 'he', name: 'Hessen', emoji: '🦁' },
{ code: 'mv', name: 'Mecklenburg-Vorpommern', emoji: '🐂' },
{ code: 'ni', name: 'Niedersachsen', emoji: '🐴' },
{ code: 'nw', name: 'Nordrhein-Westfalen', emoji: '🏛️' },
{ code: 'rp', name: 'Rheinland-Pfalz', emoji: '🍇' },
{ code: 'sl', name: 'Saarland', emoji: '⚒️' },
{ code: 'sn', name: 'Sachsen', emoji: '⚔️' },
{ code: 'st', name: 'Sachsen-Anhalt', emoji: '🏰' },
{ code: 'sh', name: 'Schleswig-Holstein', emoji: '🌊' },
{ code: 'th', name: 'Thüringen', emoji: '🌲' },
]
const SCHULFORMEN = [
{ id: 'grundschule', name: 'Grundschule', icon: '🎒' },
{ id: 'hauptschule', name: 'Hauptschule', icon: '📚' },
{ id: 'realschule', name: 'Realschule', icon: '📖' },
{ id: 'gymnasium', name: 'Gymnasium', icon: '🎓' },
{ id: 'gesamtschule', name: 'Gesamtschule', icon: '🏫' },
{ id: 'foerderschule', name: 'Förderschule', icon: '💚' },
{ id: 'berufsschule', name: 'Berufsschule', icon: '🔧' },
]
const COMMON_QUESTIONS = [
'Wie formuliere ich eine Bemerkung zur Arbeits- und Sozialverhalten?',
'Welche Noten dürfen im Zeugnis stehen?',
'Wann sind Zeugniskonferenzen durchzuführen?',
'Wie gehe ich mit Fehlzeiten um?',
'Welche Unterschriften sind erforderlich?',
'Wie werden Versetzungsentscheidungen dokumentiert?',
]
// ============================================================================
// ONBOARDING WIZARD COMPONENT
// ============================================================================
function OnboardingWizard({ onComplete }: { onComplete: (prefs: Partial<UserPreferences>) => void }) {
const [step, setStep] = useState(0)
const [bundesland, setBundesland] = useState<string | null>(null)
const [schulform, setSchulform] = useState<string | null>(null)
const steps = [
{
title: 'Willkommen beim Zeugnis-Assistenten',
subtitle: 'Ihr intelligenter Helfer für alle Fragen rund um Zeugnisse',
},
{
title: 'In welchem Bundesland unterrichten Sie?',
subtitle: 'Wir zeigen Ihnen die passenden Verordnungen',
},
{
title: 'An welcher Schulform?',
subtitle: 'So können wir die Informationen noch besser anpassen',
},
{
title: 'Alles eingerichtet!',
subtitle: 'Sie können jetzt loslegen',
},
]
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gradient-to-br from-blue-600 to-purple-700">
<div className="bg-white dark:bg-gray-800 rounded-3xl shadow-2xl w-full max-w-2xl overflow-hidden">
{/* Progress */}
<div className="h-2 bg-gray-100 dark:bg-gray-700">
<div
className="h-full bg-gradient-to-r from-blue-500 to-purple-500 transition-all duration-500"
style={{ width: `${((step + 1) / steps.length) * 100}%` }}
/>
</div>
{/* Content */}
<div className="p-8">
{/* Step 0: Welcome */}
{step === 0 && (
<div className="text-center py-8">
<div className="w-24 h-24 mx-auto mb-6 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center">
<span className="text-5xl">📋</span>
</div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-3">
{steps[0].title}
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400 mb-8">
{steps[0].subtitle}
</p>
<div className="grid grid-cols-3 gap-4 text-center mb-8">
<div className="p-4">
<span className="text-3xl">🔍</span>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Schnelle Suche in Verordnungen
</p>
</div>
<div className="p-4">
<span className="text-3xl">💬</span>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
KI-gestützte Antworten
</p>
</div>
<div className="p-4">
<span className="text-3xl">📚</span>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Alle 16 Bundesländer
</p>
</div>
</div>
</div>
)}
{/* Step 1: Bundesland */}
{step === 1 && (
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2 text-center">
{steps[1].title}
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6 text-center">
{steps[1].subtitle}
</p>
<div className="grid grid-cols-4 gap-3 max-h-80 overflow-y-auto">
{BUNDESLAENDER.map((bl) => (
<button
key={bl.code}
onClick={() => setBundesland(bl.code)}
className={`p-4 rounded-xl border-2 transition-all text-center hover:scale-105 ${
bundesland === bl.code
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 shadow-lg'
: 'border-gray-200 dark:border-gray-700 hover:border-blue-300'
}`}
>
<span className="text-2xl">{bl.emoji}</span>
<p className="mt-2 text-sm font-medium text-gray-900 dark:text-white">
{bl.name}
</p>
</button>
))}
</div>
</div>
)}
{/* Step 2: Schulform */}
{step === 2 && (
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2 text-center">
{steps[2].title}
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6 text-center">
{steps[2].subtitle}
</p>
<div className="grid grid-cols-2 gap-4">
{SCHULFORMEN.map((sf) => (
<button
key={sf.id}
onClick={() => setSchulform(sf.id)}
className={`p-6 rounded-xl border-2 transition-all hover:scale-105 ${
schulform === sf.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 shadow-lg'
: 'border-gray-200 dark:border-gray-700 hover:border-blue-300'
}`}
>
<span className="text-3xl">{sf.icon}</span>
<p className="mt-3 text-lg font-medium text-gray-900 dark:text-white">
{sf.name}
</p>
</button>
))}
</div>
</div>
)}
{/* Step 3: Complete */}
{step === 3 && (
<div className="text-center py-8">
<div className="w-24 h-24 mx-auto mb-6 bg-gradient-to-br from-green-400 to-emerald-600 rounded-full flex items-center justify-center">
<span className="text-5xl"></span>
</div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-3">
{steps[3].title}
</h2>
<p className="text-lg text-gray-600 dark:text-gray-400 mb-6">
{steps[3].subtitle}
</p>
<div className="bg-gray-50 dark:bg-gray-900 rounded-xl p-6 text-left max-w-md mx-auto">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
Ihre Einstellungen:
</p>
<div className="space-y-2">
<div className="flex items-center gap-3">
<span className="text-xl">
{BUNDESLAENDER.find(b => b.code === bundesland)?.emoji}
</span>
<span className="font-medium text-gray-900 dark:text-white">
{BUNDESLAENDER.find(b => b.code === bundesland)?.name}
</span>
</div>
<div className="flex items-center gap-3">
<span className="text-xl">
{SCHULFORMEN.find(s => s.id === schulform)?.icon}
</span>
<span className="font-medium text-gray-900 dark:text-white">
{SCHULFORMEN.find(s => s.id === schulform)?.name}
</span>
</div>
</div>
</div>
</div>
)}
</div>
{/* Navigation */}
<div className="px-8 py-6 bg-gray-50 dark:bg-gray-900 flex justify-between">
<button
onClick={() => step > 0 && setStep(step - 1)}
className={`px-6 py-2 text-gray-600 dark:text-gray-400 ${step === 0 ? 'invisible' : ''}`}
>
Zurück
</button>
<button
onClick={() => {
if (step < 3) {
setStep(step + 1)
} else {
onComplete({
bundesland,
schulform,
hasSeenWizard: true,
})
}
}}
disabled={step === 1 && !bundesland || step === 2 && !schulform}
className="px-8 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-medium rounded-xl shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{step === 3 ? 'Loslegen' : 'Weiter'}
</button>
</div>
</div>
</div>
)
}
// ============================================================================
// CHAT INTERFACE COMPONENT
// ============================================================================
function ChatInterface({ messages, onSendMessage, isLoading, bundesland }: {
messages: Message[]
onSendMessage: (message: string) => void
isLoading: boolean
bundesland: string | null
}) {
const [input, setInput] = useState('')
const messagesEndRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (input.trim() && !isLoading) {
onSendMessage(input.trim())
setInput('')
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit(e)
}
}
return (
<div className="flex flex-col h-full">
{/* Messages */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{messages.length === 0 ? (
<div className="text-center py-12">
<div className="w-20 h-20 mx-auto mb-6 bg-gradient-to-br from-blue-100 to-purple-100 dark:from-blue-900/30 dark:to-purple-900/30 rounded-full flex items-center justify-center">
<span className="text-4xl">💬</span>
</div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-3">
Stellen Sie eine Frage
</h3>
<p className="text-gray-500 dark:text-gray-400 mb-6 max-w-md mx-auto">
Der Zeugnis-Assistent beantwortet Ihre Fragen basierend auf den
offiziellen Verordnungen {bundesland ? `für ${BUNDESLAENDER.find(b => b.code === bundesland)?.name}` : ''}.
</p>
<div className="flex flex-wrap justify-center gap-2">
{COMMON_QUESTIONS.slice(0, 3).map((q, i) => (
<button
key={i}
onClick={() => onSendMessage(q)}
className="px-4 py-2 text-sm bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700 transition"
>
{q}
</button>
))}
</div>
</div>
) : (
messages.map((message) => (
<div
key={message.id}
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] rounded-2xl p-4 ${
message.role === 'user'
? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-800 shadow-lg border border-gray-200 dark:border-gray-700'
}`}
>
<p className={`${message.role === 'user' ? 'text-white' : 'text-gray-900 dark:text-white'}`}>
{message.content}
</p>
{message.sources && message.sources.length > 0 && (
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
Quellen:
</p>
<div className="space-y-2">
{message.sources.map((source) => (
<a
key={source.id}
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="block p-2 bg-gray-50 dark:bg-gray-900 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition"
>
<p className="text-sm font-medium text-blue-600 dark:text-blue-400">
{source.title}
</p>
<p className="text-xs text-gray-500">
{source.bundesland_name} {source.doc_type}
</p>
</a>
))}
</div>
</div>
)}
</div>
</div>
))
)}
{isLoading && (
<div className="flex justify-start">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" />
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }} />
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }} />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="p-4 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<form onSubmit={handleSubmit} className="flex gap-3">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Stellen Sie Ihre Frage..."
rows={1}
className="flex-1 px-4 py-3 bg-gray-100 dark:bg-gray-900 border-0 rounded-xl resize-none focus:ring-2 focus:ring-blue-500 text-gray-900 dark:text-white placeholder-gray-500"
/>
<button
type="submit"
disabled={!input.trim() || isLoading}
className="px-6 py-3 bg-blue-600 text-white rounded-xl hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
</button>
</form>
</div>
</div>
)
}
// ============================================================================
// SEARCH RESULTS COMPONENT
// ============================================================================
function SearchResults({ results, onSelect }: {
results: SearchResult[]
onSelect: (result: SearchResult) => void
}) {
if (results.length === 0) {
return (
<div className="text-center py-12">
<span className="text-4xl">🔍</span>
<p className="mt-4 text-gray-500 dark:text-gray-400">
Keine Ergebnisse gefunden
</p>
</div>
)
}
return (
<div className="space-y-4">
{results.map((result) => (
<button
key={result.id}
onClick={() => onSelect(result)}
className="w-full text-left p-4 bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 hover:shadow-md hover:border-blue-300 transition"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium text-gray-900 dark:text-white mb-1">
{result.title}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
{result.bundesland_name} {result.doc_type}
</p>
<p className="text-sm text-gray-600 dark:text-gray-300 line-clamp-2">
{result.snippet}
</p>
</div>
<div className="ml-4 flex items-center gap-2">
<span className={`px-2 py-1 text-xs rounded-full ${
result.relevance_score > 0.8
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: result.relevance_score > 0.5
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
}`}>
{Math.round(result.relevance_score * 100)}%
</span>
</div>
</div>
</button>
))}
</div>
)
}
// ============================================================================
// MAIN PAGE
// ============================================================================
import { useState, useEffect } from 'react'
import { UserPreferences, SearchResult, Message, BUNDESLAENDER, SCHULFORMEN, COMMON_QUESTIONS } from './_components/types'
import OnboardingWizard from './_components/OnboardingWizard'
import ChatInterface from './_components/ChatInterface'
import SearchResults from './_components/SearchResults'
export default function ZeugnissePage() {
const [preferences, setPreferences] = useState<UserPreferences>({
bundesland: null,
schulform: null,
hasSeenWizard: false,
favorites: [],
recentSearches: [],
bundesland: null, schulform: null, hasSeenWizard: false, favorites: [], recentSearches: [],
})
const [showWizard, setShowWizard] = useState(true)
const [activeTab, setActiveTab] = useState<'chat' | 'search' | 'documents'>('chat')
@@ -505,59 +17,30 @@ export default function ZeugnissePage() {
const [messages, setMessages] = useState<Message[]>([])
const [isLoading, setIsLoading] = useState(false)
// Load preferences from localStorage
useEffect(() => {
const saved = localStorage.getItem('zeugnis-preferences')
if (saved) {
const parsed = JSON.parse(saved)
setPreferences(parsed)
setShowWizard(!parsed.hasSeenWizard)
}
if (saved) { const parsed = JSON.parse(saved); setPreferences(parsed); setShowWizard(!parsed.hasSeenWizard) }
}, [])
// Save preferences
const savePreferences = (newPrefs: Partial<UserPreferences>) => {
const updated = { ...preferences, ...newPrefs }
setPreferences(updated)
localStorage.setItem('zeugnis-preferences', JSON.stringify(updated))
}
// Handle wizard completion
const handleWizardComplete = (prefs: Partial<UserPreferences>) => {
savePreferences(prefs)
setShowWizard(false)
savePreferences(prefs); setShowWizard(false)
}
// Handle chat message
const handleSendMessage = async (content: string) => {
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content,
timestamp: new Date(),
}
const userMessage: Message = { id: Date.now().toString(), role: 'user', content, timestamp: new Date() }
setMessages(prev => [...prev, userMessage])
setIsLoading(true)
// Simulate API call
setTimeout(() => {
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: `Basierend auf der Zeugnisverordnung ${preferences.bundesland ? `für ${BUNDESLAENDER.find(b => b.code === preferences.bundesland)?.name}` : ''} kann ich Ihnen folgende Auskunft geben:\n\n${content.includes('Bemerkung') ? 'Bemerkungen zum Arbeits- und Sozialverhalten sollten wertschätzend formuliert werden und konkrete Beobachtungen enthalten. Sie müssen den Vorgaben der jeweiligen Schulordnung entsprechen.' : content.includes('Noten') ? 'Im Zeugnis werden die Noten gemäß der geltenden Notenverordnung eingetragen. Die Notenskala reicht von 1 (sehr gut) bis 6 (ungenügend).' : 'Ich habe in den Verordnungen relevante Informationen zu Ihrer Frage gefunden. Bitte beachten Sie die verlinkten Quellen für weitere Details.'}`,
sources: [
{
id: '1',
title: 'Zeugnisverordnung',
bundesland: preferences.bundesland || 'ni',
bundesland_name: BUNDESLAENDER.find(b => b.code === (preferences.bundesland || 'ni'))?.name || 'Niedersachsen',
doc_type: 'Verordnung',
snippet: 'Relevanter Auszug aus der Verordnung...',
relevance_score: 0.92,
url: '#',
last_updated: '2024-01-15',
},
],
id: (Date.now() + 1).toString(), role: 'assistant',
content: `Basierend auf der Zeugnisverordnung ${preferences.bundesland ? `fuer ${BUNDESLAENDER.find(b => b.code === preferences.bundesland)?.name}` : ''} kann ich Ihnen folgende Auskunft geben:\n\n${content.includes('Bemerkung') ? 'Bemerkungen zum Arbeits- und Sozialverhalten sollten wertschaetzend formuliert werden und konkrete Beobachtungen enthalten. Sie muessen den Vorgaben der jeweiligen Schulordnung entsprechen.' : content.includes('Noten') ? 'Im Zeugnis werden die Noten gemaess der geltenden Notenverordnung eingetragen. Die Notenskala reicht von 1 (sehr gut) bis 6 (ungenuegend).' : 'Ich habe in den Verordnungen relevante Informationen zu Ihrer Frage gefunden. Bitte beachten Sie die verlinkten Quellen fuer weitere Details.'}`,
sources: [{ id: '1', title: 'Zeugnisverordnung', bundesland: preferences.bundesland || 'ni', bundesland_name: BUNDESLAENDER.find(b => b.code === (preferences.bundesland || 'ni'))?.name || 'Niedersachsen', doc_type: 'Verordnung', snippet: 'Relevanter Auszug aus der Verordnung...', relevance_score: 0.92, url: '#', last_updated: '2024-01-15' }],
timestamp: new Date(),
}
setMessages(prev => [...prev, assistantMessage])
@@ -565,207 +48,72 @@ export default function ZeugnissePage() {
}, 1500)
}
// Handle search
const handleSearch = (query: string) => {
setSearchQuery(query)
if (!query.trim()) {
setSearchResults([])
return
}
// Simulate search results
if (!query.trim()) { setSearchResults([]); return }
setSearchResults([
{
id: '1',
title: 'Zeugnisverordnung - Bemerkungen',
bundesland: preferences.bundesland || 'ni',
bundesland_name: BUNDESLAENDER.find(b => b.code === (preferences.bundesland || 'ni'))?.name || 'Niedersachsen',
doc_type: 'Verordnung',
snippet: 'Die Bemerkungen im Zeugnis sollen das Arbeits- und Sozialverhalten beschreiben...',
relevance_score: 0.95,
url: '#',
last_updated: '2024-01-15',
},
{
id: '2',
title: 'Ergänzende Bestimmungen für Zeugnisse',
bundesland: preferences.bundesland || 'ni',
bundesland_name: BUNDESLAENDER.find(b => b.code === (preferences.bundesland || 'ni'))?.name || 'Niedersachsen',
doc_type: 'Erlass',
snippet: 'Für die Erstellung von Zeugnissen gelten folgende ergänzende Bestimmungen...',
relevance_score: 0.87,
url: '#',
last_updated: '2024-02-20',
},
{ id: '1', title: 'Zeugnisverordnung - Bemerkungen', bundesland: preferences.bundesland || 'ni', bundesland_name: BUNDESLAENDER.find(b => b.code === (preferences.bundesland || 'ni'))?.name || 'Niedersachsen', doc_type: 'Verordnung', snippet: 'Die Bemerkungen im Zeugnis sollen das Arbeits- und Sozialverhalten beschreiben...', relevance_score: 0.95, url: '#', last_updated: '2024-01-15' },
{ id: '2', title: 'Ergaenzende Bestimmungen fuer Zeugnisse', bundesland: preferences.bundesland || 'ni', bundesland_name: BUNDESLAENDER.find(b => b.code === (preferences.bundesland || 'ni'))?.name || 'Niedersachsen', doc_type: 'Erlass', snippet: 'Fuer die Erstellung von Zeugnissen gelten folgende ergaenzende Bestimmungen...', relevance_score: 0.87, url: '#', last_updated: '2024-02-20' },
])
// Save to recent searches
savePreferences({
recentSearches: [query, ...preferences.recentSearches.filter(s => s !== query).slice(0, 9)],
})
savePreferences({ recentSearches: [query, ...preferences.recentSearches.filter(s => s !== query).slice(0, 9)] })
}
if (showWizard) {
return <OnboardingWizard onComplete={handleWizardComplete} />
}
if (showWizard) return <OnboardingWizard onComplete={handleWizardComplete} />
return (
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
{/* Header */}
<header className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center">
<span className="text-xl">📋</span>
</div>
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center"><span className="text-xl">📋</span></div>
<div>
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
Zeugnis-Assistent
</h1>
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Zeugnis-Assistent</h1>
<p className="text-sm text-gray-500 dark:text-gray-400">
{BUNDESLAENDER.find(b => b.code === preferences.bundesland)?.name || 'Alle Bundesländer'}
{preferences.schulform && ` ${SCHULFORMEN.find(s => s.id === preferences.schulform)?.name}`}
{BUNDESLAENDER.find(b => b.code === preferences.bundesland)?.name || 'Alle Bundeslaender'}
{preferences.schulform && ` - ${SCHULFORMEN.find(s => s.id === preferences.schulform)?.name}`}
</p>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowWizard(true)}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition"
title="Einstellungen"
>
<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>
</button>
</div>
<button onClick={() => setShowWizard(true)} className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition" title="Einstellungen">
<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>
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 mt-4 -mb-px">
{[
{ id: 'chat' as const, label: 'Assistent', icon: '💬' },
{ id: 'search' as const, label: 'Suche', icon: '🔍' },
{ id: 'documents' as const, label: 'Dokumente', icon: '📄' },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-6 py-3 text-sm font-medium rounded-t-lg transition ${
activeTab === tab.id
? 'bg-gray-100 dark:bg-gray-700 text-blue-600 dark:text-blue-400 border-b-2 border-blue-600'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
}`}
>
<span className="mr-2">{tab.icon}</span>
{tab.label}
{([{ id: 'chat' as const, label: 'Assistent', icon: '💬' }, { id: 'search' as const, label: 'Suche', icon: '🔍' }, { id: 'documents' as const, label: 'Dokumente', icon: '📄' }]).map((tab) => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)} className={`px-6 py-3 text-sm font-medium rounded-t-lg transition ${activeTab === tab.id ? 'bg-gray-100 dark:bg-gray-700 text-blue-600 dark:text-blue-400 border-b-2 border-blue-600' : 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'}`}>
<span className="mr-2">{tab.icon}</span>{tab.label}
</button>
))}
</div>
</div>
</header>
{/* Content */}
<main className="max-w-7xl mx-auto p-6">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 overflow-hidden min-h-[600px]">
{activeTab === 'chat' && (
<ChatInterface
messages={messages}
onSendMessage={handleSendMessage}
isLoading={isLoading}
bundesland={preferences.bundesland}
/>
)}
{activeTab === 'chat' && <ChatInterface messages={messages} onSendMessage={handleSendMessage} isLoading={isLoading} bundesland={preferences.bundesland} />}
{activeTab === 'search' && (
<div className="p-6">
{/* Search Input */}
<div className="relative mb-6">
<input
type="text"
value={searchQuery}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Suchen Sie in den Verordnungen..."
className="w-full px-12 py-4 bg-gray-100 dark:bg-gray-900 border-0 rounded-xl text-lg focus:ring-2 focus:ring-blue-500"
/>
<svg
className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-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>
<input type="text" value={searchQuery} onChange={(e) => handleSearch(e.target.value)} placeholder="Suchen Sie in den Verordnungen..." className="w-full px-12 py-4 bg-gray-100 dark:bg-gray-900 border-0 rounded-xl text-lg focus:ring-2 focus:ring-blue-500" />
<svg className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-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>
{/* Recent Searches */}
{!searchQuery && preferences.recentSearches.length > 0 && (
<div className="mb-6">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-3">
Letzte Suchen
</h3>
<div className="flex flex-wrap gap-2">
{preferences.recentSearches.map((search, i) => (
<button
key={i}
onClick={() => handleSearch(search)}
className="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition"
>
{search}
</button>
))}
</div>
</div>
<div className="mb-6"><h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-3">Letzte Suchen</h3><div className="flex flex-wrap gap-2">{preferences.recentSearches.map((search, i) => (<button key={i} onClick={() => handleSearch(search)} className="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition">{search}</button>))}</div></div>
)}
{/* Common Questions */}
{!searchQuery && (
<div className="mb-6">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-3">
Häufige Fragen
</h3>
<div className="grid grid-cols-2 gap-3">
{COMMON_QUESTIONS.map((q, i) => (
<button
key={i}
onClick={() => handleSearch(q)}
className="p-4 text-left bg-gray-50 dark:bg-gray-900 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 transition"
>
<p className="text-sm text-gray-700 dark:text-gray-300">{q}</p>
</button>
))}
</div>
</div>
)}
{/* Results */}
{searchQuery && (
<SearchResults
results={searchResults}
onSelect={(result) => {
handleSendMessage(`Erkläre mir: ${result.title}`)
setActiveTab('chat')
}}
/>
<div className="mb-6"><h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-3">Haeufige Fragen</h3><div className="grid grid-cols-2 gap-3">{COMMON_QUESTIONS.map((q, i) => (<button key={i} onClick={() => handleSearch(q)} className="p-4 text-left bg-gray-50 dark:bg-gray-900 rounded-xl hover:bg-gray-100 dark:hover:bg-gray-800 transition"><p className="text-sm text-gray-700 dark:text-gray-300">{q}</p></button>))}</div></div>
)}
{searchQuery && <SearchResults results={searchResults} onSelect={(result) => { handleSendMessage(`Erklaere mir: ${result.title}`); setActiveTab('chat') }} />}
</div>
)}
{activeTab === 'documents' && (
<div className="p-6">
<div className="text-center py-12">
<span className="text-6xl">📚</span>
<h3 className="mt-4 text-xl font-semibold text-gray-900 dark:text-white">
Dokumente-Browser
</h3>
<p className="mt-2 text-gray-500 dark:text-gray-400 max-w-md mx-auto">
Durchsuchen Sie alle verfügbaren Verordnungen und Handreichungen für Ihr Bundesland.
</p>
<button className="mt-6 px-6 py-3 bg-blue-600 text-white font-medium rounded-xl hover:bg-blue-700 transition">
Dokumente durchsuchen
</button>
<h3 className="mt-4 text-xl font-semibold text-gray-900 dark:text-white">Dokumente-Browser</h3>
<p className="mt-2 text-gray-500 dark:text-gray-400 max-w-md mx-auto">Durchsuchen Sie alle verfuegbaren Verordnungen und Handreichungen fuer Ihr Bundesland.</p>
<button className="mt-6 px-6 py-3 bg-blue-600 text-white font-medium rounded-xl hover:bg-blue-700 transition">Dokumente durchsuchen</button>
</div>
</div>
)}