[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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">"{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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 "AI Risikobewertung" um eine Analyse zu starten.</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
71
website/app/admin/compliance/modules/_components/types.ts
Normal file
71
website/app/admin/compliance/modules/_components/types.ts
Normal 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' },
|
||||
};
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 "AI Risikobewertung" 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>
|
||||
)}
|
||||
|
||||
162
website/app/admin/compliance/scraper/_components/ScraperTabs.tsx
Normal file
162
website/app/admin/compliance/scraper/_components/ScraperTabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
65
website/app/admin/compliance/scraper/_components/types.ts
Normal file
65
website/app/admin/compliance/scraper/_components/types.ts
Normal 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: '📋' },
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
184
website/app/admin/content/_components/ContentEditorTabs.tsx
Normal file
184
website/app/admin/content/_components/ContentEditorTabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
website/app/admin/content/_components/HeroEditor.tsx
Normal file
106
website/app/admin/content/_components/HeroEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
website/app/admin/content/_components/LivePreviewPanel.tsx
Normal file
74
website/app/admin/content/_components/LivePreviewPanel.tsx
Normal 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§ion=${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>
|
||||
)
|
||||
}
|
||||
11
website/app/admin/content/_components/types.ts
Normal file
11
website/app/admin/content/_components/types.ts
Normal 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'
|
||||
173
website/app/admin/content/_components/useContentEditor.ts
Normal file
173
website/app/admin/content/_components/useContentEditor.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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§ion=${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>
|
||||
|
||||
98
website/app/admin/screen-flow/_components/flow-helpers.ts
Normal file
98
website/app/admin/screen-flow/_components/flow-helpers.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
173
website/app/admin/screen-flow/_components/screen-data.ts
Normal file
173
website/app/admin/screen-flow/_components/screen-data.ts
Normal 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',
|
||||
}
|
||||
16
website/app/admin/screen-flow/_components/types.ts
Normal file
16
website/app/admin/screen-flow/_components/types.ts
Normal 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'
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
96
website/app/zeugnisse/_components/ChatInterface.tsx
Normal file
96
website/app/zeugnisse/_components/ChatInterface.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
87
website/app/zeugnisse/_components/OnboardingWizard.tsx
Normal file
87
website/app/zeugnisse/_components/OnboardingWizard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
website/app/zeugnisse/_components/SearchResults.tsx
Normal file
48
website/app/zeugnisse/_components/SearchResults.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
website/app/zeugnisse/_components/types.ts
Normal file
65
website/app/zeugnisse/_components/types.ts
Normal 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?',
|
||||
]
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user