fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
396
admin-v2/app/(admin)/ai/gpu/page.tsx
Normal file
396
admin-v2/app/(admin)/ai/gpu/page.tsx
Normal file
@@ -0,0 +1,396 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* GPU Infrastructure Admin Page
|
||||
*
|
||||
* vast.ai GPU Management for LLM Processing
|
||||
* Part of KI-Werkzeuge
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { AIToolsSidebarResponsive } from '@/components/ai/AIToolsSidebar'
|
||||
|
||||
interface VastStatus {
|
||||
instance_id: number | null
|
||||
status: string
|
||||
gpu_name: string | null
|
||||
dph_total: number | null
|
||||
endpoint_base_url: string | null
|
||||
last_activity: string | null
|
||||
auto_shutdown_in_minutes: number | null
|
||||
total_runtime_hours: number | null
|
||||
total_cost_usd: number | null
|
||||
account_credit: number | null
|
||||
account_total_spend: number | null
|
||||
session_runtime_minutes: number | null
|
||||
session_cost_usd: number | null
|
||||
message: string | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
export default function GPUInfrastructurePage() {
|
||||
const [status, setStatus] = useState<VastStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
const API_PROXY = '/api/admin/gpu'
|
||||
|
||||
const fetchStatus = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(API_PROXY)
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
setStatus(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
|
||||
setStatus({
|
||||
instance_id: null,
|
||||
status: 'error',
|
||||
gpu_name: null,
|
||||
dph_total: null,
|
||||
endpoint_base_url: null,
|
||||
last_activity: null,
|
||||
auto_shutdown_in_minutes: null,
|
||||
total_runtime_hours: null,
|
||||
total_cost_usd: null,
|
||||
account_credit: null,
|
||||
account_total_spend: null,
|
||||
session_runtime_minutes: null,
|
||||
session_cost_usd: null,
|
||||
message: 'Verbindung fehlgeschlagen'
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
}, [fetchStatus])
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(fetchStatus, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [fetchStatus])
|
||||
|
||||
const powerOn = async () => {
|
||||
setActionLoading('on')
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(API_PROXY, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'on' }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || data.detail || 'Aktion fehlgeschlagen')
|
||||
}
|
||||
|
||||
setMessage('Start angefordert')
|
||||
setTimeout(fetchStatus, 3000)
|
||||
setTimeout(fetchStatus, 10000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Starten')
|
||||
fetchStatus()
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const powerOff = async () => {
|
||||
setActionLoading('off')
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const response = await fetch(API_PROXY, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'off' }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || data.detail || 'Aktion fehlgeschlagen')
|
||||
}
|
||||
|
||||
setMessage('Stop angefordert')
|
||||
setTimeout(fetchStatus, 3000)
|
||||
setTimeout(fetchStatus, 10000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler beim Stoppen')
|
||||
fetchStatus()
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusBadge = (s: string) => {
|
||||
const baseClasses = 'px-3 py-1 rounded-full text-sm font-semibold uppercase'
|
||||
switch (s) {
|
||||
case 'running':
|
||||
return `${baseClasses} bg-green-100 text-green-800`
|
||||
case 'stopped':
|
||||
case 'exited':
|
||||
return `${baseClasses} bg-red-100 text-red-800`
|
||||
case 'loading':
|
||||
case 'scheduling':
|
||||
case 'creating':
|
||||
case 'starting...':
|
||||
case 'stopping...':
|
||||
return `${baseClasses} bg-yellow-100 text-yellow-800`
|
||||
default:
|
||||
return `${baseClasses} bg-slate-100 text-slate-600`
|
||||
}
|
||||
}
|
||||
|
||||
const getCreditColor = (credit: number | null) => {
|
||||
if (credit === null) return 'text-slate-500'
|
||||
if (credit < 5) return 'text-red-600'
|
||||
if (credit < 15) return 'text-yellow-600'
|
||||
return 'text-green-600'
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title="GPU Infrastruktur"
|
||||
purpose="Verwalten Sie die vast.ai GPU-Instanzen fuer LLM-Verarbeitung und OCR. Starten/Stoppen Sie GPUs bei Bedarf und ueberwachen Sie Kosten in Echtzeit."
|
||||
audience={['DevOps', 'Entwickler', 'System-Admins']}
|
||||
architecture={{
|
||||
services: ['vast.ai API', 'Ollama', 'VLLM'],
|
||||
databases: ['PostgreSQL (Logs)'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'LLM Vergleich', href: '/ai/llm-compare', description: 'KI-Provider testen' },
|
||||
{ name: 'Test Quality (BQAS)', href: '/ai/test-quality', description: 'Golden Suite & Tests' },
|
||||
{ name: 'Magic Help', href: '/ai/magic-help', description: 'TrOCR Testing' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* KI-Werkzeuge Sidebar */}
|
||||
<AIToolsSidebarResponsive currentTool="gpu" />
|
||||
|
||||
{/* Status Cards */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-6">
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Status</div>
|
||||
{loading ? (
|
||||
<span className="px-3 py-1 rounded-full text-sm font-semibold bg-slate-100 text-slate-600">
|
||||
Laden...
|
||||
</span>
|
||||
) : (
|
||||
<span className={getStatusBadge(
|
||||
actionLoading === 'on' ? 'starting...' :
|
||||
actionLoading === 'off' ? 'stopping...' :
|
||||
status?.status || 'unknown'
|
||||
)}>
|
||||
{actionLoading === 'on' ? 'starting...' :
|
||||
actionLoading === 'off' ? 'stopping...' :
|
||||
status?.status || 'unbekannt'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">GPU</div>
|
||||
<div className="font-semibold text-slate-900">
|
||||
{status?.gpu_name || '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Kosten/h</div>
|
||||
<div className="font-semibold text-slate-900">
|
||||
{status?.dph_total ? `$${status.dph_total.toFixed(3)}` : '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Auto-Stop</div>
|
||||
<div className="font-semibold text-slate-900">
|
||||
{status && status.auto_shutdown_in_minutes !== null
|
||||
? `${status.auto_shutdown_in_minutes} min`
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Budget</div>
|
||||
<div className={`font-bold text-lg ${getCreditColor(status?.account_credit ?? null)}`}>
|
||||
{status && status.account_credit !== null
|
||||
? `$${status.account_credit.toFixed(2)}`
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-slate-500 mb-2">Session</div>
|
||||
<div className="font-semibold text-slate-900">
|
||||
{status && status.session_runtime_minutes !== null && status.session_cost_usd !== null
|
||||
? `${Math.round(status.session_runtime_minutes)} min / $${status.session_cost_usd.toFixed(3)}`
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex items-center gap-4 mt-6 pt-6 border-t border-slate-200">
|
||||
<button
|
||||
onClick={powerOn}
|
||||
disabled={actionLoading !== null || status?.status === 'running'}
|
||||
className="px-6 py-2 bg-orange-600 text-white rounded-lg font-medium hover:bg-orange-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Starten
|
||||
</button>
|
||||
<button
|
||||
onClick={powerOff}
|
||||
disabled={actionLoading !== null || status?.status !== 'running'}
|
||||
className="px-6 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Stoppen
|
||||
</button>
|
||||
<button
|
||||
onClick={fetchStatus}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 border border-slate-300 text-slate-700 rounded-lg font-medium hover:bg-slate-50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading ? 'Aktualisiere...' : 'Aktualisieren'}
|
||||
</button>
|
||||
|
||||
{message && (
|
||||
<span className="ml-4 text-sm text-green-600 font-medium">{message}</span>
|
||||
)}
|
||||
{error && (
|
||||
<span className="ml-4 text-sm text-red-600 font-medium">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extended Stats */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Kosten-Uebersicht</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Session Laufzeit</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.session_runtime_minutes !== null
|
||||
? `${Math.round(status.session_runtime_minutes)} Minuten`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Session Kosten</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.session_cost_usd !== null
|
||||
? `$${status.session_cost_usd.toFixed(4)}`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center pt-4 border-t border-slate-100">
|
||||
<span className="text-slate-600">Gesamtlaufzeit</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.total_runtime_hours !== null
|
||||
? `${status.total_runtime_hours.toFixed(1)} Stunden`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Gesamtkosten</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.total_cost_usd !== null
|
||||
? `$${status.total_cost_usd.toFixed(2)}`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">vast.ai Ausgaben</span>
|
||||
<span className="font-semibold">
|
||||
{status && status.account_total_spend !== null
|
||||
? `$${status.account_total_spend.toFixed(2)}`
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4">Instanz-Details</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Instanz ID</span>
|
||||
<span className="font-mono text-sm">
|
||||
{status?.instance_id || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">GPU</span>
|
||||
<span className="font-semibold">
|
||||
{status?.gpu_name || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Stundensatz</span>
|
||||
<span className="font-semibold">
|
||||
{status?.dph_total ? `$${status.dph_total.toFixed(4)}/h` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Letzte Aktivitaet</span>
|
||||
<span className="text-sm">
|
||||
{status?.last_activity
|
||||
? new Date(status.last_activity).toLocaleString('de-DE')
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
{status?.endpoint_base_url && status.status === 'running' && (
|
||||
<div className="pt-4 border-t border-slate-100">
|
||||
<div className="text-slate-600 text-sm mb-1">Endpoint</div>
|
||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded block overflow-x-auto">
|
||||
{status.endpoint_base_url}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="bg-violet-50 border border-violet-200 rounded-xl p-4">
|
||||
<div className="flex gap-3">
|
||||
<svg className="w-5 h-5 text-violet-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<div>
|
||||
<h4 className="font-semibold text-violet-900">Auto-Shutdown</h4>
|
||||
<p className="text-sm text-violet-800 mt-1">
|
||||
Die GPU-Instanz wird automatisch gestoppt, wenn sie laengere Zeit inaktiv ist.
|
||||
Der Status wird alle 30 Sekunden automatisch aktualisiert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { AIToolsSidebarResponsive } from '@/components/ai/AIToolsSidebar'
|
||||
|
||||
interface LLMResponse {
|
||||
provider: string
|
||||
@@ -210,21 +211,24 @@ export default function LLMComparePage() {
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title="LLM Vergleich"
|
||||
purpose="Vergleichen Sie Antworten verschiedener KI-Provider (OpenAI, Claude, Self-hosted) fuer Qualitaetssicherung. Optimieren Sie Parameter und System Prompts fuer beste Ergebnisse."
|
||||
purpose="Vergleichen Sie Antworten verschiedener KI-Provider (OpenAI, Claude, Self-hosted) fuer Qualitaetssicherung. Optimieren Sie Parameter und System Prompts fuer beste Ergebnisse. Standalone-Werkzeug ohne direkten Datenfluss zur KI-Pipeline."
|
||||
audience={['Entwickler', 'Data Scientists', 'QA']}
|
||||
architecture={{
|
||||
services: ['llm-gateway (Python)', 'Ollama', 'OpenAI API', 'Claude API'],
|
||||
databases: ['PostgreSQL (History)', 'Qdrant (RAG)'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'RAG Management', href: '/ai/rag', description: 'Training Data verwalten' },
|
||||
{ name: 'GPU Infrastruktur', href: '/infrastructure/gpu', description: 'GPU-Ressourcen' },
|
||||
{ name: 'OCR-Labeling', href: '/ai/ocr-labeling', description: 'Handschrift-Training' },
|
||||
{ name: 'Test Quality (BQAS)', href: '/ai/test-quality', description: 'Golden Suite & Synthetic Tests' },
|
||||
{ name: 'GPU Infrastruktur', href: '/ai/gpu', description: 'GPU-Ressourcen verwalten' },
|
||||
{ name: 'Agent Management', href: '/ai/agents', description: 'Multi-Agent System' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* KI-Werkzeuge Sidebar */}
|
||||
<AIToolsSidebarResponsive currentTool="llm-compare" />
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column: Input & Settings */}
|
||||
<div className="lg:col-span-1 space-y-4">
|
||||
|
||||
1604
admin-v2/app/(admin)/ai/magic-help/page.tsx
Normal file
1604
admin-v2/app/(admin)/ai/magic-help/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1412
admin-v2/app/(admin)/ai/ocr-compare/page.tsx
Normal file
1412
admin-v2/app/(admin)/ai/ocr-compare/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
987
admin-v2/app/(admin)/ai/ocr-labeling/page.tsx
Normal file
987
admin-v2/app/(admin)/ai/ocr-labeling/page.tsx
Normal file
@@ -0,0 +1,987 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* OCR Labeling Admin Page
|
||||
*
|
||||
* Labeling interface for handwriting training data collection.
|
||||
* DSGVO-konform: Alle Verarbeitung lokal auf Mac Mini (Ollama).
|
||||
*
|
||||
* Teil der KI-Daten-Pipeline:
|
||||
* OCR-Labeling → RAG Pipeline → Daten & RAG
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { AIModuleSidebarResponsive } from '@/components/ai/AIModuleSidebar'
|
||||
import type {
|
||||
OCRSession,
|
||||
OCRItem,
|
||||
OCRStats,
|
||||
TrainingSample,
|
||||
CreateSessionRequest,
|
||||
OCRModel,
|
||||
} from './types'
|
||||
|
||||
// API Base URL for klausur-service
|
||||
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
// Tab definitions
|
||||
type TabId = 'labeling' | 'sessions' | 'upload' | 'stats' | 'export'
|
||||
|
||||
const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
|
||||
{
|
||||
id: 'labeling',
|
||||
name: 'Labeling',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'sessions',
|
||||
name: 'Sessions',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'upload',
|
||||
name: 'Upload',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'stats',
|
||||
name: 'Statistiken',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'export',
|
||||
name: 'Export',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export default function OCRLabelingPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('labeling')
|
||||
const [sessions, setSessions] = useState<OCRSession[]>([])
|
||||
const [selectedSession, setSelectedSession] = useState<string | null>(null)
|
||||
const [queue, setQueue] = useState<OCRItem[]>([])
|
||||
const [currentItem, setCurrentItem] = useState<OCRItem | null>(null)
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [stats, setStats] = useState<OCRStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [correctedText, setCorrectedText] = useState('')
|
||||
const [labelStartTime, setLabelStartTime] = useState<number | null>(null)
|
||||
|
||||
// Fetch sessions
|
||||
const fetchSessions = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setSessions(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch sessions:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch queue
|
||||
const fetchQueue = useCallback(async () => {
|
||||
try {
|
||||
const url = selectedSession
|
||||
? `${API_BASE}/api/v1/ocr-label/queue?session_id=${selectedSession}&limit=20`
|
||||
: `${API_BASE}/api/v1/ocr-label/queue?limit=20`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setQueue(data)
|
||||
if (data.length > 0 && !currentItem) {
|
||||
setCurrentItem(data[0])
|
||||
setCurrentIndex(0)
|
||||
setCorrectedText(data[0].ocr_text || '')
|
||||
setLabelStartTime(Date.now())
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch queue:', err)
|
||||
}
|
||||
}, [selectedSession, currentItem])
|
||||
|
||||
// Fetch stats
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const url = selectedSession
|
||||
? `${API_BASE}/api/v1/ocr-label/stats?session_id=${selectedSession}`
|
||||
: `${API_BASE}/api/v1/ocr-label/stats`
|
||||
const res = await fetch(url)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStats(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch stats:', err)
|
||||
}
|
||||
}, [selectedSession])
|
||||
|
||||
// Initial data load
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
setLoading(true)
|
||||
await Promise.all([fetchSessions(), fetchQueue(), fetchStats()])
|
||||
setLoading(false)
|
||||
}
|
||||
loadData()
|
||||
}, [fetchSessions, fetchQueue, fetchStats])
|
||||
|
||||
// Refresh queue when session changes
|
||||
useEffect(() => {
|
||||
setCurrentItem(null)
|
||||
setCurrentIndex(0)
|
||||
fetchQueue()
|
||||
fetchStats()
|
||||
}, [selectedSession, fetchQueue, fetchStats])
|
||||
|
||||
// Navigate to next item
|
||||
const goToNext = () => {
|
||||
if (currentIndex < queue.length - 1) {
|
||||
const nextIndex = currentIndex + 1
|
||||
setCurrentIndex(nextIndex)
|
||||
setCurrentItem(queue[nextIndex])
|
||||
setCorrectedText(queue[nextIndex].ocr_text || '')
|
||||
setLabelStartTime(Date.now())
|
||||
} else {
|
||||
// Refresh queue
|
||||
fetchQueue()
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to previous item
|
||||
const goToPrev = () => {
|
||||
if (currentIndex > 0) {
|
||||
const prevIndex = currentIndex - 1
|
||||
setCurrentIndex(prevIndex)
|
||||
setCurrentItem(queue[prevIndex])
|
||||
setCorrectedText(queue[prevIndex].ocr_text || '')
|
||||
setLabelStartTime(Date.now())
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate label time
|
||||
const getLabelTime = (): number | undefined => {
|
||||
if (!labelStartTime) return undefined
|
||||
return Math.round((Date.now() - labelStartTime) / 1000)
|
||||
}
|
||||
|
||||
// Confirm item
|
||||
const confirmItem = async () => {
|
||||
if (!currentItem) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/confirm`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
item_id: currentItem.id,
|
||||
label_time_seconds: getLabelTime(),
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
// Remove from queue and go to next
|
||||
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
|
||||
goToNext()
|
||||
fetchStats()
|
||||
} else {
|
||||
setError('Bestaetigung fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
// Correct item
|
||||
const correctItem = async () => {
|
||||
if (!currentItem || !correctedText.trim()) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/correct`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
item_id: currentItem.id,
|
||||
ground_truth: correctedText.trim(),
|
||||
label_time_seconds: getLabelTime(),
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
|
||||
goToNext()
|
||||
fetchStats()
|
||||
} else {
|
||||
setError('Korrektur fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
// Skip item
|
||||
const skipItem = async () => {
|
||||
if (!currentItem) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/skip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ item_id: currentItem.id }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setQueue(prev => prev.filter(item => item.id !== currentItem.id))
|
||||
goToNext()
|
||||
fetchStats()
|
||||
} else {
|
||||
setError('Ueberspringen fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Only handle if not in text input
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
confirmItem()
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
goToNext()
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
goToPrev()
|
||||
} else if (e.key === 's' && !e.ctrlKey && !e.metaKey) {
|
||||
skipItem()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [currentItem, correctedText])
|
||||
|
||||
// Render Labeling Tab
|
||||
const renderLabelingTab = () => (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left: Image Viewer */}
|
||||
<div className="lg:col-span-2 bg-white rounded-lg shadow p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Bild</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={goToPrev}
|
||||
disabled={currentIndex === 0}
|
||||
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
|
||||
title="Zurueck (Pfeiltaste links)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm text-slate-600">
|
||||
{currentIndex + 1} / {queue.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={goToNext}
|
||||
disabled={currentIndex >= queue.length - 1}
|
||||
className="p-2 rounded hover:bg-slate-100 disabled:opacity-50"
|
||||
title="Weiter (Pfeiltaste rechts)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentItem ? (
|
||||
<div className="relative bg-slate-100 rounded-lg overflow-hidden" style={{ minHeight: '400px' }}>
|
||||
<img
|
||||
src={currentItem.image_url || `${API_BASE}${currentItem.image_path}`}
|
||||
alt="OCR Bild"
|
||||
className="w-full h-auto max-h-[600px] object-contain"
|
||||
onError={(e) => {
|
||||
// Fallback if image fails to load
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-64 bg-slate-100 rounded-lg">
|
||||
<p className="text-slate-500">Keine Bilder in der Warteschlange</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: OCR Text & Actions */}
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="space-y-4">
|
||||
{/* OCR Result */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-lg font-semibold">OCR-Ergebnis</h3>
|
||||
{currentItem?.ocr_confidence && (
|
||||
<span className={`text-sm px-2 py-1 rounded ${
|
||||
currentItem.ocr_confidence > 0.8
|
||||
? 'bg-green-100 text-green-800'
|
||||
: currentItem.ocr_confidence > 0.5
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{Math.round(currentItem.ocr_confidence * 100)}% Konfidenz
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-slate-50 p-3 rounded-lg min-h-[100px] text-sm">
|
||||
{currentItem?.ocr_text || <span className="text-slate-400">Kein OCR-Text</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Correction Input */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Korrektur</h3>
|
||||
<textarea
|
||||
value={correctedText}
|
||||
onChange={(e) => setCorrectedText(e.target.value)}
|
||||
placeholder="Korrigierter Text..."
|
||||
className="w-full h-32 p-3 border border-slate-200 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={confirmItem}
|
||||
disabled={!currentItem}
|
||||
className="w-full px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Korrekt (Enter)
|
||||
</button>
|
||||
<button
|
||||
onClick={correctItem}
|
||||
disabled={!currentItem || !correctedText.trim() || correctedText === currentItem?.ocr_text}
|
||||
className="w-full px-4 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
Korrektur speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={skipItem}
|
||||
disabled={!currentItem}
|
||||
className="w-full px-4 py-2 bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
Ueberspringen (S)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts */}
|
||||
<div className="text-xs text-slate-500 mt-4">
|
||||
<p className="font-medium mb-1">Tastaturkuerzel:</p>
|
||||
<p>Enter = Bestaetigen | S = Ueberspringen</p>
|
||||
<p>Pfeiltasten = Navigation</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom: Queue Preview */}
|
||||
<div className="lg:col-span-3 bg-white rounded-lg shadow p-4">
|
||||
<h3 className="text-lg font-semibold mb-4">Warteschlange ({queue.length} Items)</h3>
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{queue.slice(0, 10).map((item, idx) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setCurrentIndex(idx)
|
||||
setCurrentItem(item)
|
||||
setCorrectedText(item.ocr_text || '')
|
||||
setLabelStartTime(Date.now())
|
||||
}}
|
||||
className={`flex-shrink-0 w-24 h-24 rounded-lg overflow-hidden border-2 ${
|
||||
idx === currentIndex
|
||||
? 'border-primary-500'
|
||||
: 'border-transparent hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={item.image_url || `${API_BASE}${item.image_path}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
{queue.length > 10 && (
|
||||
<div className="flex-shrink-0 w-24 h-24 rounded-lg bg-slate-100 flex items-center justify-center text-slate-500">
|
||||
+{queue.length - 10} mehr
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Render Sessions Tab
|
||||
const renderSessionsTab = () => {
|
||||
const [newSession, setNewSession] = useState<CreateSessionRequest>({
|
||||
name: '',
|
||||
source_type: 'klausur',
|
||||
description: '',
|
||||
ocr_model: 'llama3.2-vision:11b',
|
||||
})
|
||||
|
||||
const createSession = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newSession),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setNewSession({ name: '', source_type: 'klausur', description: '', ocr_model: 'llama3.2-vision:11b' })
|
||||
fetchSessions()
|
||||
} else {
|
||||
setError('Session erstellen fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Create Session */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Neue Session erstellen</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSession.name}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="z.B. Mathe Klausur Q1 2025"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Typ</label>
|
||||
<select
|
||||
value={newSession.source_type}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, source_type: e.target.value as 'klausur' | 'handwriting_sample' | 'scan' }))}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="klausur">Klausur</option>
|
||||
<option value="handwriting_sample">Handschriftprobe</option>
|
||||
<option value="scan">Scan</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">OCR Modell</label>
|
||||
<select
|
||||
value={newSession.ocr_model}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, ocr_model: e.target.value as OCRModel }))}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="llama3.2-vision:11b">llama3.2-vision:11b - Vision LLM (Standard)</option>
|
||||
<option value="trocr">TrOCR - Microsoft Transformer (schnell)</option>
|
||||
<option value="paddleocr">PaddleOCR + LLM (4x schneller)</option>
|
||||
<option value="donut">Donut - Document Understanding (strukturiert)</option>
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{newSession.ocr_model === 'paddleocr' && 'PaddleOCR erkennt Text schnell, LLM strukturiert die Ergebnisse.'}
|
||||
{newSession.ocr_model === 'donut' && 'Speziell fuer Dokumente mit Tabellen und Formularen.'}
|
||||
{newSession.ocr_model === 'trocr' && 'Schnelles Transformer-Modell fuer gedruckten Text.'}
|
||||
{newSession.ocr_model === 'llama3.2-vision:11b' && 'Beste Qualitaet bei Handschrift, aber langsamer.'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSession.description}
|
||||
onChange={(e) => setNewSession(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="Optional..."
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={createSession}
|
||||
disabled={!newSession.name}
|
||||
className="mt-4 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
Session erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sessions List */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-slate-200">
|
||||
<h3 className="text-lg font-semibold">Sessions ({sessions.length})</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-200">
|
||||
{sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`p-4 hover:bg-slate-50 cursor-pointer ${
|
||||
selectedSession === session.id ? 'bg-primary-50 border-l-4 border-primary-500' : ''
|
||||
}`}
|
||||
onClick={() => setSelectedSession(session.id === selectedSession ? null : session.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">{session.name}</h4>
|
||||
<p className="text-sm text-slate-500">
|
||||
{session.source_type} | {session.ocr_model}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">
|
||||
{session.labeled_items}/{session.total_items} gelabelt
|
||||
</p>
|
||||
<div className="w-32 bg-slate-200 rounded-full h-2 mt-1">
|
||||
<div
|
||||
className="bg-primary-600 rounded-full h-2"
|
||||
style={{
|
||||
width: `${session.total_items > 0 ? (session.labeled_items / session.total_items) * 100 : 0}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{session.description && (
|
||||
<p className="text-sm text-slate-600 mt-2">{session.description}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{sessions.length === 0 && (
|
||||
<p className="p-4 text-slate-500 text-center">Keine Sessions vorhanden</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render Upload Tab
|
||||
const renderUploadTab = () => {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadResults, setUploadResults] = useState<any[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleUpload = async (files: FileList) => {
|
||||
if (!selectedSession) {
|
||||
setError('Bitte zuerst eine Session auswaehlen')
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
Array.from(files).forEach(file => formData.append('files', file))
|
||||
formData.append('run_ocr', 'true')
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/sessions/${selectedSession}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setUploadResults(data.items || [])
|
||||
fetchQueue()
|
||||
fetchStats()
|
||||
} else {
|
||||
setError('Upload fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler beim Upload')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Session Selection */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Session auswaehlen</h3>
|
||||
<select
|
||||
value={selectedSession || ''}
|
||||
onChange={(e) => setSelectedSession(e.target.value || null)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">-- Session waehlen --</option>
|
||||
{sessions.map((session) => (
|
||||
<option key={session.id} value={session.id}>
|
||||
{session.name} ({session.total_items} Items)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Bilder hochladen</h3>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center ${
|
||||
selectedSession ? 'border-slate-300 hover:border-primary-500' : 'border-slate-200 opacity-50'
|
||||
}`}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
e.currentTarget.classList.add('border-primary-500', 'bg-primary-50')
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
e.currentTarget.classList.remove('border-primary-500', 'bg-primary-50')
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
handleUpload(e.dataTransfer.files)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/png,image/jpeg,image/jpg"
|
||||
onChange={(e) => e.target.files && handleUpload(e.target.files)}
|
||||
className="hidden"
|
||||
disabled={!selectedSession}
|
||||
/>
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
|
||||
<p>Hochladen & OCR ausfuehren...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-12 h-12 text-slate-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<p className="text-slate-600 mb-2">
|
||||
Bilder hierher ziehen oder{' '}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!selectedSession}
|
||||
className="text-primary-600 hover:underline"
|
||||
>
|
||||
auswaehlen
|
||||
</button>
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">PNG, JPG (max. 10MB pro Bild)</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Results */}
|
||||
{uploadResults.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Upload-Ergebnisse ({uploadResults.length})</h3>
|
||||
<div className="space-y-2">
|
||||
{uploadResults.map((result) => (
|
||||
<div key={result.id} className="flex items-center justify-between p-2 bg-slate-50 rounded">
|
||||
<span className="text-sm">{result.filename}</span>
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
result.ocr_text ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{result.ocr_text ? `OCR OK (${Math.round((result.ocr_confidence || 0) * 100)}%)` : 'Kein OCR'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Render Stats Tab
|
||||
const renderStatsTab = () => (
|
||||
<div className="space-y-6">
|
||||
{/* Global Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">Gesamt Items</h4>
|
||||
<p className="text-3xl font-bold mt-2">{stats?.total_items || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">Gelabelt</h4>
|
||||
<p className="text-3xl font-bold mt-2 text-green-600">{stats?.labeled_items || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">Ausstehend</h4>
|
||||
<p className="text-3xl font-bold mt-2 text-yellow-600">{stats?.pending_items || 0}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h4 className="text-sm font-medium text-slate-500">OCR-Genauigkeit</h4>
|
||||
<p className="text-3xl font-bold mt-2">{stats?.accuracy_rate || 0}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Stats */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Details</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Bestaetigt</p>
|
||||
<p className="text-xl font-semibold text-green-600">{stats?.confirmed_items || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Korrigiert</p>
|
||||
<p className="text-xl font-semibold text-primary-600">{stats?.corrected_items || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Exportierbar</p>
|
||||
<p className="text-xl font-semibold">{stats?.exportable_items || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">Durchschn. Label-Zeit</p>
|
||||
<p className="text-xl font-semibold">{stats?.avg_label_time_seconds || 0}s</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{stats?.total_items ? (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Fortschritt</h3>
|
||||
<div className="w-full bg-slate-200 rounded-full h-4">
|
||||
<div
|
||||
className="bg-primary-600 rounded-full h-4 transition-all"
|
||||
style={{ width: `${(stats.labeled_items / stats.total_items) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-2">
|
||||
{Math.round((stats.labeled_items / stats.total_items) * 100)}% abgeschlossen
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Render Export Tab
|
||||
const renderExportTab = () => {
|
||||
const [exportFormat, setExportFormat] = useState<'generic' | 'trocr' | 'llama_vision'>('generic')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [exportResult, setExportResult] = useState<any>(null)
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/ocr-label/export`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
export_format: exportFormat,
|
||||
session_id: selectedSession,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setExportResult(data)
|
||||
} else {
|
||||
setError('Export fehlgeschlagen')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Netzwerkfehler')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Training-Daten exportieren</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Export-Format</label>
|
||||
<select
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value as typeof exportFormat)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="generic">Generic JSON</option>
|
||||
<option value="trocr">TrOCR Fine-Tuning</option>
|
||||
<option value="llama_vision">Llama Vision Fine-Tuning</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Session (optional)</label>
|
||||
<select
|
||||
value={selectedSession || ''}
|
||||
onChange={(e) => setSelectedSession(e.target.value || null)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">Alle Sessions</option>
|
||||
{sessions.map((session) => (
|
||||
<option key={session.id} value={session.id}>{session.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || (stats?.exportable_items || 0) === 0}
|
||||
className="w-full px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{exporting ? 'Exportiere...' : `${stats?.exportable_items || 0} Samples exportieren`}
|
||||
</button>
|
||||
|
||||
{/* Cross-Link to Magic Help for TrOCR Fine-Tuning */}
|
||||
{exportFormat === 'trocr' && (stats?.exportable_items || 0) > 0 && (
|
||||
<Link
|
||||
href="/ai/magic-help?source=ocr-labeling"
|
||||
className="w-full mt-3 px-4 py-2 bg-purple-100 text-purple-700 border border-purple-300 rounded-lg hover:bg-purple-200 flex items-center justify-center gap-2 transition-colors"
|
||||
>
|
||||
<span>✨</span>
|
||||
Mit Magic Help testen & fine-tunen
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{exportResult && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Export-Ergebnis</h3>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-green-800">
|
||||
{exportResult.exported_count} Samples erfolgreich exportiert
|
||||
</p>
|
||||
<p className="text-sm text-green-600">
|
||||
Batch: {exportResult.batch_id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 p-4 rounded-lg overflow-auto max-h-64">
|
||||
<pre className="text-xs">{JSON.stringify(exportResult.samples?.slice(0, 3), null, 2)}</pre>
|
||||
{(exportResult.samples?.length || 0) > 3 && (
|
||||
<p className="text-slate-500 mt-2">... und {exportResult.samples.length - 3} weitere</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">OCR-Labeling</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">Handschrift-Training & Ground Truth Erfassung</p>
|
||||
</div>
|
||||
|
||||
{/* Page Purpose with Related Pages */}
|
||||
<PagePurpose
|
||||
title="OCR-Labeling"
|
||||
purpose="Erstellen Sie Ground Truth Daten für das Training von Handschrift-Erkennungsmodellen. Labeln Sie OCR-Ergebnisse, korrigieren Sie Fehler und exportieren Sie Trainingsdaten für TrOCR, Llama Vision und andere Modelle. Teil der KI-Daten-Pipeline: Gelabelte Daten können zur RAG Pipeline exportiert werden."
|
||||
audience={['Entwickler', 'Data Scientists', 'QA-Team']}
|
||||
architecture={{
|
||||
services: ['klausur-service (Python)'],
|
||||
databases: ['PostgreSQL', 'MinIO (Bilder)'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'Magic Help', href: '/ai/magic-help', description: 'TrOCR testen & fine-tunen' },
|
||||
{ name: 'RAG Pipeline', href: '/ai/rag-pipeline', description: 'Trainierte Daten indexieren' },
|
||||
{ name: 'Klausur-Korrektur', href: '/ai/klausur-korrektur', description: 'OCR in Aktion' },
|
||||
{ name: 'Daten & RAG', href: '/ai/rag', description: 'Indexierte Daten durchsuchen' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* AI Module Sidebar - Desktop: Fixed, Mobile: FAB + Drawer */}
|
||||
<AIModuleSidebarResponsive currentModule="ocr-labeling" />
|
||||
|
||||
{/* Error Toast */}
|
||||
{error && (
|
||||
<div className="fixed top-4 right-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded z-50">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-4">X</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6">
|
||||
<div className="border-b border-slate-200">
|
||||
<nav className="flex space-x-4" aria-label="Tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-3 border-b-2 font-medium text-sm transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'labeling' && renderLabelingTab()}
|
||||
{activeTab === 'sessions' && renderSessionsTab()}
|
||||
{activeTab === 'upload' && renderUploadTab()}
|
||||
{activeTab === 'stats' && renderStatsTab()}
|
||||
{activeTab === 'export' && renderExportTab()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
123
admin-v2/app/(admin)/ai/ocr-labeling/types.ts
Normal file
123
admin-v2/app/(admin)/ai/ocr-labeling/types.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* TypeScript types for OCR Labeling UI
|
||||
*/
|
||||
|
||||
/**
|
||||
* Available OCR Models
|
||||
*
|
||||
* - llama3.2-vision:11b: Vision LLM, beste Qualitaet bei Handschrift (Standard)
|
||||
* - trocr: Microsoft TrOCR, schnell bei gedrucktem Text
|
||||
* - paddleocr: PaddleOCR + LLM, 4x schneller durch Hybrid-Ansatz
|
||||
* - donut: Document Understanding Transformer, strukturierte Dokumente
|
||||
*/
|
||||
export type OCRModel = 'llama3.2-vision:11b' | 'trocr' | 'paddleocr' | 'donut'
|
||||
|
||||
export const OCR_MODEL_INFO: Record<OCRModel, { label: string; description: string; speed: string }> = {
|
||||
'llama3.2-vision:11b': {
|
||||
label: 'Vision LLM',
|
||||
description: 'Beste Qualitaet bei Handschrift',
|
||||
speed: 'langsam',
|
||||
},
|
||||
trocr: {
|
||||
label: 'Microsoft TrOCR',
|
||||
description: 'Schnell bei gedrucktem Text',
|
||||
speed: 'schnell',
|
||||
},
|
||||
paddleocr: {
|
||||
label: 'PaddleOCR + LLM',
|
||||
description: 'Hybrid-Ansatz: OCR + Strukturierung',
|
||||
speed: 'sehr schnell',
|
||||
},
|
||||
donut: {
|
||||
label: 'Donut',
|
||||
description: 'Document Understanding fuer Tabellen/Formulare',
|
||||
speed: 'mittel',
|
||||
},
|
||||
}
|
||||
|
||||
export interface OCRSession {
|
||||
id: string
|
||||
name: string
|
||||
source_type: 'klausur' | 'handwriting_sample' | 'scan'
|
||||
description?: string
|
||||
ocr_model?: OCRModel
|
||||
total_items: number
|
||||
labeled_items: number
|
||||
confirmed_items: number
|
||||
corrected_items: number
|
||||
skipped_items: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface OCRItem {
|
||||
id: string
|
||||
session_id: string
|
||||
session_name: string
|
||||
image_path: string
|
||||
image_url?: string
|
||||
ocr_text?: string
|
||||
ocr_confidence?: number
|
||||
ground_truth?: string
|
||||
status: 'pending' | 'confirmed' | 'corrected' | 'skipped'
|
||||
metadata?: Record<string, unknown>
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface OCRStats {
|
||||
total_sessions?: number
|
||||
session_id?: string
|
||||
name?: string
|
||||
total_items: number
|
||||
labeled_items: number
|
||||
confirmed_items: number
|
||||
corrected_items: number
|
||||
skipped_items?: number
|
||||
pending_items: number
|
||||
exportable_items?: number
|
||||
accuracy_rate: number
|
||||
avg_label_time_seconds?: number
|
||||
progress_percent?: number
|
||||
}
|
||||
|
||||
export interface TrainingSample {
|
||||
id: string
|
||||
image_path: string
|
||||
ground_truth: string
|
||||
export_format: 'generic' | 'trocr' | 'llama_vision'
|
||||
training_batch: string
|
||||
exported_at?: string
|
||||
}
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
name: string
|
||||
source_type: 'klausur' | 'handwriting_sample' | 'scan'
|
||||
description?: string
|
||||
ocr_model?: OCRModel
|
||||
}
|
||||
|
||||
export interface ConfirmRequest {
|
||||
item_id: string
|
||||
label_time_seconds?: number
|
||||
}
|
||||
|
||||
export interface CorrectRequest {
|
||||
item_id: string
|
||||
ground_truth: string
|
||||
label_time_seconds?: number
|
||||
}
|
||||
|
||||
export interface ExportRequest {
|
||||
export_format: 'generic' | 'trocr' | 'llama_vision'
|
||||
session_id?: string
|
||||
batch_id?: string
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
id: string
|
||||
filename: string
|
||||
image_path: string
|
||||
image_hash: string
|
||||
ocr_text?: string
|
||||
ocr_confidence?: number
|
||||
status: string
|
||||
}
|
||||
1443
admin-v2/app/(admin)/ai/rag-pipeline/page.tsx
Normal file
1443
admin-v2/app/(admin)/ai/rag-pipeline/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { AIModuleSidebarResponsive } from '@/components/ai/AIModuleSidebar'
|
||||
|
||||
// API uses local proxy route to klausur-service
|
||||
const API_PROXY = '/api/legal-corpus'
|
||||
@@ -1021,20 +1022,25 @@ export default function RAGPage() {
|
||||
<div className="p-6">
|
||||
{/* Page Purpose */}
|
||||
<PagePurpose
|
||||
title="Legal Corpus RAG"
|
||||
purpose="Das Legal Corpus RAG System indexiert alle 19 relevanten Regulierungen (DSGVO, AI Act, CRA, BSI TR-03161, etc.) fuer semantische Suche waehrend UCCA-Assessments. Die Dokumente werden in Chunks aufgeteilt und mit BGE-M3 Embeddings indexiert."
|
||||
title="Daten & RAG"
|
||||
purpose="Verwalten und durchsuchen Sie indexierte Dokumente im RAG-System. Das Legal Corpus enthält 19+ Regulierungen (DSGVO, AI Act, CRA, BSI TR-03161, etc.) für semantische Suche. Teil der KI-Daten-Pipeline: Empfängt Embeddings von der RAG Pipeline und liefert Suchergebnisse an die Klausur-Korrektur."
|
||||
audience={['DSB', 'Compliance Officer', 'Entwickler']}
|
||||
gdprArticles={['§5 UrhG (Amtliche Werke)', 'Art. 5 DSGVO (Rechenschaftspflicht)']}
|
||||
architecture={{
|
||||
services: ['klausur-service (Python)', 'embedding-service (BGE-M3)', 'Qdrant (Vector DB)'],
|
||||
databases: ['Qdrant Collection: bp_legal_corpus'],
|
||||
databases: ['Qdrant Collections: bp_legal_corpus, bp_nibis_eh, bp_eh'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'RAG Pipeline', href: '/ai/rag-pipeline', description: 'Neue Dokumente indexieren' },
|
||||
{ name: 'Klausur-Korrektur', href: '/ai/klausur-korrektur', description: 'RAG-Suche nutzen' },
|
||||
{ name: 'OCR-Labeling', href: '/ai/ocr-labeling', description: 'Ground Truth erstellen' },
|
||||
{ name: 'Compliance Hub', href: '/compliance/hub', description: 'Compliance-Dashboard' },
|
||||
{ name: 'Requirements', href: '/compliance/requirements', description: 'Anforderungskatalog' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* AI Module Sidebar - Desktop: Fixed, Mobile: FAB + Drawer */}
|
||||
<AIModuleSidebarResponsive currentModule="rag" />
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-xl p-4 border border-slate-200">
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { AIToolsSidebarResponsive } from '@/components/ai/AIToolsSidebar'
|
||||
import type { TestRun, BQASMetrics, TrendData, TabType } from './types'
|
||||
|
||||
// API Configuration - Use internal proxy to avoid CORS issues
|
||||
@@ -1429,14 +1430,17 @@ export default function TestQualityPage() {
|
||||
databases: ['Qdrant', 'PostgreSQL'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'CI/CD Scheduler', href: '/infrastructure/ci-cd', description: 'Automatische Test-Planung' },
|
||||
{ name: 'RAG Management', href: '/ai/rag', description: 'Training Data & RAG Pipelines' },
|
||||
{ name: 'LLM Vergleich', href: '/ai/llm-compare', description: 'Provider-Vergleich' },
|
||||
{ name: 'GPU Infrastruktur', href: '/ai/gpu', description: 'GPU-Ressourcen verwalten' },
|
||||
{ name: 'RAG Management', href: '/ai/rag', description: 'Training Data & RAG Pipelines' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* KI-Werkzeuge Sidebar */}
|
||||
<AIToolsSidebarResponsive currentTool="test-quality" />
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center gap-3">
|
||||
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -99,47 +99,51 @@ export default function ArchitecturePage() {
|
||||
<div className="bg-white rounded-xl shadow-sm border p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Migrations-Checkliste</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 p-3 border border-slate-200 rounded-lg">
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">Grundgeruest Admin v2 erstellt (Layout, Navigation)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-slate-200 rounded-lg">
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">Compliance Hub migriert</span>
|
||||
<span className="text-slate-700">Compliance Hub migriert (DSR, DSMS, VVT, TOM, DSFA, Controls, Evidence, Risks)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-slate-200 rounded-lg">
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">Consent Verwaltung migriert</span>
|
||||
<span className="text-slate-700">Consent Verwaltung migriert (inkl. Einwilligungen)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-slate-200 rounded-lg">
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">Workflow (Versionierung) migriert mit Sync-Scroll</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-yellow-200 bg-yellow-50 rounded-lg">
|
||||
<input type="checkbox" className="w-4 h-4" />
|
||||
<span className="text-slate-700">DSR-Modul migrieren</span>
|
||||
<span className="text-xs text-yellow-600 ml-auto">Prioritaet: Hoch</span>
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">KI-Module migriert (LLM Compare, RAG, AI Quality, Agents)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">Infrastruktur-Module migriert (GPU, Security, SBOM, CI/CD, Middleware)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">Communication-Module migriert (Mail, Alerts, Matrix, Video-Chat)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-green-200 bg-green-50 rounded-lg">
|
||||
<input type="checkbox" checked readOnly className="w-4 h-4 text-green-600" />
|
||||
<span className="text-slate-700">Development-Module migriert (Brandbook, Content, Docs, Game, Unity)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-yellow-200 bg-yellow-50 rounded-lg">
|
||||
<input type="checkbox" className="w-4 h-4" />
|
||||
<span className="text-slate-700">Cookie-Kategorien migrieren</span>
|
||||
<span className="text-slate-700">Klausur-Korrektur migrieren</span>
|
||||
<span className="text-xs text-yellow-600 ml-auto">Bleibt vorerst im alten Admin</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-yellow-200 bg-yellow-50 rounded-lg">
|
||||
<input type="checkbox" className="w-4 h-4" />
|
||||
<span className="text-slate-700">OCR-Labeling migrieren</span>
|
||||
<span className="text-xs text-yellow-600 ml-auto">Prioritaet: Mittel</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-slate-200 rounded-lg">
|
||||
<input type="checkbox" className="w-4 h-4" />
|
||||
<span className="text-slate-700">KI-Module migrieren (LLM Compare, OCR, RAG)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-slate-200 rounded-lg">
|
||||
<input type="checkbox" className="w-4 h-4" />
|
||||
<span className="text-slate-700">Infrastruktur-Module migrieren</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-slate-200 rounded-lg">
|
||||
<input type="checkbox" className="w-4 h-4" />
|
||||
<span className="text-slate-700">Alle Module getestet und deployed</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 border border-slate-200 rounded-lg">
|
||||
<input type="checkbox" className="w-4 h-4" />
|
||||
<span className="text-slate-700">Verwaiste Module identifiziert und dokumentiert</span>
|
||||
<span className="text-slate-700">Verwaiste Module identifiziert (voice, training, multiplayer, pca-platform)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getStoredRole, isCategoryVisibleForRole, RoleId } from '@/lib/roles'
|
||||
import { CategoryCard } from '@/components/common/ModuleCard'
|
||||
import { InfoNote } from '@/components/common/InfoBox'
|
||||
import { ServiceStatus } from '@/components/common/ServiceStatus'
|
||||
import { NightModeWidget } from '@/components/dashboard/NightModeWidget'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface Stats {
|
||||
@@ -111,7 +112,18 @@ export default function DashboardPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Infrastructure & System Status */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Infrastruktur</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
{/* Night Mode Widget */}
|
||||
<NightModeWidget />
|
||||
|
||||
{/* System Status */}
|
||||
<ServiceStatus />
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Aktivitaet</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent DSR */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
|
||||
@@ -127,9 +139,6 @@ export default function DashboardPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Status */}
|
||||
<ServiceStatus />
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
|
||||
769
admin-v2/app/(admin)/development/content/page.tsx
Normal file
769
admin-v2/app/(admin)/development/content/page.tsx
Normal file
@@ -0,0 +1,769 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Admin Panel for Website Content
|
||||
*
|
||||
* Allows editing all website texts:
|
||||
* - Hero Section
|
||||
* - Features
|
||||
* - FAQ
|
||||
* - Pricing
|
||||
* - Trust Indicators
|
||||
* - Testimonial
|
||||
*
|
||||
* Includes Live-Preview of website
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { WebsiteContent, HeroContent, FeatureContent } from '@/lib/content-types'
|
||||
|
||||
// Admin Key (in production via login)
|
||||
const ADMIN_KEY = 'breakpilot-admin-2024'
|
||||
|
||||
// Mapping tabs to website sections
|
||||
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 default function ContentPage() {
|
||||
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)
|
||||
|
||||
// Scroll preview to section
|
||||
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
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Scroll to section on tab change
|
||||
useEffect(() => {
|
||||
scrollToSection(activeTab)
|
||||
}, [activeTab, scrollToSection])
|
||||
|
||||
// Load content
|
||||
useEffect(() => {
|
||||
loadContent()
|
||||
}, [])
|
||||
|
||||
async function loadContent() {
|
||||
try {
|
||||
const res = await fetch('/api/development/content')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setContent(data)
|
||||
} else {
|
||||
setMessage({ type: 'error', text: 'Fehler beim Laden' })
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Fehler beim Laden' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (!content) return
|
||||
|
||||
setSaving(true)
|
||||
setMessage(null)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/development/content', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-admin-key': ADMIN_KEY,
|
||||
},
|
||||
body: JSON.stringify(content),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setMessage({ type: 'success', text: 'Gespeichert!' })
|
||||
} else {
|
||||
const error = await res.json()
|
||||
setMessage({ type: 'error', text: error.error || 'Fehler beim Speichern' })
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Fehler beim Speichern' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Hero Section update
|
||||
function updateHero(field: keyof HeroContent, value: string) {
|
||||
if (!content) return
|
||||
setContent({
|
||||
...content,
|
||||
hero: { ...content.hero, [field]: value },
|
||||
})
|
||||
}
|
||||
|
||||
// Feature update
|
||||
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 update
|
||||
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 })
|
||||
}
|
||||
|
||||
// Add FAQ
|
||||
function addFAQ() {
|
||||
if (!content) return
|
||||
setContent({
|
||||
...content,
|
||||
faq: [...content.faq, { question: 'Neue Frage?', answer: ['Antwort hier...'] }],
|
||||
})
|
||||
}
|
||||
|
||||
// Remove FAQ
|
||||
function removeFAQ(index: number) {
|
||||
if (!content) return
|
||||
const newFAQ = content.faq.filter((_, i) => i !== index)
|
||||
setContent({ ...content, faq: newFAQ })
|
||||
}
|
||||
|
||||
// Pricing update
|
||||
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) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-xl text-slate-600">Laden...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-xl text-red-600">Fehler beim Laden</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Toolbar */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-lg font-semibold text-slate-900">Website Content</h1>
|
||||
{/* Preview Toggle */}
|
||||
<button
|
||||
onClick={() => setShowPreview(!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'
|
||||
}`}
|
||||
title={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" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
Live-Preview
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={saveChanges}
|
||||
disabled={saving}
|
||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? 'Speichern...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6">
|
||||
<div className="flex gap-1 bg-slate-100 p-1 rounded-lg w-fit">
|
||||
{(['hero', 'features', 'faq', 'pricing', 'other'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => 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'
|
||||
}`}
|
||||
>
|
||||
{tab === 'hero' && 'Hero'}
|
||||
{tab === 'features' && 'Features'}
|
||||
{tab === 'faq' && 'FAQ'}
|
||||
{tab === 'pricing' && 'Preise'}
|
||||
{tab === 'other' && 'Sonstige'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Split Layout: Editor + Preview */}
|
||||
<div className={`grid gap-6 ${showPreview ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
{/* Editor Panel */}
|
||||
<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">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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Features Tab */}
|
||||
{activeTab === 'features' && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900">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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FAQ Tab */}
|
||||
{activeTab === 'faq' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-slate-900">FAQ</h2>
|
||||
<button
|
||||
onClick={addFAQ}
|
||||
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
+ Frage hinzufuegen
|
||||
</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">
|
||||
<div className="flex-1 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Frage {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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Antwort
|
||||
</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-blue-500 focus:border-blue-500 font-mono text-sm"
|
||||
/>
|
||||
</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">Preise</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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</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-blue-500 focus:border-blue-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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</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-blue-600 rounded"
|
||||
/>
|
||||
<span className="text-sm text-slate-700">Beliebt</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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Aufgaben
|
||||
</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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</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-blue-500 focus:border-blue-500 font-mono text-sm"
|
||||
/>
|
||||
</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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</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-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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">breakpilot.app</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={`https://macmini:3000/?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"
|
||||
/>
|
||||
{/* Section Indicator */}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
797
admin-v2/app/(admin)/development/screen-flow/page.tsx
Normal file
797
admin-v2/app/(admin)/development/screen-flow/page.tsx
Normal file
@@ -0,0 +1,797 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Screen Flow Visualization
|
||||
*
|
||||
* Visualisiert alle Screens aus:
|
||||
* - Studio (Port 8000): Lehrer-Oberflaeche
|
||||
* - Admin v2 (Port 3002): Admin Panel
|
||||
*/
|
||||
|
||||
import { useCallback, useState, useMemo, useEffect } from 'react'
|
||||
import ReactFlow, {
|
||||
Node,
|
||||
Edge,
|
||||
Controls,
|
||||
Background,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
BackgroundVariant,
|
||||
MarkerType,
|
||||
Panel,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
|
||||
// ============================================
|
||||
// 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: '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' },
|
||||
]
|
||||
|
||||
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 v2 SCREENS (Port 3002)
|
||||
// Based on navigation.ts - Last updated: 2026-02-03
|
||||
// ============================================
|
||||
|
||||
const ADMIN_SCREENS: ScreenDefinition[] = [
|
||||
// === META / OVERVIEW ===
|
||||
{ id: 'admin-dashboard', name: 'Dashboard', description: 'Uebersicht & Statistiken', category: 'overview', icon: '🏠', url: '/dashboard' },
|
||||
{ id: 'admin-onboarding', name: 'Onboarding', description: 'Lern-Wizards fuer alle Module', category: 'overview', icon: '📖', url: '/onboarding' },
|
||||
{ id: 'admin-architecture', name: 'Architektur', description: 'Backend-Module & Datenfluss', category: 'overview', icon: '🏗️', url: '/architecture' },
|
||||
{ id: 'admin-backlog', name: 'Production Backlog', description: 'Go-Live Checkliste', category: 'overview', icon: '📝', url: '/backlog' },
|
||||
{ id: 'admin-rbac', name: 'RBAC', description: 'Rollen & Berechtigungen', category: 'overview', icon: '👥', url: '/rbac' },
|
||||
|
||||
// === DSGVO (Violet #7c3aed) ===
|
||||
{ id: 'admin-consent', name: 'Consent Verwaltung', description: 'Rechtliche Dokumente & Versionen', category: 'dsgvo', icon: '📄', url: '/dsgvo/consent' },
|
||||
{ id: 'admin-dsr', name: 'Datenschutzanfragen', description: 'DSGVO Art. 15-21', category: 'dsgvo', icon: '🔒', url: '/dsgvo/dsr' },
|
||||
{ id: 'admin-einwilligungen', name: 'Einwilligungen', description: 'Nutzer-Consent Uebersicht', category: 'dsgvo', icon: '✅', url: '/dsgvo/einwilligungen' },
|
||||
{ id: 'admin-vvt', name: 'VVT', description: 'Verarbeitungsverzeichnis Art. 30', category: 'dsgvo', icon: '📋', url: '/dsgvo/vvt' },
|
||||
{ id: 'admin-dsfa', name: 'DSFA', description: 'Datenschutz-Folgenabschaetzung', category: 'dsgvo', icon: '⚖️', url: '/dsgvo/dsfa' },
|
||||
{ id: 'admin-tom', name: 'TOMs', description: 'Technische & Org. Massnahmen', category: 'dsgvo', icon: '🛡️', url: '/dsgvo/tom' },
|
||||
{ id: 'admin-loeschfristen', name: 'Loeschfristen', description: 'Aufbewahrung & Deadlines', category: 'dsgvo', icon: '🗑️', url: '/dsgvo/loeschfristen' },
|
||||
{ id: 'admin-advisory-board', name: 'Advisory Board', description: 'KI-Use-Case Pruefung', category: 'dsgvo', icon: '🧑⚖️', url: '/dsgvo/advisory-board' },
|
||||
{ id: 'admin-escalations', name: 'Eskalations-Queue', description: 'DSB Review & Freigabe', category: 'dsgvo', icon: '🚨', url: '/dsgvo/escalations' },
|
||||
|
||||
// === COMPLIANCE (Purple #9333ea) ===
|
||||
{ id: 'admin-compliance-hub', name: 'Compliance Hub', description: 'Zentrales GRC Dashboard', category: 'compliance', icon: '✅', url: '/compliance/hub' },
|
||||
{ id: 'admin-audit-checklist', name: 'Audit Checkliste', description: '476 Anforderungen pruefen', category: 'compliance', icon: '📋', url: '/compliance/audit-checklist' },
|
||||
{ id: 'admin-requirements', name: 'Requirements', description: '558+ aus 19 Verordnungen', category: 'compliance', icon: '📜', url: '/compliance/requirements' },
|
||||
{ id: 'admin-controls', name: 'Controls', description: '474 Control-Mappings', category: 'compliance', icon: '🎛️', url: '/compliance/controls' },
|
||||
{ id: 'admin-evidence', name: 'Evidence', description: 'Nachweise & Dokumentation', category: 'compliance', icon: '📎', url: '/compliance/evidence' },
|
||||
{ id: 'admin-risks', name: 'Risiken', description: 'Risk Matrix & Register', category: 'compliance', icon: '⚠️', url: '/compliance/risks' },
|
||||
{ id: 'admin-audit-report', name: 'Audit Report', description: 'PDF Audit-Berichte', category: 'compliance', icon: '📊', url: '/compliance/audit-report' },
|
||||
{ id: 'admin-modules', name: 'Service Registry', description: '30+ Service-Module', category: 'compliance', icon: '🔧', url: '/compliance/modules' },
|
||||
{ id: 'admin-dsms', name: 'DSMS', description: 'Datenschutz-Management-System', category: 'compliance', icon: '🏛️', url: '/compliance/dsms' },
|
||||
{ id: 'admin-compliance-workflow', name: 'Workflow', description: 'Freigabe-Workflows', category: 'compliance', icon: '🔄', url: '/compliance/workflow' },
|
||||
{ id: 'admin-source-policy', name: 'Quellen-Policy', description: 'Datenquellen & Compliance', category: 'compliance', icon: '📚', url: '/compliance/source-policy' },
|
||||
{ id: 'admin-ai-act', name: 'EU-AI-Act', description: 'KI-Risikoklassifizierung', category: 'compliance', icon: '🤖', url: '/compliance/ai-act' },
|
||||
{ id: 'admin-obligations', name: 'Pflichten', description: 'NIS2, DSGVO, AI Act', category: 'compliance', icon: '⚡', url: '/compliance/obligations' },
|
||||
|
||||
// === KI & AUTOMATISIERUNG (Teal #14b8a6) ===
|
||||
{ id: 'admin-llm-compare', name: 'LLM Vergleich', description: 'KI-Provider Vergleich', category: 'ai', icon: '🤖', url: '/ai/llm-compare' },
|
||||
{ id: 'admin-rag', name: 'Daten & RAG', description: 'Training Data & RAG', category: 'ai', icon: '🗄️', url: '/ai/rag' },
|
||||
{ id: 'admin-ocr-labeling', name: 'OCR-Labeling', description: 'Handschrift-Training', category: 'ai', icon: '✍️', url: '/ai/ocr-labeling' },
|
||||
{ id: 'admin-magic-help', name: 'Magic Help', description: 'TrOCR Handschrift-OCR', category: 'ai', icon: '🪄', url: '/ai/magic-help' },
|
||||
{ id: 'admin-klausur-korrektur', name: 'Klausur-Korrektur', description: 'Abitur-Korrektur mit KI', category: 'ai', icon: '📝', url: '/ai/klausur-korrektur' },
|
||||
{ id: 'admin-quality', name: 'Qualitaet & Audit', description: 'Compliance-Audit & Traceability', category: 'ai', icon: '✨', url: '/ai/quality' },
|
||||
{ id: 'admin-test-quality', name: 'Test Quality (BQAS)', description: 'Golden Suite & Synthetic Tests', category: 'ai', icon: '🧪', url: '/ai/test-quality' },
|
||||
{ id: 'admin-agents', name: 'Agent Management', description: 'Multi-Agent & SOUL-Editor', category: 'ai', icon: '🧠', url: '/ai/agents' },
|
||||
|
||||
// === INFRASTRUKTUR (Orange #f97316) ===
|
||||
{ id: 'admin-gpu', name: 'GPU Infrastruktur', description: 'vast.ai GPU Management', category: 'infrastructure', icon: '🖥️', url: '/infrastructure/gpu' },
|
||||
{ id: 'admin-middleware', name: 'Middleware', description: 'Stack & API Gateway', category: 'infrastructure', icon: '🔧', url: '/infrastructure/middleware' },
|
||||
{ id: 'admin-security', name: 'Security', description: 'DevSecOps & Scans', category: 'infrastructure', icon: '🔐', url: '/infrastructure/security' },
|
||||
{ id: 'admin-sbom', name: 'SBOM', description: 'Software Bill of Materials', category: 'infrastructure', icon: '📦', url: '/infrastructure/sbom' },
|
||||
{ id: 'admin-cicd', name: 'CI/CD', description: 'Pipelines & Deployments', category: 'infrastructure', icon: '🔄', url: '/infrastructure/ci-cd' },
|
||||
{ id: 'admin-tests', name: 'Test Dashboard', description: '195+ Tests & Coverage', category: 'infrastructure', icon: '🧪', url: '/infrastructure/tests' },
|
||||
|
||||
// === BILDUNG (Blue #3b82f6) ===
|
||||
{ id: 'admin-edu-search', name: 'Education Search', description: 'Bildungsquellen & Crawler', category: 'education', icon: '🔍', url: '/education/edu-search' },
|
||||
{ id: 'admin-zeugnisse', name: 'Zeugnisse-Crawler', description: 'Zeugnis-Daten', category: 'education', icon: '📜', url: '/education/zeugnisse-crawler' },
|
||||
{ id: 'admin-rag-pipeline', name: 'RAG Pipeline', description: 'Bildungsdokumente indexieren', category: 'ai', icon: '🔗', url: '/ai/rag-pipeline' },
|
||||
{ id: 'admin-foerderantrag', name: 'Foerderantrag-Wizard', description: 'DigitalPakt & Landesfoerderung', category: 'education', icon: '💰', url: '/education/foerderantrag' },
|
||||
|
||||
// === KOMMUNIKATION (Green #22c55e) ===
|
||||
{ id: 'admin-video', name: 'Video & Chat', description: 'Matrix & Jitsi Monitoring', category: 'communication', icon: '🎥', url: '/communication/video-chat' },
|
||||
{ id: 'admin-matrix', name: 'Voice Service', description: 'Voice-First Interface', category: 'communication', icon: '🎙️', url: '/communication/matrix' },
|
||||
{ id: 'admin-mail', name: 'Unified Inbox', description: 'E-Mail & KI-Analyse', category: 'communication', icon: '📧', url: '/communication/mail' },
|
||||
{ id: 'admin-alerts', name: 'Alerts Monitoring', description: 'Google Alerts & Feeds', category: 'communication', icon: '🔔', url: '/communication/alerts' },
|
||||
|
||||
// === ENTWICKLUNG (Slate #64748b) ===
|
||||
{ id: 'admin-workflow', name: 'Dev Workflow', description: 'Git, CI/CD & Team-Regeln', category: 'development', icon: '⚡', url: '/development/workflow' },
|
||||
{ id: 'admin-game', name: 'Breakpilot Drive', description: 'Lernspiel Management', category: 'development', icon: '🎮', url: '/development/game' },
|
||||
{ id: 'admin-unity', name: 'Unity Bridge', description: 'Unity Editor Steuerung', category: 'development', icon: '🎯', url: '/development/unity-bridge' },
|
||||
{ id: 'admin-companion', name: 'Companion Dev', description: 'Lesson-Modus Entwicklung', category: 'development', icon: '📚', url: '/development/companion' },
|
||||
{ id: 'admin-docs', name: 'Developer Docs', description: 'API & Architektur', category: 'development', icon: '📖', url: '/development/docs' },
|
||||
{ id: 'admin-brandbook', name: 'Brandbook', description: 'Corporate Design', category: 'development', icon: '🎨', url: '/development/brandbook' },
|
||||
{ id: 'admin-screen-flow', name: 'Screen Flow', description: 'UI Screen-Verbindungen', category: 'development', icon: '🔀', url: '/development/screen-flow' },
|
||||
{ id: 'admin-content', name: 'Uebersetzungen', description: 'Website Content & Sprachen', category: 'development', icon: '🌐', url: '/development/content' },
|
||||
]
|
||||
|
||||
const ADMIN_CONNECTIONS: ConnectionDef[] = [
|
||||
// === OVERVIEW/META FLOWS ===
|
||||
{ source: 'admin-dashboard', target: 'admin-onboarding', label: 'Erste Schritte' },
|
||||
{ source: 'admin-dashboard', target: 'admin-architecture', label: 'System' },
|
||||
{ source: 'admin-dashboard', target: 'admin-backlog', label: 'Go-Live' },
|
||||
{ source: 'admin-dashboard', target: 'admin-compliance-hub', label: 'Compliance' },
|
||||
{ source: 'admin-onboarding', target: 'admin-consent' },
|
||||
{ source: 'admin-onboarding', target: 'admin-llm-compare' },
|
||||
{ source: 'admin-rbac', target: 'admin-consent' },
|
||||
|
||||
// === DSGVO FLOW ===
|
||||
{ source: 'admin-consent', target: 'admin-einwilligungen', label: 'Nutzer' },
|
||||
{ source: 'admin-consent', target: 'admin-dsr' },
|
||||
{ source: 'admin-dsr', target: 'admin-loeschfristen' },
|
||||
{ source: 'admin-vvt', target: 'admin-tom' },
|
||||
{ source: 'admin-vvt', target: 'admin-dsfa' },
|
||||
{ source: 'admin-dsfa', target: 'admin-tom' },
|
||||
{ source: 'admin-advisory-board', target: 'admin-escalations', label: 'Eskalation' },
|
||||
{ source: 'admin-advisory-board', target: 'admin-dsfa', label: 'Risiko' },
|
||||
|
||||
// === COMPLIANCE FLOW ===
|
||||
{ source: 'admin-compliance-hub', target: 'admin-audit-checklist', label: 'Audit' },
|
||||
{ source: 'admin-compliance-hub', target: 'admin-requirements', label: 'Anforderungen' },
|
||||
{ source: 'admin-compliance-hub', target: 'admin-risks', label: 'Risiken' },
|
||||
{ source: 'admin-compliance-hub', target: 'admin-ai-act', label: 'AI Act' },
|
||||
{ source: 'admin-requirements', target: 'admin-controls' },
|
||||
{ source: 'admin-controls', target: 'admin-evidence' },
|
||||
{ source: 'admin-audit-checklist', target: 'admin-audit-report', label: 'Report' },
|
||||
{ source: 'admin-risks', target: 'admin-controls' },
|
||||
{ source: 'admin-modules', target: 'admin-controls' },
|
||||
{ source: 'admin-source-policy', target: 'admin-rag' },
|
||||
{ source: 'admin-obligations', target: 'admin-requirements' },
|
||||
{ source: 'admin-dsms', target: 'admin-compliance-workflow' },
|
||||
|
||||
// === KI & AUTOMATISIERUNG FLOW ===
|
||||
{ source: 'admin-llm-compare', target: 'admin-rag', label: 'Daten' },
|
||||
{ source: 'admin-rag', target: 'admin-quality' },
|
||||
{ source: 'admin-rag', target: 'admin-agents' },
|
||||
{ source: 'admin-ocr-labeling', target: 'admin-magic-help', label: 'Training' },
|
||||
{ source: 'admin-magic-help', target: 'admin-klausur-korrektur', label: 'Korrektur' },
|
||||
{ source: 'admin-quality', target: 'admin-test-quality' },
|
||||
{ source: 'admin-agents', target: 'admin-test-quality', label: 'BQAS' },
|
||||
{ source: 'admin-klausur-korrektur', target: 'admin-quality', label: 'Audit' },
|
||||
|
||||
// === INFRASTRUKTUR FLOW ===
|
||||
{ source: 'admin-security', target: 'admin-sbom', label: 'Dependencies' },
|
||||
{ source: 'admin-sbom', target: 'admin-tests' },
|
||||
{ source: 'admin-tests', target: 'admin-cicd', label: 'Pipeline' },
|
||||
{ source: 'admin-cicd', target: 'admin-middleware' },
|
||||
{ source: 'admin-middleware', target: 'admin-gpu', label: 'GPU' },
|
||||
{ source: 'admin-security', target: 'admin-compliance-hub', label: 'Compliance' },
|
||||
|
||||
// === BILDUNG FLOW ===
|
||||
{ source: 'admin-edu-search', target: 'admin-rag', label: 'Quellen' },
|
||||
{ source: 'admin-edu-search', target: 'admin-zeugnisse' },
|
||||
{ source: 'admin-training', target: 'admin-onboarding' },
|
||||
{ source: 'admin-foerderantrag', target: 'admin-docs', label: 'Docs' },
|
||||
|
||||
// === KOMMUNIKATION FLOW ===
|
||||
{ source: 'admin-video', target: 'admin-matrix', label: 'Voice' },
|
||||
{ source: 'admin-mail', target: 'admin-alerts' },
|
||||
{ source: 'admin-alerts', target: 'admin-mail', label: 'Inbox' },
|
||||
|
||||
// === ENTWICKLUNG FLOW ===
|
||||
{ source: 'admin-workflow', target: 'admin-cicd', label: 'Pipeline' },
|
||||
{ source: 'admin-workflow', target: 'admin-docs' },
|
||||
{ source: 'admin-game', target: 'admin-unity', label: 'Editor' },
|
||||
{ source: 'admin-companion', target: 'admin-agents', label: 'Agents' },
|
||||
{ source: 'admin-brandbook', target: 'admin-screen-flow' },
|
||||
{ source: 'admin-docs', target: 'admin-architecture' },
|
||||
{ source: 'admin-content', target: 'admin-brandbook' },
|
||||
]
|
||||
|
||||
// ============================================
|
||||
// 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' },
|
||||
}
|
||||
|
||||
// Colors from navigation.ts
|
||||
const ADMIN_COLORS: Record<string, { bg: string; border: string; text: string }> = {
|
||||
overview: { bg: '#e0f2fe', border: '#0ea5e9', text: '#0369a1' }, // Sky (Meta)
|
||||
dsgvo: { bg: '#ede9fe', border: '#7c3aed', text: '#5b21b6' }, // Violet
|
||||
compliance: { bg: '#f3e8ff', border: '#9333ea', text: '#6b21a8' }, // Purple
|
||||
ai: { bg: '#ccfbf1', border: '#14b8a6', text: '#0f766e' }, // Teal
|
||||
infrastructure: { bg: '#ffedd5', border: '#f97316', text: '#c2410c' },// Orange
|
||||
education: { bg: '#dbeafe', border: '#3b82f6', text: '#1e40af' }, // Blue
|
||||
communication: { bg: '#dcfce7', border: '#22c55e', text: '#166534' }, // Green
|
||||
development: { bg: '#f1f5f9', border: '#64748b', text: '#334155' }, // Slate
|
||||
}
|
||||
|
||||
const STUDIO_LABELS: Record<string, string> = {
|
||||
navigation: 'Navigation',
|
||||
content: 'Content & Tools',
|
||||
communication: 'Kommunikation',
|
||||
school: 'Schulverwaltung',
|
||||
admin: 'Administration',
|
||||
ai: 'KI & Assistent',
|
||||
}
|
||||
|
||||
// Labels from navigation.ts
|
||||
const ADMIN_LABELS: Record<string, string> = {
|
||||
overview: 'Uebersicht & Meta',
|
||||
dsgvo: 'DSGVO',
|
||||
compliance: 'Compliance & GRC',
|
||||
ai: 'KI & Automatisierung',
|
||||
infrastructure: 'Infrastruktur & DevOps',
|
||||
education: 'Bildung & Schule',
|
||||
communication: 'Kommunikation & Alerts',
|
||||
development: 'Entwicklung & Produkte',
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 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
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 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 },
|
||||
dsgvo: { x: 50, y: 150 },
|
||||
compliance: { x: 700, y: 150 },
|
||||
ai: { x: 50, y: 350 },
|
||||
communication: { x: 400, y: 350 },
|
||||
infrastructure: { x: 700, y: 350 },
|
||||
education: { x: 50, y: 550 },
|
||||
development: { x: 400, 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
|
||||
// ============================================
|
||||
|
||||
export default function ScreenFlowPage() {
|
||||
const [flowType, setFlowType] = useState<FlowType>('admin')
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
|
||||
const [selectedNode, setSelectedNode] = 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:3002'
|
||||
|
||||
// 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',
|
||||
},
|
||||
}
|
||||
})
|
||||
}, [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' },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: isHighlighted ? '#3b82f6' : '#94a3b8', width: 15, height: 15 },
|
||||
}
|
||||
})
|
||||
}, [connections, selectedNode, connectedNodes])
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([])
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([])
|
||||
|
||||
// Update nodes/edges when dependencies change
|
||||
useEffect(() => {
|
||||
setNodes(initialNodes)
|
||||
setEdges(initialEdges)
|
||||
}, [initialNodes, initialEdges, setNodes, setEdges])
|
||||
|
||||
// Reset when flow type changes
|
||||
const handleFlowTypeChange = useCallback((newType: FlowType) => {
|
||||
setFlowType(newType)
|
||||
setSelectedNode(null)
|
||||
setSelectedCategory(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) {
|
||||
setPreviewScreen(screen)
|
||||
}
|
||||
}, [screens, baseUrl, selectedNode])
|
||||
|
||||
// Handle background click - deselect
|
||||
const onPaneClick = useCallback(() => {
|
||||
setSelectedNode(null)
|
||||
setPreviewScreen(null)
|
||||
}, [])
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
totalScreens: screens.length,
|
||||
totalConnections: connections.length,
|
||||
connectedCount: connectedNodes.size,
|
||||
}
|
||||
|
||||
const categories = Object.keys(labels)
|
||||
|
||||
// Connected screens list
|
||||
const connectedScreens = selectedNode
|
||||
? screens.filter(s => connectedNodes.has(s.id))
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Flow Type Selector */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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-Oberflaeche</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-primary-500 bg-primary-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-primary-500 text-white' : 'bg-slate-100'
|
||||
}`}>
|
||||
⚙️
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-bold text-lg">Admin v2 (Port 3002)</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>
|
||||
</div>
|
||||
|
||||
{/* Stats & Selection Info */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<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-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className="text-3xl font-bold text-primary-600">{stats.totalConnections}</div>
|
||||
<div className="text-sm text-slate-500">Verbindungen</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm 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={() => {
|
||||
setSelectedNode(null)
|
||||
setPreviewScreen(null)
|
||||
}}
|
||||
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 zu sehen
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedCategory(null)
|
||||
setSelectedNode(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)
|
||||
setPreviewScreen(null)
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2"
|
||||
style={{
|
||||
background: selectedCategory === key ? catColors.border : catColors.bg,
|
||||
color: selectedCategory === key ? 'white' : catColors.text,
|
||||
}}
|
||||
>
|
||||
<span className="w-3 h-3 rounded-full" style={{ background: catColors.border }} />
|
||||
{labels[key]} ({count})
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connected Screens List */}
|
||||
{selectedNode && connectedScreens.length > 1 && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 shadow-sm">
|
||||
<div className="text-sm font-medium text-slate-700 mb-3">Verbundene Screens:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{connectedScreens.map((screen) => {
|
||||
const catColors = colors[screen.category] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
|
||||
const isCurrentNode = screen.id === selectedNode
|
||||
return (
|
||||
<button
|
||||
key={screen.id}
|
||||
onClick={() => {
|
||||
if (screen.url) {
|
||||
window.open(`${baseUrl}${screen.url}`, '_blank')
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${
|
||||
isCurrentNode ? 'ring-2 ring-primary-500' : ''
|
||||
}`}
|
||||
style={{
|
||||
background: isCurrentNode ? catColors.border : catColors.bg,
|
||||
color: isCurrentNode ? 'white' : catColors.text,
|
||||
}}
|
||||
>
|
||||
<span>{screen.icon}</span>
|
||||
{screen.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flow Diagram */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden" style={{ height: '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} />
|
||||
|
||||
<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 v2'}
|
||||
</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<br/>
|
||||
Doppelklick = Oeffnen
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* Screen List */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-3 bg-slate-50 border-b flex items-center justify-between">
|
||||
<h3 className="font-medium text-slate-700">
|
||||
Alle Screens ({screens.length})
|
||||
</h3>
|
||||
<span className="text-xs text-slate-400">{baseUrl}</span>
|
||||
</div>
|
||||
<div className="divide-y max-h-80 overflow-y-auto">
|
||||
{screens
|
||||
.filter(s => !selectedCategory || s.category === selectedCategory)
|
||||
.map((screen) => {
|
||||
const catColors = colors[screen.category] || { bg: '#f1f5f9', border: '#94a3b8', text: '#475569' }
|
||||
return (
|
||||
<button
|
||||
key={screen.id}
|
||||
onClick={() => {
|
||||
setSelectedNode(screen.id)
|
||||
setSelectedCategory(null)
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
Eye,
|
||||
Download,
|
||||
AlertTriangle,
|
||||
Info
|
||||
Info,
|
||||
Container
|
||||
} from 'lucide-react'
|
||||
|
||||
interface WorkflowStep {
|
||||
@@ -88,6 +89,14 @@ export default function WorkflowPage() {
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'Integration Tests',
|
||||
description: 'Docker Compose Test-Umgebung mit Backend, DB und Consent-Service fuer vollstaendige E2E-Tests.',
|
||||
command: 'docker compose -f docker-compose.test.yml up -d',
|
||||
icon: <Container className="h-6 w-6" />,
|
||||
location: 'macmini'
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: 'Frontend testen',
|
||||
description: 'Teste die Änderungen im Browser auf dem Mac Mini.',
|
||||
command: 'http://macmini:3000',
|
||||
@@ -158,8 +167,8 @@ export default function WorkflowPage() {
|
||||
<span>Browser für Frontend-Tests</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Tägliches Backup (automatisch)</span>
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
<span>Backup manuell (MacBook nachts aus)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -192,6 +201,10 @@ export default function WorkflowPage() {
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>PostgreSQL Datenbank</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<span>Automatisches Backup (02:00 Uhr lokal)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -314,17 +327,18 @@ export default function WorkflowPage() {
|
||||
Backup & Sicherheit
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Mac Mini - Automatisches lokales Backup */}
|
||||
<div className="bg-green-50 rounded-xl p-5 border border-green-200">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Clock className="h-5 w-5 text-green-600" />
|
||||
<h3 className="font-semibold text-green-900">Tägliches Backup</h3>
|
||||
<h3 className="font-semibold text-green-900">Mac Mini (Auto)</h3>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-green-800">
|
||||
<li>• Läuft automatisch um 02:00 Uhr</li>
|
||||
<li>• Git Repository wird synchronisiert</li>
|
||||
<li>• PostgreSQL-Dump wird erstellt</li>
|
||||
<li>• Backups werden 7 Tage aufbewahrt</li>
|
||||
<li>• Automatisch um 02:00 Uhr</li>
|
||||
<li>• PostgreSQL-Dump lokal</li>
|
||||
<li>• Git Repository gesichert</li>
|
||||
<li>• 7 Tage Aufbewahrung</li>
|
||||
</ul>
|
||||
<div className="mt-4 p-3 bg-green-100 rounded-lg">
|
||||
<code className="text-xs text-green-700 font-mono">
|
||||
@@ -333,10 +347,29 @@ export default function WorkflowPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MacBook - Manuelles Backup */}
|
||||
<div className="bg-amber-50 rounded-xl p-5 border border-amber-200">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600" />
|
||||
<h3 className="font-semibold text-amber-900">MacBook (Manuell)</h3>
|
||||
</div>
|
||||
<ul className="space-y-2 text-sm text-amber-800">
|
||||
<li>• MacBook nachts aus (02:00)</li>
|
||||
<li>• Keine Auto-Synchronisation</li>
|
||||
<li>• Backup manuell anstoßen</li>
|
||||
</ul>
|
||||
<div className="mt-4 p-3 bg-amber-100 rounded-lg">
|
||||
<code className="text-xs text-amber-700 font-mono">
|
||||
rsync -avz macmini:~/Projekte/ ~/Projekte/
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manuelles Backup starten */}
|
||||
<div className="bg-blue-50 rounded-xl p-5 border border-blue-200">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Download className="h-5 w-5 text-blue-600" />
|
||||
<h3 className="font-semibold text-blue-900">Manuelles Backup</h3>
|
||||
<h3 className="font-semibold text-blue-900">Backup Script</h3>
|
||||
</div>
|
||||
<p className="text-sm text-blue-800 mb-3">
|
||||
Backup jederzeit manuell starten:
|
||||
@@ -490,6 +523,118 @@ export default function WorkflowPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CI/CD Infrastruktur - Automatisierte OAuth Integration */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-indigo-600" />
|
||||
CI/CD Infrastruktur (Automatisiert)
|
||||
</h2>
|
||||
|
||||
<div className="bg-blue-50 rounded-xl p-4 mb-6 border border-blue-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-900">Warum automatisiert?</h4>
|
||||
<p className="text-sm text-blue-800 mt-1">
|
||||
Die OAuth-Integration zwischen Woodpecker und Gitea ist vollautomatisiert.
|
||||
Dies ist eine DevSecOps Best Practice: Credentials werden in HashiCorp Vault gespeichert
|
||||
und können bei Bedarf automatisch regeneriert werden.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Architektur */}
|
||||
<div className="bg-slate-50 rounded-xl p-5 border border-slate-200">
|
||||
<h3 className="font-semibold text-slate-900 mb-3">Architektur</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center gap-3 p-2 bg-white rounded-lg border">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full" />
|
||||
<span className="font-medium">Gitea</span>
|
||||
<span className="text-slate-500">Port 3003</span>
|
||||
<span className="text-xs text-slate-400 ml-auto">Git Server</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<ArrowRight className="h-4 w-4 text-slate-400 rotate-90" />
|
||||
<span className="text-xs text-slate-500 ml-2">OAuth 2.0</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-2 bg-white rounded-lg border">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-full" />
|
||||
<span className="font-medium">Woodpecker</span>
|
||||
<span className="text-slate-500">Port 8090</span>
|
||||
<span className="text-xs text-slate-400 ml-auto">CI/CD Server</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<ArrowRight className="h-4 w-4 text-slate-400 rotate-90" />
|
||||
<span className="text-xs text-slate-500 ml-2">Credentials</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-2 bg-white rounded-lg border">
|
||||
<div className="w-3 h-3 bg-purple-500 rounded-full" />
|
||||
<span className="font-medium">Vault</span>
|
||||
<span className="text-slate-500">Port 8200</span>
|
||||
<span className="text-xs text-slate-400 ml-auto">Secrets Manager</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credentials Speicherort */}
|
||||
<div className="bg-slate-50 rounded-xl p-5 border border-slate-200">
|
||||
<h3 className="font-semibold text-slate-900 mb-3">Credentials Speicherorte</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="p-3 bg-white rounded-lg border">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Database className="h-4 w-4 text-purple-500" />
|
||||
<span className="font-medium">HashiCorp Vault</span>
|
||||
</div>
|
||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded">
|
||||
secret/cicd/woodpecker
|
||||
</code>
|
||||
<p className="text-xs text-slate-500 mt-1">Client ID + Secret (Quelle der Wahrheit)</p>
|
||||
</div>
|
||||
<div className="p-3 bg-white rounded-lg border">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<FileCode className="h-4 w-4 text-blue-500" />
|
||||
<span className="font-medium">.env Datei</span>
|
||||
</div>
|
||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded">
|
||||
WOODPECKER_GITEA_CLIENT/SECRET
|
||||
</code>
|
||||
<p className="text-xs text-slate-500 mt-1">Für Docker Compose (aus Vault geladen)</p>
|
||||
</div>
|
||||
<div className="p-3 bg-white rounded-lg border">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Database className="h-4 w-4 text-green-500" />
|
||||
<span className="font-medium">Gitea PostgreSQL</span>
|
||||
</div>
|
||||
<code className="text-xs bg-slate-100 px-2 py-1 rounded">
|
||||
oauth2_application
|
||||
</code>
|
||||
<p className="text-xs text-slate-500 mt-1">OAuth App Registration (gehashtes Secret)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Troubleshooting */}
|
||||
<div className="mt-6 bg-amber-50 rounded-xl p-5 border border-amber-200">
|
||||
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-600" />
|
||||
Troubleshooting: OAuth Fehler beheben
|
||||
</h3>
|
||||
<p className="text-sm text-amber-800 mb-3">
|
||||
Falls der Fehler "Client ID not registered" oder "user does not exist" auftritt:
|
||||
</p>
|
||||
<div className="bg-slate-800 rounded-lg p-4 font-mono text-sm">
|
||||
<p className="text-slate-400"># Credentials automatisch regenerieren</p>
|
||||
<p className="text-green-400">./scripts/sync-woodpecker-credentials.sh --regenerate</p>
|
||||
<p className="text-slate-400 mt-2"># Oder manuell: Vault → Gitea → .env → Restart</p>
|
||||
<p className="text-green-400">rsync .env macmini:~/Projekte/breakpilot-pwa/</p>
|
||||
<p className="text-green-400">ssh macmini "cd ~/Projekte/breakpilot-pwa && docker compose up -d --force-recreate woodpecker-server"</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Members Info */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h2 className="text-xl font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AehnlicheDokumente - RAG-based similar documents panel
|
||||
* Shows documents with similar content based on vector similarity
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Loader2, FileText, AlertCircle, RefreshCw, ExternalLink } from 'lucide-react'
|
||||
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
|
||||
import type { SimilarDocument } from '@/lib/education/abitur-archiv-types'
|
||||
import { FAECHER } from '@/lib/education/abitur-docs-types'
|
||||
|
||||
interface AehnlicheDokumenteProps {
|
||||
documentId: string
|
||||
onSelectDocument: (doc: AbiturDokument) => void
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export function AehnlicheDokumente({
|
||||
documentId,
|
||||
onSelectDocument,
|
||||
limit = 5
|
||||
}: AehnlicheDokumenteProps) {
|
||||
const [similarDocs, setSimilarDocs] = useState<SimilarDocument[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSimilarDocuments = async () => {
|
||||
if (!documentId) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/education/abitur-archiv/similar?id=${documentId}&limit=${limit}`)
|
||||
|
||||
if (!res.ok) {
|
||||
// Use mock data if endpoint not available
|
||||
setSimilarDocs(getMockSimilarDocuments(documentId))
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setSimilarDocs(data.similar || [])
|
||||
} catch (err) {
|
||||
console.log('Similar docs fetch failed, using mock data')
|
||||
setSimilarDocs(getMockSimilarDocuments(documentId))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchSimilarDocuments()
|
||||
}, [documentId, limit])
|
||||
|
||||
const handleRefresh = () => {
|
||||
setLoading(true)
|
||||
// Re-trigger the effect
|
||||
setSimilarDocs([])
|
||||
setTimeout(() => {
|
||||
setSimilarDocs(getMockSimilarDocuments(documentId))
|
||||
setLoading(false)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<Loader2 className="w-8 h-8 text-blue-600 animate-spin mb-3" />
|
||||
<p className="text-sm text-slate-500">Suche aehnliche Dokumente...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
|
||||
<p className="text-sm text-red-600 mb-3">{error}</p>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="px-4 py-2 text-sm text-blue-600 hover:bg-blue-50 rounded-lg flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (similarDocs.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<FileText className="w-10 h-10 text-slate-300 mx-auto mb-3" />
|
||||
<p className="text-sm text-slate-500">Keine aehnlichen Dokumente gefunden</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
Versuchen Sie eine andere Suche oder laden Sie mehr Dokumente hoch.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-slate-700">Aehnliche Dokumente</h4>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded"
|
||||
title="Aktualisieren"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{similarDocs.map((doc) => (
|
||||
<SimilarDocumentCard
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
onSelect={() => {
|
||||
// Convert SimilarDocument to AbiturDokument for selection
|
||||
// In production, this would fetch the full document
|
||||
onSelectDocument(doc as unknown as AbiturDokument)
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-400 text-center pt-2">
|
||||
Basierend auf semantischer Aehnlichkeit (RAG)
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SimilarDocumentCard({
|
||||
document,
|
||||
onSelect
|
||||
}: {
|
||||
document: SimilarDocument
|
||||
onSelect: () => void
|
||||
}) {
|
||||
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
|
||||
const similarityPercent = Math.round(document.similarity_score * 100)
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
className="w-full flex items-start gap-3 p-3 bg-white border border-slate-200 rounded-lg
|
||||
hover:bg-blue-50 hover:border-blue-200 transition-colors text-left group"
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center flex-shrink-0
|
||||
group-hover:bg-blue-100 transition-colors">
|
||||
<FileText className="w-5 h-5 text-slate-400 group-hover:text-blue-500" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-800 truncate group-hover:text-blue-700">
|
||||
{fachLabel} {document.jahr}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 flex items-center gap-2">
|
||||
<span>{document.niveau}</span>
|
||||
<span>|</span>
|
||||
<span>Aufgabe {document.aufgaben_nummer}</span>
|
||||
{document.typ === 'erwartungshorizont' && (
|
||||
<>
|
||||
<span>|</span>
|
||||
<span className="text-orange-600">EWH</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Similarity Score */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
similarityPercent >= 80
|
||||
? 'bg-green-100 text-green-700'
|
||||
: similarityPercent >= 60
|
||||
? 'bg-yellow-100 text-yellow-700'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{similarityPercent}%
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Mock data generator for development
|
||||
function getMockSimilarDocuments(documentId: string): SimilarDocument[] {
|
||||
// Generate consistent mock data based on document ID
|
||||
const idHash = documentId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||
|
||||
const faecher = ['deutsch', 'englisch']
|
||||
const jahre = [2021, 2022, 2023, 2024, 2025]
|
||||
const niveaus: Array<'eA' | 'gA'> = ['eA', 'gA']
|
||||
const nummern = ['I', 'II', 'III']
|
||||
const typen: Array<'aufgabe' | 'erwartungshorizont'> = ['aufgabe', 'erwartungshorizont']
|
||||
|
||||
const docs: SimilarDocument[] = []
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const idx = (idHash + i) % (faecher.length * jahre.length * niveaus.length)
|
||||
docs.push({
|
||||
id: `similar-${documentId}-${i}`,
|
||||
dateiname: `${jahre[idx % jahre.length]}_${faecher[idx % faecher.length]}_${niveaus[idx % niveaus.length]}_${nummern[idx % nummern.length]}.pdf`,
|
||||
similarity_score: 0.95 - (i * 0.1) + (Math.random() * 0.05),
|
||||
fach: faecher[idx % faecher.length],
|
||||
jahr: jahre[(idx + i) % jahre.length],
|
||||
niveau: niveaus[idx % niveaus.length],
|
||||
typ: typen[(idx + i) % typen.length],
|
||||
aufgaben_nummer: nummern[(idx + i) % nummern.length]
|
||||
})
|
||||
}
|
||||
|
||||
return docs.sort((a, b) => b.similarity_score - a.similarity_score)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* DokumentCard - Card component for Abitur document grid view
|
||||
* Features: Preview, Download, Add to Klausur actions
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { FileText, Eye, Download, Plus, Calendar, Layers, BookOpen, ExternalLink } from 'lucide-react'
|
||||
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
|
||||
import { formatFileSize, FAECHER, NIVEAUS } from '@/lib/education/abitur-docs-types'
|
||||
|
||||
interface DokumentCardProps {
|
||||
document: AbiturDokument
|
||||
onPreview: (doc: AbiturDokument) => void
|
||||
onDownload: (doc: AbiturDokument) => void
|
||||
onAddToKlausur?: (doc: AbiturDokument) => void
|
||||
}
|
||||
|
||||
export function DokumentCard({
|
||||
document,
|
||||
onPreview,
|
||||
onDownload,
|
||||
onAddToKlausur
|
||||
}: DokumentCardProps) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
|
||||
const niveauLabel = document.niveau === 'eA' ? 'Erhoehtes Niveau' : 'Grundlegendes Niveau'
|
||||
|
||||
const handleDownload = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onDownload(document)
|
||||
}
|
||||
|
||||
const handleAddToKlausur = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onAddToKlausur?.(document)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-white rounded-xl border border-slate-200 overflow-hidden hover:shadow-lg
|
||||
transition-all duration-200 cursor-pointer group"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={() => onPreview(document)}
|
||||
>
|
||||
{/* Header with Type Badge */}
|
||||
<div className="relative h-32 bg-gradient-to-br from-slate-100 to-slate-50 flex items-center justify-center">
|
||||
<FileText className="w-16 h-16 text-slate-300 group-hover:text-blue-400 transition-colors" />
|
||||
|
||||
{/* Type Badge */}
|
||||
<div className="absolute top-3 left-3">
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${
|
||||
document.typ === 'erwartungshorizont'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-purple-100 text-purple-700'
|
||||
}`}>
|
||||
{document.typ === 'erwartungshorizont' ? 'Erwartungshorizont' : 'Aufgabe'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Year Badge */}
|
||||
<div className="absolute top-3 right-3">
|
||||
<span className="px-2 py-1 bg-white/80 backdrop-blur-sm rounded-lg text-xs font-semibold text-slate-700">
|
||||
{document.jahr}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="absolute bottom-3 right-3">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
document.status === 'indexed'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: document.status === 'error'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{document.status === 'indexed' ? 'Indexiert' : document.status === 'error' ? 'Fehler' : 'Ausstehend'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Hover Overlay with Preview */}
|
||||
{isHovered && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<button
|
||||
className="px-4 py-2 bg-white text-slate-800 rounded-lg font-medium
|
||||
flex items-center gap-2 shadow-lg hover:bg-blue-50 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onPreview(document)
|
||||
}}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Vorschau
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-slate-800 mb-2 line-clamp-2 min-h-[2.5rem]">
|
||||
{fachLabel} {document.niveau} - Aufgabe {document.aufgaben_nummer}
|
||||
</h3>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="space-y-1.5 mb-4">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
<span>{fachLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<Layers className="w-4 h-4" />
|
||||
<span>{niveauLabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<span className="capitalize">{document.bundesland}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<span>{formatFileSize(document.file_size)}</span>
|
||||
<span>|</span>
|
||||
<span>{document.dateiname}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onPreview(document)}
|
||||
className="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100
|
||||
transition-colors text-sm font-medium flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Vorschau
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="px-3 py-2 text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Herunterladen"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
{onAddToKlausur && (
|
||||
<button
|
||||
onClick={handleAddToKlausur}
|
||||
className="px-3 py-2 text-green-600 hover:bg-green-50 rounded-lg transition-colors"
|
||||
title="Zur Klausur hinzufuegen"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact card variant for list view or similar documents
|
||||
*/
|
||||
export function DokumentCardCompact({
|
||||
document,
|
||||
onPreview,
|
||||
similarity_score
|
||||
}: {
|
||||
document: AbiturDokument
|
||||
onPreview: (doc: AbiturDokument) => void
|
||||
similarity_score?: number
|
||||
}) {
|
||||
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => onPreview(document)}
|
||||
className="w-full flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-lg
|
||||
hover:bg-slate-50 hover:border-slate-300 transition-colors text-left"
|
||||
>
|
||||
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<FileText className="w-5 h-5 text-slate-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-800 truncate">
|
||||
{fachLabel} {document.jahr} - {document.niveau}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 truncate">
|
||||
Aufgabe {document.aufgaben_nummer}
|
||||
{document.typ === 'erwartungshorizont' && ' (EWH)'}
|
||||
</div>
|
||||
</div>
|
||||
{similarity_score !== undefined && (
|
||||
<div className="flex-shrink-0">
|
||||
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded-full">
|
||||
{Math.round(similarity_score * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* FullscreenViewer - Enhanced PDF viewer with fullscreen, zoom, and page navigation
|
||||
* Features: Keyboard shortcuts, zoom controls, similar documents panel
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
X, Download, ZoomIn, ZoomOut, Maximize2, Minimize2,
|
||||
ChevronLeft, ChevronRight, RotateCw, FileText, Search,
|
||||
BookOpen, Calendar, Layers, ExternalLink, Plus
|
||||
} from 'lucide-react'
|
||||
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
|
||||
import { formatFileSize, formatDocumentTitle, FAECHER, NIVEAUS } from '@/lib/education/abitur-docs-types'
|
||||
import { ZOOM_LEVELS, MIN_ZOOM, MAX_ZOOM, ZOOM_STEP } from '@/lib/education/abitur-archiv-types'
|
||||
import { AehnlicheDokumente } from './AehnlicheDokumente'
|
||||
|
||||
interface FullscreenViewerProps {
|
||||
document: AbiturDokument | null
|
||||
onClose: () => void
|
||||
onAddToKlausur?: (doc: AbiturDokument) => void
|
||||
backendUrl?: string
|
||||
}
|
||||
|
||||
export function FullscreenViewer({
|
||||
document,
|
||||
onClose,
|
||||
onAddToKlausur,
|
||||
backendUrl = ''
|
||||
}: FullscreenViewerProps) {
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [zoom, setZoom] = useState(100)
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [showSidebar, setShowSidebar] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'details' | 'similar'>('details')
|
||||
|
||||
// Reset state when document changes
|
||||
useEffect(() => {
|
||||
setZoom(100)
|
||||
setCurrentPage(1)
|
||||
setIsFullscreen(false)
|
||||
}, [document?.id])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
if (!document) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Ignore if typing in an input
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
if (isFullscreen) {
|
||||
setIsFullscreen(false)
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
break
|
||||
case 'f':
|
||||
case 'F11':
|
||||
e.preventDefault()
|
||||
setIsFullscreen(prev => !prev)
|
||||
break
|
||||
case '+':
|
||||
case '=':
|
||||
e.preventDefault()
|
||||
setZoom(z => Math.min(MAX_ZOOM, z + ZOOM_STEP))
|
||||
break
|
||||
case '-':
|
||||
e.preventDefault()
|
||||
setZoom(z => Math.max(MIN_ZOOM, z - ZOOM_STEP))
|
||||
break
|
||||
case '0':
|
||||
e.preventDefault()
|
||||
setZoom(100)
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault()
|
||||
setCurrentPage(p => Math.max(1, p - 1))
|
||||
break
|
||||
case 'ArrowRight':
|
||||
e.preventDefault()
|
||||
setCurrentPage(p => Math.min(totalPages, p + 1))
|
||||
break
|
||||
case 's':
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault()
|
||||
handleDownload()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [document, isFullscreen, totalPages, onClose])
|
||||
|
||||
// Handle native fullscreen changes
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setIsFullscreen(!!window.document.fullscreenElement)
|
||||
}
|
||||
|
||||
window.document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
return () => window.document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||
}, [])
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!document) return
|
||||
const link = window.document.createElement('a')
|
||||
link.href = pdfUrl
|
||||
link.download = document.dateiname
|
||||
link.click()
|
||||
}, [document])
|
||||
|
||||
const handleSearchInRAG = () => {
|
||||
if (!document) return
|
||||
window.location.href = `/education/edu-search?doc=${document.id}&search=1`
|
||||
}
|
||||
|
||||
const handleAddToKlausur = () => {
|
||||
if (!document || !onAddToKlausur) return
|
||||
onAddToKlausur(document)
|
||||
}
|
||||
|
||||
if (!document) return null
|
||||
|
||||
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
|
||||
const niveauLabel = NIVEAUS.find(n => n.id === document.niveau)?.label || document.niveau
|
||||
|
||||
// Build PDF URL
|
||||
const pdfUrl = backendUrl
|
||||
? `${backendUrl}/api/abitur-docs/${document.id}/file`
|
||||
: document.file_path
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 z-50 flex ${isFullscreen ? 'bg-black' : 'bg-black/60 backdrop-blur-sm'}`}>
|
||||
{/* Modal Container */}
|
||||
<div className={`relative bg-white flex flex-col ${
|
||||
isFullscreen ? 'w-full h-full' : 'w-[95vw] h-[95vh] max-w-7xl m-auto rounded-2xl overflow-hidden shadow-2xl'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-white border-b border-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-5 h-5 text-blue-600" />
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">
|
||||
{formatDocumentTitle(document)}
|
||||
</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
{document.dateiname}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Zoom Controls */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-slate-100 rounded-lg">
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.max(MIN_ZOOM, z - ZOOM_STEP))}
|
||||
className="p-1.5 hover:bg-slate-200 rounded"
|
||||
title="Verkleinern (-)"
|
||||
>
|
||||
<ZoomOut className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-slate-700 w-12 text-center">
|
||||
{zoom}%
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.min(MAX_ZOOM, z + ZOOM_STEP))}
|
||||
className="p-1.5 hover:bg-slate-200 rounded"
|
||||
title="Vergroessern (+)"
|
||||
>
|
||||
<ZoomIn className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setZoom(100)}
|
||||
className="p-1.5 hover:bg-slate-200 rounded ml-1"
|
||||
title="Zuruecksetzen (0)"
|
||||
>
|
||||
<RotateCw className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Page Navigation */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-slate-100 rounded-lg">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="p-1.5 hover:bg-slate-200 rounded disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
<span className="text-sm font-medium text-slate-700 min-w-[60px] text-center">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-1.5 hover:bg-slate-200 rounded disabled:opacity-50"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4 text-slate-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-px h-6 bg-slate-200" />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<button
|
||||
onClick={handleSearchInRAG}
|
||||
className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 flex items-center gap-1.5"
|
||||
title="In RAG suchen"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">RAG-Suche</span>
|
||||
</button>
|
||||
|
||||
{onAddToKlausur && (
|
||||
<button
|
||||
onClick={handleAddToKlausur}
|
||||
className="px-3 py-1.5 text-sm bg-green-100 text-green-700 rounded-lg hover:bg-green-200 flex items-center gap-1.5"
|
||||
title="Als Vorlage verwenden"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Zur Klausur</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 flex items-center gap-1.5"
|
||||
title="Herunterladen (Ctrl+S)"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Download</span>
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-slate-200" />
|
||||
|
||||
<button
|
||||
onClick={() => setShowSidebar(!showSidebar)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
showSidebar ? 'bg-slate-200 text-slate-700' : 'text-slate-500 hover:bg-slate-100'
|
||||
}`}
|
||||
title="Seitenleiste"
|
||||
>
|
||||
<Layers className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg"
|
||||
title={isFullscreen ? 'Vollbild beenden (F)' : 'Vollbild (F)'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="w-5 h-5 text-slate-600" />
|
||||
) : (
|
||||
<Maximize2 className="w-5 h-5 text-slate-600" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg"
|
||||
title="Schliessen (Esc)"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* PDF Viewer */}
|
||||
<div className="flex-1 bg-slate-100 p-4 overflow-auto">
|
||||
<div
|
||||
className="bg-white rounded-lg border border-slate-200 mx-auto shadow-sm transition-transform duration-200"
|
||||
style={{
|
||||
transform: `scale(${zoom / 100})`,
|
||||
transformOrigin: 'top center',
|
||||
width: '100%',
|
||||
maxWidth: zoom > 100 ? 'none' : '100%'
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src={pdfUrl}
|
||||
className="w-full h-[calc(90vh-120px)] rounded-lg"
|
||||
title={document.dateiname}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
{showSidebar && (
|
||||
<div className="w-80 border-l border-slate-200 bg-slate-50 flex flex-col">
|
||||
{/* Sidebar Tabs */}
|
||||
<div className="flex border-b border-slate-200">
|
||||
<button
|
||||
onClick={() => setActiveTab('details')}
|
||||
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === 'details'
|
||||
? 'text-blue-600 border-b-2 border-blue-600 bg-white'
|
||||
: 'text-slate-600 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('similar')}
|
||||
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
|
||||
activeTab === 'similar'
|
||||
? 'text-blue-600 border-b-2 border-blue-600 bg-white'
|
||||
: 'text-slate-600 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
Aehnliche
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{activeTab === 'details' ? (
|
||||
<div className="space-y-4">
|
||||
{/* Fach */}
|
||||
<div className="flex items-start gap-3">
|
||||
<BookOpen className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Fach</div>
|
||||
<div className="font-medium text-slate-900">{fachLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Jahr */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Calendar className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Jahr</div>
|
||||
<div className="font-medium text-slate-900">{document.jahr}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Niveau */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Layers className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Niveau</div>
|
||||
<div className="font-medium text-slate-900">{niveauLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aufgabe */}
|
||||
<div className="flex items-start gap-3">
|
||||
<FileText className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Aufgabe</div>
|
||||
<div className="font-medium text-slate-900">
|
||||
{document.aufgaben_nummer}
|
||||
<span className="ml-2 px-2 py-0.5 bg-slate-200 text-slate-700 text-xs rounded-full">
|
||||
{document.typ === 'erwartungshorizont' ? 'Erwartungshorizont' : 'Aufgabe'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bundesland */}
|
||||
<div className="flex items-start gap-3">
|
||||
<ExternalLink className="w-5 h-5 text-slate-400 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Bundesland</div>
|
||||
<div className="font-medium text-slate-900 capitalize">{document.bundesland}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-slate-200" />
|
||||
|
||||
{/* File Info */}
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">Datei-Info</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-3 text-sm space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Dateiname</span>
|
||||
<span className="text-slate-900 font-mono text-xs truncate max-w-[150px]" title={document.dateiname}>
|
||||
{document.dateiname}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Groesse</span>
|
||||
<span className="text-slate-900">{formatFileSize(document.file_size)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Status</span>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
document.status === 'indexed'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: document.status === 'error'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{document.status === 'indexed' ? 'Indexiert' : document.status === 'error' ? 'Fehler' : 'Ausstehend'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* RAG Info */}
|
||||
{document.indexed && document.vector_ids.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">RAG-Index</div>
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-purple-700">
|
||||
<Search className="w-4 h-4" />
|
||||
<span>{document.vector_ids.length} Vektoren indexiert</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-purple-600">
|
||||
Confidence: {(document.confidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="text-xs text-slate-400 pt-2">
|
||||
<div>Erstellt: {new Date(document.created_at).toLocaleString('de-DE')}</div>
|
||||
<div>Aktualisiert: {new Date(document.updated_at).toLocaleString('de-DE')}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<AehnlicheDokumente
|
||||
documentId={document.id}
|
||||
onSelectDocument={(doc) => {
|
||||
// This would be handled by parent - for now just show preview
|
||||
console.log('Selected similar document:', doc.id)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcut Hint */}
|
||||
<div className="absolute bottom-4 left-4 text-xs text-slate-400 bg-white/80 backdrop-blur-sm px-3 py-1.5 rounded-lg shadow-sm">
|
||||
Tastenkuerzel: F (Vollbild) | +/- (Zoom) | 0 (Reset) | Esc (Schliessen)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* ThemenSuche - Autocomplete search for Abitur themes
|
||||
* Features debounced API calls, suggestion display, and keyboard navigation
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Search, X, Loader2 } from 'lucide-react'
|
||||
import type { ThemaSuggestion } from '@/lib/education/abitur-archiv-types'
|
||||
import { POPULAR_THEMES } from '@/lib/education/abitur-archiv-types'
|
||||
|
||||
interface ThemenSucheProps {
|
||||
onSearch: (query: string) => void
|
||||
onClear: () => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function ThemenSuche({
|
||||
onSearch,
|
||||
onClear,
|
||||
placeholder = 'Thema suchen (z.B. Gedichtanalyse, Eroerterung, Drama...)'
|
||||
}: ThemenSucheProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [suggestions, setSuggestions] = useState<ThemaSuggestion[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Debounced API call for suggestions
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(async () => {
|
||||
if (query.length >= 2) {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/education/abitur-archiv/suggest?q=${encodeURIComponent(query)}`)
|
||||
const data = await res.json()
|
||||
setSuggestions(data.suggestions || [])
|
||||
setShowDropdown(true)
|
||||
} catch (error) {
|
||||
console.error('Suggest error:', error)
|
||||
// Fallback to popular themes
|
||||
setSuggestions(POPULAR_THEMES.filter(t =>
|
||||
t.label.toLowerCase().includes(query.toLowerCase())
|
||||
))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
} else if (query.length === 0) {
|
||||
setSuggestions(POPULAR_THEMES)
|
||||
} else {
|
||||
setSuggestions([])
|
||||
}
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [query])
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(e.target as Node) &&
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setShowDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!showDropdown || suggestions.length === 0) return
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1))
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => Math.max(prev - 1, -1))
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (selectedIndex >= 0) {
|
||||
handleSelectSuggestion(suggestions[selectedIndex])
|
||||
} else if (query.trim()) {
|
||||
handleSearch()
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
setShowDropdown(false)
|
||||
setSelectedIndex(-1)
|
||||
break
|
||||
}
|
||||
}, [showDropdown, suggestions, selectedIndex, query])
|
||||
|
||||
const handleSelectSuggestion = (suggestion: ThemaSuggestion) => {
|
||||
setQuery(suggestion.label)
|
||||
setShowDropdown(false)
|
||||
setSelectedIndex(-1)
|
||||
onSearch(suggestion.label)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
if (query.trim()) {
|
||||
onSearch(query.trim())
|
||||
setShowDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setQuery('')
|
||||
setSuggestions(POPULAR_THEMES)
|
||||
setShowDropdown(false)
|
||||
setSelectedIndex(-1)
|
||||
onClear()
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const handleFocus = () => {
|
||||
if (query.length === 0) {
|
||||
setSuggestions(POPULAR_THEMES)
|
||||
}
|
||||
setShowDropdown(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Search Input */}
|
||||
<div className="relative flex items-center">
|
||||
<div className="absolute left-4 text-slate-400">
|
||||
{loading ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setSelectedIndex(-1)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={handleFocus}
|
||||
placeholder={placeholder}
|
||||
className="w-full pl-12 pr-24 py-3 text-lg border border-slate-300 rounded-xl
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
bg-white shadow-sm"
|
||||
/>
|
||||
<div className="absolute right-2 flex items-center gap-2">
|
||||
{query && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg"
|
||||
title="Suche loeschen"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={!query.trim()}
|
||||
className="px-4 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700
|
||||
disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
|
||||
>
|
||||
Suchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestions Dropdown */}
|
||||
{showDropdown && suggestions.length > 0 && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute top-full left-0 right-0 mt-2 bg-white rounded-xl border border-slate-200
|
||||
shadow-lg z-50 max-h-80 overflow-y-auto"
|
||||
>
|
||||
<div className="p-2">
|
||||
{query.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs font-medium text-slate-500 uppercase tracking-wide">
|
||||
Beliebte Themen
|
||||
</div>
|
||||
)}
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={`${suggestion.aufgabentyp}-${suggestion.label}`}
|
||||
onClick={() => handleSelectSuggestion(suggestion)}
|
||||
className={`w-full px-3 py-2.5 text-left rounded-lg flex items-center justify-between
|
||||
transition-colors ${
|
||||
index === selectedIndex
|
||||
? 'bg-blue-50 text-blue-700'
|
||||
: 'hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Search className="w-4 h-4 text-slate-400" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-800">{suggestion.label}</div>
|
||||
{suggestion.kategorie && (
|
||||
<div className="text-xs text-slate-500">{suggestion.kategorie}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-slate-400">
|
||||
{suggestion.count} Dokumente
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Theme Tags */}
|
||||
{!showDropdown && query.length === 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<span className="text-sm text-slate-500">Vorschlaege:</span>
|
||||
{POPULAR_THEMES.slice(0, 5).map((theme) => (
|
||||
<button
|
||||
key={theme.aufgabentyp}
|
||||
onClick={() => handleSelectSuggestion(theme)}
|
||||
className="px-3 py-1 text-sm bg-slate-100 text-slate-700 rounded-full
|
||||
hover:bg-slate-200 transition-colors"
|
||||
>
|
||||
{theme.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
516
admin-v2/app/(admin)/education/abitur-archiv/page.tsx
Normal file
516
admin-v2/app/(admin)/education/abitur-archiv/page.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Abitur-Archiv - Hauptseite
|
||||
* Zentralabitur-Materialien 2021-2025 mit erweiterter Themensuche
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
FileText, Filter, ChevronLeft, ChevronRight, Eye, Download, Search,
|
||||
X, Loader2, Grid, List, LayoutGrid, BarChart3, Archive
|
||||
} from 'lucide-react'
|
||||
import type { AbiturDokument, AbiturDocsResponse } from '@/lib/education/abitur-docs-types'
|
||||
import {
|
||||
formatFileSize,
|
||||
FAECHER,
|
||||
JAHRE,
|
||||
BUNDESLAENDER,
|
||||
NIVEAUS,
|
||||
TYPEN,
|
||||
} from '@/lib/education/abitur-docs-types'
|
||||
import type { ViewMode, ThemaSuggestion } from '@/lib/education/abitur-archiv-types'
|
||||
import { ThemenSuche } from './components/ThemenSuche'
|
||||
import { DokumentCard } from './components/DokumentCard'
|
||||
import { FullscreenViewer } from './components/FullscreenViewer'
|
||||
|
||||
export default function AbiturArchivPage() {
|
||||
// Documents state
|
||||
const [documents, setDocuments] = useState<AbiturDokument[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Pagination
|
||||
const [page, setPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [total, setTotal] = useState(0)
|
||||
const limit = 20
|
||||
|
||||
// View mode
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grid')
|
||||
|
||||
// Filters
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
const [filterFach, setFilterFach] = useState<string>('')
|
||||
const [filterJahr, setFilterJahr] = useState<string>('')
|
||||
const [filterBundesland, setFilterBundesland] = useState<string>('')
|
||||
const [filterNiveau, setFilterNiveau] = useState<string>('')
|
||||
const [filterTyp, setFilterTyp] = useState<string>('')
|
||||
|
||||
// Theme search
|
||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||
const [themes, setThemes] = useState<ThemaSuggestion[]>([])
|
||||
|
||||
// Modal
|
||||
const [selectedDocument, setSelectedDocument] = useState<AbiturDokument | null>(null)
|
||||
|
||||
// Stats
|
||||
const [stats, setStats] = useState({ total: 0, indexed: 0, faecher: 0 })
|
||||
|
||||
// Fetch documents
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('page', page.toString())
|
||||
params.set('limit', limit.toString())
|
||||
if (filterFach) params.set('fach', filterFach)
|
||||
if (filterJahr) params.set('jahr', filterJahr)
|
||||
if (filterBundesland) params.set('bundesland', filterBundesland)
|
||||
if (filterNiveau) params.set('niveau', filterNiveau)
|
||||
if (filterTyp) params.set('typ', filterTyp)
|
||||
if (searchQuery) params.set('thema', searchQuery)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/education/abitur-archiv?${params.toString()}`)
|
||||
if (!response.ok) throw new Error('Fehler beim Laden der Dokumente')
|
||||
|
||||
const data = await response.json()
|
||||
setDocuments(data.documents || [])
|
||||
setTotalPages(data.total_pages || 1)
|
||||
setTotal(data.total || 0)
|
||||
setThemes(data.themes || [])
|
||||
|
||||
// Update stats
|
||||
const indexed = (data.documents || []).filter((d: AbiturDokument) => d.status === 'indexed').length
|
||||
const uniqueFaecher = new Set((data.documents || []).map((d: AbiturDokument) => d.fach)).size
|
||||
setStats({ total: data.total || 0, indexed, faecher: uniqueFaecher })
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp, searchQuery])
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments()
|
||||
}, [fetchDocuments])
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilterFach('')
|
||||
setFilterJahr('')
|
||||
setFilterBundesland('')
|
||||
setFilterNiveau('')
|
||||
setFilterTyp('')
|
||||
setSearchQuery('')
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
setSearchQuery(query)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchQuery('')
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleDownload = (doc: AbiturDokument) => {
|
||||
const link = window.document.createElement('a')
|
||||
link.href = doc.file_path
|
||||
link.download = doc.dateiname
|
||||
link.click()
|
||||
}
|
||||
|
||||
const handleAddToKlausur = (doc: AbiturDokument) => {
|
||||
// Navigate to klausur-korrektur with document reference
|
||||
const params = new URLSearchParams()
|
||||
params.set('archiv_doc_id', doc.id)
|
||||
params.set('aufgabentyp', doc.typ === 'erwartungshorizont' ? 'vorlage' : 'aufgabe')
|
||||
window.location.href = `/education/klausur-korrektur?${params.toString()}`
|
||||
}
|
||||
|
||||
const hasActiveFilters = filterFach || filterJahr || filterBundesland || filterNiveau || filterTyp || searchQuery
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-slate-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center">
|
||||
<Archive className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Abitur-Archiv</h1>
|
||||
<p className="text-sm text-slate-500">Zentralabitur-Materialien 2021-2025</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="hidden md:flex items-center gap-6">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-slate-800">{stats.total}</div>
|
||||
<div className="text-xs text-slate-500">Dokumente</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.indexed}</div>
|
||||
<div className="text-xs text-slate-500">Indexiert</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{stats.faecher}</div>
|
||||
<div className="text-xs text-slate-500">Faecher</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
|
||||
{/* Theme Search */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<ThemenSuche
|
||||
onSearch={handleSearch}
|
||||
onClear={handleClearSearch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setFilterOpen(!filterOpen)}
|
||||
className={`px-4 py-2 rounded-lg font-medium flex items-center gap-2 transition-colors ${
|
||||
filterOpen || hasActiveFilters
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
Filter
|
||||
{hasActiveFilters && (
|
||||
<span className="bg-purple-600 text-white text-xs px-1.5 py-0.5 rounded-full">
|
||||
{[filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp, searchQuery].filter(Boolean).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-sm text-slate-500 hover:text-slate-700 flex items-center gap-1"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Results count */}
|
||||
<span className="text-sm text-slate-500">
|
||||
{total} Treffer
|
||||
</span>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex bg-slate-100 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
viewMode === 'grid' ? 'bg-white shadow-sm text-blue-600' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
title="Raster-Ansicht"
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
viewMode === 'list' ? 'bg-white shadow-sm text-blue-600' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
title="Listen-Ansicht"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Dropdowns */}
|
||||
{filterOpen && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 pt-4 border-t border-slate-200">
|
||||
{/* Fach */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">Fach</label>
|
||||
<select
|
||||
value={filterFach}
|
||||
onChange={(e) => { setFilterFach(e.target.value); setPage(1) }}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Alle Faecher</option>
|
||||
{FAECHER.map(f => (
|
||||
<option key={f.id} value={f.id}>{f.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Jahr */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">Jahr</label>
|
||||
<select
|
||||
value={filterJahr}
|
||||
onChange={(e) => { setFilterJahr(e.target.value); setPage(1) }}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Alle Jahre</option>
|
||||
{JAHRE.map(j => (
|
||||
<option key={j} value={j}>{j}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Bundesland */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">Bundesland</label>
|
||||
<select
|
||||
value={filterBundesland}
|
||||
onChange={(e) => { setFilterBundesland(e.target.value); setPage(1) }}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Alle Bundeslaender</option>
|
||||
{BUNDESLAENDER.map(b => (
|
||||
<option key={b.id} value={b.id}>{b.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Niveau */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">Niveau</label>
|
||||
<select
|
||||
value={filterNiveau}
|
||||
onChange={(e) => { setFilterNiveau(e.target.value); setPage(1) }}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Alle Niveaus</option>
|
||||
{NIVEAUS.map(n => (
|
||||
<option key={n.id} value={n.id}>{n.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Typ */}
|
||||
<div>
|
||||
<label className="block text-xs text-slate-500 mb-1">Typ</label>
|
||||
<select
|
||||
value={filterTyp}
|
||||
onChange={(e) => { setFilterTyp(e.target.value); setPage(1) }}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Alle Typen</option>
|
||||
{TYPEN.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active Search Query Display */}
|
||||
{searchQuery && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<Search className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-sm text-blue-700">
|
||||
Suche: <strong>{searchQuery}</strong>
|
||||
</span>
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className="ml-auto text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Document Display */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-16 text-red-600">
|
||||
<p>{error}</p>
|
||||
<button
|
||||
onClick={() => fetchDocuments()}
|
||||
className="mt-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
) : documents.length === 0 ? (
|
||||
<div className="text-center py-16 text-slate-500">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>Keine Dokumente gefunden</p>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="mt-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Filter zuruecksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : viewMode === 'grid' ? (
|
||||
/* Grid View */
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{documents.map((doc) => (
|
||||
<DokumentCard
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
onPreview={setSelectedDocument}
|
||||
onDownload={handleDownload}
|
||||
onAddToKlausur={handleAddToKlausur}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* List View */
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-600">Dokument</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-slate-600">Fach</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-600">Jahr</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-600">Niveau</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-600">Typ</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-slate-600">Groesse</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-600">Status</th>
|
||||
<th className="text-center px-4 py-3 font-medium text-slate-600">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{documents.map((doc) => {
|
||||
const fachLabel = FAECHER.find(f => f.id === doc.fach)?.label || doc.fach
|
||||
return (
|
||||
<tr
|
||||
key={doc.id}
|
||||
className="border-b border-slate-100 hover:bg-slate-50 cursor-pointer"
|
||||
onClick={() => setSelectedDocument(doc)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-red-500" />
|
||||
<span className="font-medium text-slate-900 truncate max-w-[200px]" title={doc.dateiname}>
|
||||
{doc.dateiname}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="capitalize">{fachLabel}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">{doc.jahr}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
doc.niveau === 'eA'
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{doc.niveau}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
doc.typ === 'erwartungshorizont'
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-purple-100 text-purple-700'
|
||||
}`}>
|
||||
{doc.typ === 'erwartungshorizont' ? 'EWH' : 'Aufgabe'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-slate-500">
|
||||
{formatFileSize(doc.file_size)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
doc.status === 'indexed'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: doc.status === 'error'
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{doc.status === 'indexed' ? 'Indexiert' : doc.status === 'error' ? 'Fehler' : 'Ausstehend'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<div className="flex items-center justify-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => setSelectedDocument(doc)}
|
||||
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded"
|
||||
title="Vorschau"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownload(doc)}
|
||||
className="p-1.5 text-slate-600 hover:bg-slate-100 rounded"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{documents.length > 0 && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 bg-slate-50">
|
||||
<div className="text-sm text-slate-500">
|
||||
Zeige {(page - 1) * limit + 1}-{Math.min(page * limit, total)} von {total} Dokumenten
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm text-slate-600">
|
||||
Seite {page} von {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Viewer Modal */}
|
||||
<FullscreenViewer
|
||||
document={selectedDocument}
|
||||
onClose={() => setSelectedDocument(null)}
|
||||
onAddToKlausur={handleAddToKlausur}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
admin-v2/app/(admin)/education/companion/page.tsx
Normal file
76
admin-v2/app/(admin)/education/companion/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { getModuleByHref } from '@/lib/navigation'
|
||||
import { CompanionDashboard } from '@/components/companion/CompanionDashboard'
|
||||
import { GraduationCap } from 'lucide-react'
|
||||
|
||||
function LoadingFallback() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header Skeleton */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-12 w-80 bg-slate-200 rounded-xl animate-pulse" />
|
||||
<div className="flex gap-2">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="h-10 w-10 bg-slate-200 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase Timeline Skeleton */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6">
|
||||
<div className="h-4 w-24 bg-slate-200 rounded mb-4 animate-pulse" />
|
||||
<div className="flex gap-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<div className="w-10 h-10 bg-slate-200 rounded-full animate-pulse" />
|
||||
{i < 5 && <div className="w-8 h-1 bg-slate-200 animate-pulse" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Skeleton */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<div className="h-4 w-16 bg-slate-200 rounded mb-2 animate-pulse" />
|
||||
<div className="h-8 w-12 bg-slate-200 rounded animate-pulse" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content Skeleton */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6 h-64 animate-pulse" />
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6 h-64 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CompanionPage() {
|
||||
const moduleInfo = getModuleByHref('/education/companion')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Purpose Header */}
|
||||
{moduleInfo && (
|
||||
<PagePurpose
|
||||
title={moduleInfo.module.name}
|
||||
purpose={moduleInfo.module.purpose}
|
||||
audience={moduleInfo.module.audience}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Companion Dashboard */}
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<CompanionDashboard />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,9 +5,10 @@
|
||||
* Bildungsquellen und Crawler-Verwaltung
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { Search, Database, RefreshCw, ExternalLink, FileText, BookOpen } from 'lucide-react'
|
||||
import { Search, Database, RefreshCw, ExternalLink, FileText, BookOpen, FolderOpen } from 'lucide-react'
|
||||
import { DokumenteTab } from '@/components/education/DokumenteTab'
|
||||
|
||||
interface DataSource {
|
||||
id: string
|
||||
@@ -42,7 +43,12 @@ const DATA_SOURCES: DataSource[] = [
|
||||
|
||||
export default function EduSearchPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [activeTab, setActiveTab] = useState<'search' | 'sources' | 'crawler'>('search')
|
||||
const [activeTab, setActiveTab] = useState<'search' | 'documents' | 'sources' | 'crawler'>('search')
|
||||
const [documentCount, setDocumentCount] = useState<number>(0)
|
||||
|
||||
const handleDocumentCountChange = useCallback((count: number) => {
|
||||
setDocumentCount(count)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -95,6 +101,22 @@ export default function EduSearchPage() {
|
||||
<Search className="w-4 h-4 inline mr-2" />
|
||||
Suche
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('documents')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
activeTab === 'documents'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<FolderOpen className="w-4 h-4 inline mr-2" />
|
||||
Dokumente
|
||||
{documentCount > 0 && (
|
||||
<span className="ml-2 px-1.5 py-0.5 bg-white/20 rounded text-xs">
|
||||
{documentCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('sources')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
@@ -162,6 +184,11 @@ export default function EduSearchPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documents Tab */}
|
||||
{activeTab === 'documents' && (
|
||||
<DokumenteTab onDocumentCountChange={handleDocumentCountChange} />
|
||||
)}
|
||||
|
||||
{/* Sources Tab */}
|
||||
{activeTab === 'sources' && (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||
@@ -281,9 +308,9 @@ export default function EduSearchPage() {
|
||||
<div className="font-medium text-slate-900">Zeugnisse-Crawler</div>
|
||||
<div className="text-sm text-slate-500">Zeugnis-Strukturen verwalten</div>
|
||||
</a>
|
||||
<a href="/education/training" className="block p-3 bg-white rounded-lg hover:shadow-md transition-shadow">
|
||||
<div className="font-medium text-slate-900">Training</div>
|
||||
<div className="text-sm text-slate-500">Schulungsmodule verwalten</div>
|
||||
<a href="/ai/rag-pipeline" className="block p-3 bg-white rounded-lg hover:shadow-md transition-shadow">
|
||||
<div className="font-medium text-slate-900">RAG Pipeline</div>
|
||||
<div className="text-sm text-slate-500">Bildungsdokumente indexieren</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,484 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Fairness-Dashboard
|
||||
*
|
||||
* Visualizes grading consistency and identifies outliers for review.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
|
||||
// Same-origin proxy to avoid CORS issues
|
||||
const API_BASE = '/klausur-api'
|
||||
|
||||
const GRADE_LABELS: Record<number, string> = {
|
||||
15: '1+', 14: '1', 13: '1-', 12: '2+', 11: '2', 10: '2-',
|
||||
9: '3+', 8: '3', 7: '3-', 6: '4+', 5: '4', 4: '4-',
|
||||
3: '5+', 2: '5', 1: '5-', 0: '6'
|
||||
}
|
||||
|
||||
const CRITERION_COLORS: Record<string, string> = {
|
||||
rechtschreibung: '#dc2626',
|
||||
grammatik: '#2563eb',
|
||||
inhalt: '#16a34a',
|
||||
struktur: '#9333ea',
|
||||
stil: '#ea580c',
|
||||
}
|
||||
|
||||
interface FairnessData {
|
||||
klausur_id: string
|
||||
students_count: number
|
||||
graded_count: number
|
||||
statistics: {
|
||||
average_grade: number
|
||||
average_raw_points: number
|
||||
min_grade: number
|
||||
max_grade: number
|
||||
spread: number
|
||||
standard_deviation: number
|
||||
}
|
||||
criteria_breakdown: Record<string, {
|
||||
average: number
|
||||
min: number
|
||||
max: number
|
||||
count: number
|
||||
}>
|
||||
outliers: Array<{
|
||||
student_id: string
|
||||
student_name: string
|
||||
grade_points: number
|
||||
deviation: number
|
||||
direction: 'above' | 'below'
|
||||
}>
|
||||
fairness_score: number
|
||||
warnings: string[]
|
||||
recommendation: string
|
||||
}
|
||||
|
||||
interface Klausur {
|
||||
id: string
|
||||
title: string
|
||||
subject: string
|
||||
students: Array<{
|
||||
id: string
|
||||
student_name: string
|
||||
anonym_id: string
|
||||
grade_points: number
|
||||
criteria_scores: Record<string, { score: number }>
|
||||
}>
|
||||
}
|
||||
|
||||
export default function FairnessDashboardPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const klausurId = params.klausurId as string
|
||||
|
||||
const [klausur, setKlausur] = useState<Klausur | null>(null)
|
||||
const [fairnessData, setFairnessData] = useState<FairnessData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
|
||||
const klausurRes = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}`)
|
||||
if (klausurRes.ok) {
|
||||
setKlausur(await klausurRes.json())
|
||||
}
|
||||
|
||||
const fairnessRes = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/fairness`)
|
||||
if (fairnessRes.ok) {
|
||||
setFairnessData(await fairnessRes.json())
|
||||
} else {
|
||||
const errData = await fairnessRes.json()
|
||||
setError(errData.detail || 'Fehler beim Laden der Fairness-Analyse')
|
||||
}
|
||||
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch data:', err)
|
||||
setError('Fehler beim Laden der Daten')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [klausurId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
const getGradeDistribution = () => {
|
||||
if (!klausur?.students) return []
|
||||
|
||||
const distribution: Record<number, number> = {}
|
||||
for (let i = 0; i <= 15; i++) {
|
||||
distribution[i] = 0
|
||||
}
|
||||
|
||||
klausur.students.forEach(s => {
|
||||
if (s.grade_points >= 0 && s.grade_points <= 15) {
|
||||
distribution[s.grade_points]++
|
||||
}
|
||||
})
|
||||
|
||||
return Object.entries(distribution).map(([grade, count]) => ({
|
||||
grade: parseInt(grade),
|
||||
count,
|
||||
label: GRADE_LABELS[parseInt(grade)] || grade
|
||||
}))
|
||||
}
|
||||
|
||||
const gradeDistribution = getGradeDistribution()
|
||||
const maxCount = Math.max(...gradeDistribution.map(d => d.count), 1)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-slate-200 -mx-4 -mt-6 px-4 py-4 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}`}
|
||||
className="text-purple-600 hover:text-purple-800 flex items-center gap-1 text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Zurueck zur Klausur
|
||||
</Link>
|
||||
|
||||
<div className="text-sm text-slate-500">
|
||||
{fairnessData?.graded_count || 0} von {fairnessData?.students_count || 0} Arbeiten bewertet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Fairness-Analyse</h1>
|
||||
<p className="text-sm text-slate-500">{klausur?.title || ''}</p>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-800">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fairnessData && (
|
||||
<div className="space-y-6">
|
||||
{/* Top Row: Fairness Score + Statistics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Fairness Score Gauge */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Fairness-Score</h3>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="relative w-32 h-32">
|
||||
<svg className="w-32 h-32 transform -rotate-90">
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="56"
|
||||
fill="none"
|
||||
stroke="#e2e8f0"
|
||||
strokeWidth="12"
|
||||
/>
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="56"
|
||||
fill="none"
|
||||
stroke={
|
||||
fairnessData.fairness_score >= 70 ? '#16a34a' :
|
||||
fairnessData.fairness_score >= 40 ? '#eab308' : '#dc2626'
|
||||
}
|
||||
strokeWidth="12"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${(fairnessData.fairness_score / 100) * 352} 352`}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className="text-3xl font-bold">{fairnessData.fairness_score}</span>
|
||||
<span className="text-xs text-slate-500">von 100</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`mt-4 text-center text-sm font-medium ${
|
||||
fairnessData.fairness_score >= 70 ? 'text-green-600' :
|
||||
fairnessData.fairness_score >= 40 ? 'text-yellow-600' : 'text-red-600'
|
||||
}`}>
|
||||
{fairnessData.recommendation}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Statistik</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Durchschnitt</span>
|
||||
<span className="font-semibold">
|
||||
{fairnessData.statistics.average_grade} P ({GRADE_LABELS[Math.round(fairnessData.statistics.average_grade)]})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Minimum</span>
|
||||
<span className="font-semibold">
|
||||
{fairnessData.statistics.min_grade} P ({GRADE_LABELS[fairnessData.statistics.min_grade]})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Maximum</span>
|
||||
<span className="font-semibold">
|
||||
{fairnessData.statistics.max_grade} P ({GRADE_LABELS[fairnessData.statistics.max_grade]})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Spreizung</span>
|
||||
<span className="font-semibold">{fairnessData.statistics.spread} P</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Standardabweichung</span>
|
||||
<span className="font-semibold">{fairnessData.statistics.standard_deviation}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Hinweise</h3>
|
||||
{fairnessData.warnings.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{fairnessData.warnings.map((warning, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm">
|
||||
<svg className="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span className="text-slate-700">{warning}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="text-sm">Keine Auffaelligkeiten erkannt</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grade Distribution Histogram */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Notenverteilung</h3>
|
||||
<div className="flex items-end gap-1 h-48">
|
||||
{gradeDistribution.map(({ grade, count, label }) => (
|
||||
<div key={grade} className="flex-1 flex flex-col items-center">
|
||||
<div
|
||||
className={`w-full rounded-t transition-all ${
|
||||
count > 0 ? 'bg-purple-500' : 'bg-slate-200'
|
||||
}`}
|
||||
style={{ height: `${(count / maxCount) * 160}px`, minHeight: count > 0 ? '8px' : '2px' }}
|
||||
title={`${count} Arbeiten`}
|
||||
/>
|
||||
<div className="text-xs text-slate-500 mt-1 transform -rotate-45 origin-top-left w-6 text-center">
|
||||
{label}
|
||||
</div>
|
||||
{count > 0 && (
|
||||
<div className="text-xs font-medium text-slate-700 mt-1">{count}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-slate-400 mt-6">
|
||||
<span>6 (0 Punkte)</span>
|
||||
<span>1+ (15 Punkte)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Criteria Breakdown Heatmap */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Kriterien-Vergleich</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(fairnessData.criteria_breakdown).map(([criterion, data]) => {
|
||||
const color = CRITERION_COLORS[criterion] || '#6b7280'
|
||||
const range = data.max - data.min
|
||||
|
||||
return (
|
||||
<div key={criterion} className="flex items-center gap-4">
|
||||
<div className="w-32 flex items-center gap-2">
|
||||
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: color }} />
|
||||
<span className="text-sm font-medium capitalize">{criterion}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="relative h-6 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="absolute h-full opacity-30"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
left: `${data.min}%`,
|
||||
width: `${range}%`
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-1 rounded"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
left: `${data.average}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-24 text-right">
|
||||
<span className="text-sm font-semibold">{data.average}%</span>
|
||||
<span className="text-xs text-slate-400 ml-1">avg</span>
|
||||
</div>
|
||||
<div className="w-20 text-right text-xs text-slate-500">
|
||||
{data.min}% - {data.max}%
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outliers List */}
|
||||
{fairnessData.outliers.length > 0 && (
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">
|
||||
Ausreisser ({fairnessData.outliers.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{fairnessData.outliers.map((outlier) => (
|
||||
<div
|
||||
key={outlier.student_id}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border ${
|
||||
outlier.direction === 'above'
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-red-50 border-red-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white font-bold ${
|
||||
outlier.direction === 'above' ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}>
|
||||
{outlier.direction === 'above' ? '↑' : '↓'}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{outlier.student_name}</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{outlier.grade_points} Punkte ({GRADE_LABELS[outlier.grade_points]}) -
|
||||
Abweichung: {outlier.deviation} Punkte {outlier.direction === 'above' ? 'ueber' : 'unter'} Durchschnitt
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}/${outlier.student_id}`}
|
||||
className="px-4 py-2 bg-white border border-slate-300 rounded-lg text-sm hover:bg-slate-50 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
Pruefen
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* All Students Table */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">
|
||||
Alle Arbeiten ({klausur?.students.length || 0})
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200">
|
||||
<th className="text-left py-2 px-3 font-medium text-slate-600">Student</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">Note</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">RS</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">Gram</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">Inhalt</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">Struktur</th>
|
||||
<th className="text-center py-2 px-3 font-medium text-slate-600">Stil</th>
|
||||
<th className="text-right py-2 px-3 font-medium text-slate-600">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{klausur?.students
|
||||
.sort((a, b) => b.grade_points - a.grade_points)
|
||||
.map((student) => {
|
||||
const isOutlier = fairnessData.outliers.some(o => o.student_id === student.id)
|
||||
const outlierInfo = fairnessData.outliers.find(o => o.student_id === student.id)
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={student.id}
|
||||
className={`border-b border-slate-100 ${
|
||||
isOutlier
|
||||
? outlierInfo?.direction === 'above'
|
||||
? 'bg-green-50'
|
||||
: 'bg-red-50'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-2 px-3">
|
||||
<div className="font-medium">{student.anonym_id}</div>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
<span className="font-bold">
|
||||
{student.grade_points} ({GRADE_LABELS[student.grade_points] || '-'})
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{student.criteria_scores?.rechtschreibung?.score ?? '-'}%
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{student.criteria_scores?.grammatik?.score ?? '-'}%
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{student.criteria_scores?.inhalt?.score ?? '-'}%
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{student.criteria_scores?.struktur?.score ?? '-'}%
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{student.criteria_scores?.stil?.score ?? '-'}%
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right">
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}/${student.id}`}
|
||||
className="text-purple-600 hover:text-purple-800 text-sm"
|
||||
>
|
||||
Bearbeiten
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Klausur Detail Page - Student List
|
||||
*
|
||||
* Shows all student works for a specific Klausur with upload capability.
|
||||
* Allows navigation to individual correction workspaces.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import type { Klausur, StudentWork } from '../types'
|
||||
|
||||
// Same-origin proxy to avoid CORS issues
|
||||
const API_BASE = '/klausur-api'
|
||||
|
||||
const statusConfig: Record<string, { color: string; label: string; bg: string }> = {
|
||||
UPLOADED: { color: 'text-gray-600', label: 'Hochgeladen', bg: 'bg-gray-100' },
|
||||
OCR_PROCESSING: { color: 'text-yellow-600', label: 'OCR laeuft', bg: 'bg-yellow-100' },
|
||||
OCR_COMPLETE: { color: 'text-blue-600', label: 'OCR fertig', bg: 'bg-blue-100' },
|
||||
ANALYZING: { color: 'text-purple-600', label: 'Analyse', bg: 'bg-purple-100' },
|
||||
FIRST_EXAMINER: { color: 'text-orange-600', label: 'Erstkorrektur', bg: 'bg-orange-100' },
|
||||
SECOND_EXAMINER: { color: 'text-cyan-600', label: 'Zweitkorrektur', bg: 'bg-cyan-100' },
|
||||
COMPLETED: { color: 'text-green-600', label: 'Fertig', bg: 'bg-green-100' },
|
||||
ERROR: { color: 'text-red-600', label: 'Fehler', bg: 'bg-red-100' },
|
||||
}
|
||||
|
||||
export default function KlausurDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const klausurId = params.klausurId as string
|
||||
|
||||
const [klausur, setKlausur] = useState<Klausur | null>(null)
|
||||
const [students, setStudents] = useState<StudentWork[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const fetchKlausur = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setKlausur(data)
|
||||
} else if (res.status === 404) {
|
||||
setError('Klausur nicht gefunden')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch klausur:', err)
|
||||
setError('Verbindung fehlgeschlagen')
|
||||
}
|
||||
}, [klausurId])
|
||||
|
||||
const fetchStudents = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/students`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStudents(Array.isArray(data) ? data : data.students || [])
|
||||
setError(null)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch students:', err)
|
||||
setError('Fehler beim Laden der Arbeiten')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [klausurId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchKlausur()
|
||||
fetchStudents()
|
||||
}, [fetchKlausur, fetchStudents])
|
||||
|
||||
const exportOverviewPDF = async () => {
|
||||
try {
|
||||
setExporting(true)
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/export/overview`)
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `Notenuebersicht_${klausur?.title?.replace(/\s+/g, '_') || 'Klausur'}_${new Date().toISOString().split('T')[0]}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} else {
|
||||
setError('Fehler beim PDF-Export')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to export overview PDF:', err)
|
||||
setError('Fehler beim PDF-Export')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const exportAllGutachtenPDF = async () => {
|
||||
try {
|
||||
setExporting(true)
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/export/all-gutachten`)
|
||||
if (res.ok) {
|
||||
const blob = await res.blob()
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `Alle_Gutachten_${klausur?.title?.replace(/\s+/g, '_') || 'Klausur'}_${new Date().toISOString().split('T')[0]}.pdf`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
} else {
|
||||
setError('Fehler beim PDF-Export')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to export all gutachten PDF:', err)
|
||||
setError('Fehler beim PDF-Export')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
setUploading(true)
|
||||
setUploadProgress(0)
|
||||
setError(null)
|
||||
|
||||
const totalFiles = files.length
|
||||
let uploadedCount = 0
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/v1/klausuren/${klausurId}/students`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json()
|
||||
console.error(`Failed to upload ${file.name}:`, errorData)
|
||||
}
|
||||
|
||||
uploadedCount++
|
||||
setUploadProgress(Math.round((uploadedCount / totalFiles) * 100))
|
||||
} catch (err) {
|
||||
console.error(`Failed to upload ${file.name}:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
setUploading(false)
|
||||
setUploadProgress(0)
|
||||
fetchStudents()
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteStudent = async (studentId: string) => {
|
||||
if (!confirm('Studentenarbeit wirklich loeschen?')) return
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/v1/students/${studentId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setStudents(prev => prev.filter(s => s.id !== studentId))
|
||||
} else {
|
||||
setError('Fehler beim Loeschen')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete student:', err)
|
||||
setError('Fehler beim Loeschen')
|
||||
}
|
||||
}
|
||||
|
||||
const getGradeDisplay = (student: StudentWork) => {
|
||||
if (student.grade_points === undefined || student.grade_points === null) {
|
||||
return { points: '-', label: '-' }
|
||||
}
|
||||
const labels: Record<number, string> = {
|
||||
15: '1+', 14: '1', 13: '1-', 12: '2+', 11: '2', 10: '2-',
|
||||
9: '3+', 8: '3', 7: '3-', 6: '4+', 5: '4', 4: '4-',
|
||||
3: '5+', 2: '5', 1: '5-', 0: '6'
|
||||
}
|
||||
return {
|
||||
points: student.grade_points.toString(),
|
||||
label: labels[student.grade_points] || '-'
|
||||
}
|
||||
}
|
||||
|
||||
const stats = {
|
||||
total: students.length,
|
||||
completed: students.filter(s => s.status === 'COMPLETED').length,
|
||||
inProgress: students.filter(s => ['FIRST_EXAMINER', 'SECOND_EXAMINER', 'ANALYZING'].includes(s.status)).length,
|
||||
pending: students.filter(s => ['UPLOADED', 'OCR_PROCESSING', 'OCR_COMPLETE'].includes(s.status)).length,
|
||||
avgGrade: students.filter(s => s.grade_points !== undefined && s.grade_points !== null)
|
||||
.reduce((sum, s, _, arr) => sum + (s.grade_points || 0) / arr.length, 0).toFixed(1),
|
||||
}
|
||||
|
||||
if (loading && !klausur) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
{/* Breadcrumb */}
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
href="/education/klausur-korrektur"
|
||||
className="text-purple-600 hover:text-purple-800 flex items-center gap-1 text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Zurueck zur Uebersicht
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Page header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-800">{klausur?.title || 'Klausur'}</h1>
|
||||
<p className="text-sm text-slate-500">{klausur?.subject} - {klausur?.year} | {students.length} Arbeiten</p>
|
||||
</div>
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg flex items-center gap-3">
|
||||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-red-800">{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto text-red-600 hover:text-red-800">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-slate-800">{stats.total}</div>
|
||||
<div className="text-sm text-slate-500">Gesamt</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-green-600">{stats.completed}</div>
|
||||
<div className="text-sm text-slate-500">Fertig</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-orange-600">{stats.inProgress}</div>
|
||||
<div className="text-sm text-slate-500">In Arbeit</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-gray-600">{stats.pending}</div>
|
||||
<div className="text-sm text-slate-500">Ausstehend</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-4">
|
||||
<div className="text-2xl font-bold text-purple-600">{stats.avgGrade}</div>
|
||||
<div className="text-sm text-slate-500">Durchschnitt Note</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fairness Analysis Button */}
|
||||
{stats.completed >= 2 && (
|
||||
<div className="mb-6 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}/fairness`}
|
||||
className="inline-flex items-center gap-2 px-4 py-3 bg-gradient-to-r from-purple-600 to-indigo-600 text-white rounded-lg hover:from-purple-700 hover:to-indigo-700 transition-all shadow-sm"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
Fairness-Analyse oeffnen
|
||||
<span className="text-xs bg-white/20 px-2 py-0.5 rounded-full">
|
||||
{stats.completed} bewertet
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={exportOverviewPDF}
|
||||
disabled={exporting}
|
||||
className="inline-flex items-center gap-2 px-4 py-3 bg-white border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-all shadow-sm disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
|
||||
{exporting ? 'Exportiere...' : 'Notenuebersicht PDF'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={exportAllGutachtenPDF}
|
||||
disabled={exporting}
|
||||
className="inline-flex items-center gap-2 px-4 py-3 bg-white border border-slate-300 text-slate-700 rounded-lg hover:bg-slate-50 transition-all shadow-sm disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-5 h-5" 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>
|
||||
{exporting ? 'Exportiere...' : 'Alle Gutachten PDF'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Section */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-800">Studentenarbeiten hochladen</h2>
|
||||
<p className="text-sm text-slate-500">PDF oder Bilder (JPG, PNG) der gescannten Arbeiten</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className={`px-4 py-2 rounded-lg flex items-center gap-2 cursor-pointer ${
|
||||
uploading
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{uploadProgress}%
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
Dateien hochladen
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{uploading && (
|
||||
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-purple-600 rounded-full transition-all"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Students List */}
|
||||
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-200">
|
||||
<h2 className="text-lg font-semibold text-slate-800">Studentenarbeiten ({students.length})</h2>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-purple-600"></div>
|
||||
</div>
|
||||
) : students.length === 0 ? (
|
||||
<div className="p-8 text-center text-slate-500">
|
||||
<svg className="mx-auto h-12 w-12 text-slate-300 mb-3" 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>
|
||||
<p>Noch keine Arbeiten hochgeladen</p>
|
||||
<p className="text-sm">Laden Sie gescannte PDFs oder Bilder hoch</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-slate-200">
|
||||
{students.map((student, index) => {
|
||||
const grade = getGradeDisplay(student)
|
||||
const status = statusConfig[student.status] || statusConfig.UPLOADED
|
||||
|
||||
return (
|
||||
<div
|
||||
key={student.id}
|
||||
className="p-4 hover:bg-slate-50 flex items-center gap-4"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-sm font-medium text-slate-600">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-slate-800 truncate">
|
||||
{student.anonym_id || `Arbeit ${index + 1}`}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${status.bg} ${status.color}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center w-20">
|
||||
<div className="text-lg font-bold text-slate-800">{grade.points}</div>
|
||||
<div className="text-xs text-slate-500">{grade.label}</div>
|
||||
</div>
|
||||
|
||||
<div className="w-24">
|
||||
{student.criteria_scores && Object.keys(student.criteria_scores).length > 0 ? (
|
||||
<div className="flex gap-1">
|
||||
{['rechtschreibung', 'grammatik', 'inhalt', 'struktur', 'stil'].map(criterion => (
|
||||
<div
|
||||
key={criterion}
|
||||
className={`h-2 flex-1 rounded-full ${
|
||||
student.criteria_scores[criterion] !== undefined
|
||||
? 'bg-green-500'
|
||||
: 'bg-slate-200'
|
||||
}`}
|
||||
title={criterion}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-400">Keine Bewertung</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}/${student.id}`}
|
||||
className="px-3 py-1.5 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
Korrigieren
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDeleteStudent(student.id)}
|
||||
className="p-1.5 text-red-600 hover:bg-red-50 rounded-lg"
|
||||
title="Loeschen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fairness Check Button */}
|
||||
{students.filter(s => s.status === 'COMPLETED').length >= 3 && (
|
||||
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-blue-800">Fairness-Check verfuegbar</h3>
|
||||
<p className="text-sm text-blue-600">
|
||||
Pruefen Sie die Bewertungen auf Konsistenz und Fairness
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/education/klausur-korrektur/${klausurId}/fairness`}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Fairness-Check starten
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AnnotationLayer
|
||||
*
|
||||
* SVG overlay component for displaying and creating annotations on documents.
|
||||
* Renders positioned rectangles with color-coding by annotation type.
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import type { Annotation, AnnotationType, AnnotationPosition } from '../types'
|
||||
import { ANNOTATION_COLORS } from '../types'
|
||||
|
||||
interface AnnotationLayerProps {
|
||||
annotations: Annotation[]
|
||||
selectedTool: AnnotationType | null
|
||||
onCreateAnnotation: (position: AnnotationPosition, type: AnnotationType) => void
|
||||
onSelectAnnotation: (annotation: Annotation) => void
|
||||
selectedAnnotationId?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function AnnotationLayer({
|
||||
annotations,
|
||||
selectedTool,
|
||||
onCreateAnnotation,
|
||||
onSelectAnnotation,
|
||||
selectedAnnotationId,
|
||||
disabled = false,
|
||||
}: AnnotationLayerProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const [isDrawing, setIsDrawing] = useState(false)
|
||||
const [startPos, setStartPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const [currentRect, setCurrentRect] = useState<AnnotationPosition | null>(null)
|
||||
|
||||
// Convert mouse position to percentage
|
||||
const getPercentPosition = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!svgRef.current) return null
|
||||
|
||||
const rect = svgRef.current.getBoundingClientRect()
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100
|
||||
|
||||
return { x: Math.max(0, Math.min(100, x)), y: Math.max(0, Math.min(100, y)) }
|
||||
}, [])
|
||||
|
||||
// Handle mouse down - start drawing
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (disabled || !selectedTool) return
|
||||
|
||||
const pos = getPercentPosition(e)
|
||||
if (!pos) return
|
||||
|
||||
setIsDrawing(true)
|
||||
setStartPos(pos)
|
||||
setCurrentRect({ x: pos.x, y: pos.y, width: 0, height: 0 })
|
||||
},
|
||||
[disabled, selectedTool, getPercentPosition]
|
||||
)
|
||||
|
||||
// Handle mouse move - update rectangle
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<SVGSVGElement>) => {
|
||||
if (!isDrawing || !startPos) return
|
||||
|
||||
const pos = getPercentPosition(e)
|
||||
if (!pos) return
|
||||
|
||||
const x = Math.min(startPos.x, pos.x)
|
||||
const y = Math.min(startPos.y, pos.y)
|
||||
const width = Math.abs(pos.x - startPos.x)
|
||||
const height = Math.abs(pos.y - startPos.y)
|
||||
|
||||
setCurrentRect({ x, y, width, height })
|
||||
},
|
||||
[isDrawing, startPos, getPercentPosition]
|
||||
)
|
||||
|
||||
// Handle mouse up - finish drawing
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!isDrawing || !currentRect || !selectedTool) {
|
||||
setIsDrawing(false)
|
||||
setStartPos(null)
|
||||
setCurrentRect(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Only create annotation if rectangle is large enough (min 1% x 0.5%)
|
||||
if (currentRect.width > 1 && currentRect.height > 0.5) {
|
||||
onCreateAnnotation(currentRect, selectedTool)
|
||||
}
|
||||
|
||||
setIsDrawing(false)
|
||||
setStartPos(null)
|
||||
setCurrentRect(null)
|
||||
}, [isDrawing, currentRect, selectedTool, onCreateAnnotation])
|
||||
|
||||
// Handle clicking on existing annotation
|
||||
const handleAnnotationClick = useCallback(
|
||||
(e: React.MouseEvent, annotation: Annotation) => {
|
||||
e.stopPropagation()
|
||||
onSelectAnnotation(annotation)
|
||||
},
|
||||
[onSelectAnnotation]
|
||||
)
|
||||
|
||||
return (
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className={`absolute inset-0 w-full h-full ${
|
||||
selectedTool && !disabled ? 'cursor-crosshair' : 'cursor-default'
|
||||
}`}
|
||||
style={{ pointerEvents: disabled ? 'none' : 'auto' }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
{/* SVG Defs for patterns */}
|
||||
<defs>
|
||||
{/* Wavy pattern for Rechtschreibung errors */}
|
||||
<pattern id="wavyPattern" patternUnits="userSpaceOnUse" width="10" height="4">
|
||||
<path
|
||||
d="M0 2 Q 2.5 0, 5 2 T 10 2"
|
||||
stroke="#dc2626"
|
||||
strokeWidth="1.5"
|
||||
fill="none"
|
||||
/>
|
||||
</pattern>
|
||||
{/* Straight underline pattern for Grammatik errors */}
|
||||
<pattern id="straightPattern" patternUnits="userSpaceOnUse" width="6" height="3">
|
||||
<line x1="0" y1="1.5" x2="6" y2="1.5" stroke="#2563eb" strokeWidth="1.5" />
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
{/* Existing annotations */}
|
||||
{annotations.map((annotation) => {
|
||||
const isSelected = annotation.id === selectedAnnotationId
|
||||
const color = ANNOTATION_COLORS[annotation.type] || '#6b7280'
|
||||
const isRS = annotation.type === 'rechtschreibung'
|
||||
const isGram = annotation.type === 'grammatik'
|
||||
|
||||
return (
|
||||
<g key={annotation.id} onClick={(e) => handleAnnotationClick(e, annotation)}>
|
||||
{/* Background rectangle - different styles for RS/Gram */}
|
||||
{isRS || isGram ? (
|
||||
<>
|
||||
{/* Light highlight background */}
|
||||
<rect
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y}%`}
|
||||
width={`${annotation.position.width}%`}
|
||||
height={`${annotation.position.height}%`}
|
||||
fill={color}
|
||||
fillOpacity={isSelected ? 0.25 : 0.15}
|
||||
className="cursor-pointer hover:fill-opacity-25 transition-all"
|
||||
/>
|
||||
{/* Underline - wavy for RS, straight for Gram */}
|
||||
<rect
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y + annotation.position.height - 0.5}%`}
|
||||
width={`${annotation.position.width}%`}
|
||||
height="0.5%"
|
||||
fill={isRS ? 'url(#wavyPattern)' : color}
|
||||
stroke="none"
|
||||
/>
|
||||
{/* Border when selected */}
|
||||
{isSelected && (
|
||||
<rect
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y}%`}
|
||||
width={`${annotation.position.width}%`}
|
||||
height={`${annotation.position.height}%`}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4,2"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Standard rectangle for other annotation types */
|
||||
<rect
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y}%`}
|
||||
width={`${annotation.position.width}%`}
|
||||
height={`${annotation.position.height}%`}
|
||||
fill={color}
|
||||
fillOpacity={0.2}
|
||||
stroke={color}
|
||||
strokeWidth={isSelected ? 3 : 2}
|
||||
strokeDasharray={annotation.severity === 'minor' ? '4,2' : undefined}
|
||||
className="cursor-pointer hover:fill-opacity-30 transition-all"
|
||||
rx="2"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Type indicator icon (small circle in corner) */}
|
||||
<circle
|
||||
cx={`${annotation.position.x}%`}
|
||||
cy={`${annotation.position.y}%`}
|
||||
r="6"
|
||||
fill={color}
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{/* Type letter */}
|
||||
<text
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y}%`}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="white"
|
||||
fontSize="8"
|
||||
fontWeight="bold"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{annotation.type.charAt(0).toUpperCase()}
|
||||
</text>
|
||||
|
||||
{/* Severity indicator (small dot) */}
|
||||
{annotation.severity === 'critical' && (
|
||||
<circle
|
||||
cx={`${annotation.position.x + annotation.position.width}%`}
|
||||
cy={`${annotation.position.y}%`}
|
||||
r="4"
|
||||
fill="#dc2626"
|
||||
stroke="white"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Selection indicator */}
|
||||
{isSelected && (
|
||||
<>
|
||||
{/* Corner handles */}
|
||||
{[
|
||||
{ cx: annotation.position.x, cy: annotation.position.y },
|
||||
{ cx: annotation.position.x + annotation.position.width, cy: annotation.position.y },
|
||||
{ cx: annotation.position.x, cy: annotation.position.y + annotation.position.height },
|
||||
{
|
||||
cx: annotation.position.x + annotation.position.width,
|
||||
cy: annotation.position.y + annotation.position.height,
|
||||
},
|
||||
].map((corner, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={`${corner.cx}%`}
|
||||
cy={`${corner.cy}%`}
|
||||
r="4"
|
||||
fill="white"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Currently drawing rectangle */}
|
||||
{currentRect && selectedTool && (
|
||||
<rect
|
||||
x={`${currentRect.x}%`}
|
||||
y={`${currentRect.y}%`}
|
||||
width={`${currentRect.width}%`}
|
||||
height={`${currentRect.height}%`}
|
||||
fill={ANNOTATION_COLORS[selectedTool]}
|
||||
fillOpacity={0.3}
|
||||
stroke={ANNOTATION_COLORS[selectedTool]}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5,5"
|
||||
rx="2"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AnnotationPanel
|
||||
*
|
||||
* Panel for viewing, editing, and managing annotations.
|
||||
* Shows a list of all annotations with options to edit text, change severity, or delete.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { Annotation, AnnotationType } from '../types'
|
||||
import { ANNOTATION_COLORS } from '../types'
|
||||
|
||||
interface AnnotationPanelProps {
|
||||
annotations: Annotation[]
|
||||
selectedAnnotation: Annotation | null
|
||||
onSelectAnnotation: (annotation: Annotation | null) => void
|
||||
onUpdateAnnotation: (id: string, updates: Partial<Annotation>) => void
|
||||
onDeleteAnnotation: (id: string) => void
|
||||
}
|
||||
|
||||
const SEVERITY_OPTIONS = [
|
||||
{ value: 'minor', label: 'Leicht', color: '#fbbf24' },
|
||||
{ value: 'major', label: 'Mittel', color: '#f97316' },
|
||||
{ value: 'critical', label: 'Schwer', color: '#dc2626' },
|
||||
] as const
|
||||
|
||||
const TYPE_LABELS: Record<AnnotationType, string> = {
|
||||
rechtschreibung: 'Rechtschreibung',
|
||||
grammatik: 'Grammatik',
|
||||
inhalt: 'Inhalt',
|
||||
struktur: 'Struktur',
|
||||
stil: 'Stil',
|
||||
comment: 'Kommentar',
|
||||
highlight: 'Markierung',
|
||||
}
|
||||
|
||||
export default function AnnotationPanel({
|
||||
annotations,
|
||||
selectedAnnotation,
|
||||
onSelectAnnotation,
|
||||
onUpdateAnnotation,
|
||||
onDeleteAnnotation,
|
||||
}: AnnotationPanelProps) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editText, setEditText] = useState('')
|
||||
const [editSuggestion, setEditSuggestion] = useState('')
|
||||
|
||||
// Group annotations by type
|
||||
const groupedAnnotations = annotations.reduce(
|
||||
(acc, ann) => {
|
||||
if (!acc[ann.type]) {
|
||||
acc[ann.type] = []
|
||||
}
|
||||
acc[ann.type].push(ann)
|
||||
return acc
|
||||
},
|
||||
{} as Record<AnnotationType, Annotation[]>
|
||||
)
|
||||
|
||||
const handleEdit = (annotation: Annotation) => {
|
||||
setEditingId(annotation.id)
|
||||
setEditText(annotation.text)
|
||||
setEditSuggestion(annotation.suggestion || '')
|
||||
}
|
||||
|
||||
const handleSaveEdit = (id: string) => {
|
||||
onUpdateAnnotation(id, { text: editText, suggestion: editSuggestion || undefined })
|
||||
setEditingId(null)
|
||||
setEditText('')
|
||||
setEditSuggestion('')
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingId(null)
|
||||
setEditText('')
|
||||
setEditSuggestion('')
|
||||
}
|
||||
|
||||
if (annotations.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-center text-slate-500">
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm">Keine Annotationen vorhanden</p>
|
||||
<p className="text-xs mt-1">Waehlen Sie ein Werkzeug und markieren Sie Stellen im Dokument</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
{/* Summary */}
|
||||
<div className="p-3 border-b border-slate-200 bg-slate-50">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-slate-700">{annotations.length} Annotationen</span>
|
||||
<div className="flex gap-2">
|
||||
{Object.entries(groupedAnnotations).map(([type, anns]) => (
|
||||
<span
|
||||
key={type}
|
||||
className="px-2 py-0.5 text-xs rounded-full text-white"
|
||||
style={{ backgroundColor: ANNOTATION_COLORS[type as AnnotationType] }}
|
||||
>
|
||||
{anns.length}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Annotations list by type */}
|
||||
<div className="divide-y divide-slate-100">
|
||||
{(Object.entries(groupedAnnotations) as [AnnotationType, Annotation[]][]).map(([type, anns]) => (
|
||||
<div key={type}>
|
||||
{/* Type header */}
|
||||
<div
|
||||
className="px-3 py-2 text-xs font-semibold text-white"
|
||||
style={{ backgroundColor: ANNOTATION_COLORS[type] }}
|
||||
>
|
||||
{TYPE_LABELS[type]} ({anns.length})
|
||||
</div>
|
||||
|
||||
{/* Annotations in this type */}
|
||||
{anns.map((annotation) => {
|
||||
const isSelected = selectedAnnotation?.id === annotation.id
|
||||
const isEditing = editingId === annotation.id
|
||||
const severityInfo = SEVERITY_OPTIONS.find((s) => s.value === annotation.severity)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={annotation.id}
|
||||
className={`p-3 cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-blue-50 border-l-4 border-blue-500' : 'hover:bg-slate-50'
|
||||
}`}
|
||||
onClick={() => onSelectAnnotation(isSelected ? null : annotation)}
|
||||
>
|
||||
{isEditing ? (
|
||||
/* Edit mode */
|
||||
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
|
||||
<textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
placeholder="Kommentar..."
|
||||
className="w-full p-2 text-sm border border-slate-300 rounded resize-none focus:ring-2 focus:ring-purple-500"
|
||||
rows={2}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{(type === 'rechtschreibung' || type === 'grammatik') && (
|
||||
<input
|
||||
type="text"
|
||||
value={editSuggestion}
|
||||
onChange={(e) => setEditSuggestion(e.target.value)}
|
||||
placeholder="Korrekturvorschlag..."
|
||||
className="w-full p-2 text-sm border border-slate-300 rounded focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleSaveEdit(annotation.id)}
|
||||
className="flex-1 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="flex-1 py-1 text-xs bg-slate-200 text-slate-700 rounded hover:bg-slate-300"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* View mode */
|
||||
<>
|
||||
{/* Severity badge */}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span
|
||||
className="px-1.5 py-0.5 text-[10px] rounded text-white"
|
||||
style={{ backgroundColor: severityInfo?.color || '#6b7280' }}
|
||||
>
|
||||
{severityInfo?.label || 'Unbekannt'}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">Seite {annotation.page}</span>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
{annotation.text && <p className="text-sm text-slate-700 mb-1">{annotation.text}</p>}
|
||||
|
||||
{/* Suggestion */}
|
||||
{annotation.suggestion && (
|
||||
<p className="text-xs text-green-700 bg-green-50 px-2 py-1 rounded mb-1">
|
||||
<span className="font-medium">Korrektur:</span> {annotation.suggestion}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Actions (only when selected) */}
|
||||
{isSelected && (
|
||||
<div className="flex gap-2 mt-2 pt-2 border-t border-slate-200">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEdit(annotation)
|
||||
}}
|
||||
className="flex-1 py-1 text-xs bg-slate-100 text-slate-700 rounded hover:bg-slate-200"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
|
||||
{/* Severity buttons */}
|
||||
<div className="flex gap-1">
|
||||
{SEVERITY_OPTIONS.map((sev) => (
|
||||
<button
|
||||
key={sev.value}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onUpdateAnnotation(annotation.id, { severity: sev.value })
|
||||
}}
|
||||
className={`w-6 h-6 rounded text-xs text-white font-bold ${
|
||||
annotation.severity === sev.value ? 'ring-2 ring-offset-1 ring-slate-400' : ''
|
||||
}`}
|
||||
style={{ backgroundColor: sev.color }}
|
||||
title={sev.label}
|
||||
>
|
||||
{sev.label[0]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm('Annotation loeschen?')) {
|
||||
onDeleteAnnotation(annotation.id)
|
||||
}
|
||||
}}
|
||||
className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AnnotationToolbar
|
||||
*
|
||||
* Toolbar for selecting annotation tools and controlling the document viewer.
|
||||
*/
|
||||
|
||||
import type { AnnotationType } from '../types'
|
||||
import { ANNOTATION_COLORS } from '../types'
|
||||
|
||||
interface AnnotationToolbarProps {
|
||||
selectedTool: AnnotationType | null
|
||||
onSelectTool: (tool: AnnotationType | null) => void
|
||||
zoom: number
|
||||
onZoomChange: (zoom: number) => void
|
||||
annotationCounts: Record<AnnotationType, number>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const ANNOTATION_TOOLS: { type: AnnotationType; label: string; shortcut: string }[] = [
|
||||
{ type: 'rechtschreibung', label: 'Rechtschreibung', shortcut: 'R' },
|
||||
{ type: 'grammatik', label: 'Grammatik', shortcut: 'G' },
|
||||
{ type: 'inhalt', label: 'Inhalt', shortcut: 'I' },
|
||||
{ type: 'struktur', label: 'Struktur', shortcut: 'S' },
|
||||
{ type: 'stil', label: 'Stil', shortcut: 'T' },
|
||||
{ type: 'comment', label: 'Kommentar', shortcut: 'K' },
|
||||
]
|
||||
|
||||
export default function AnnotationToolbar({
|
||||
selectedTool,
|
||||
onSelectTool,
|
||||
zoom,
|
||||
onZoomChange,
|
||||
annotationCounts,
|
||||
disabled = false,
|
||||
}: AnnotationToolbarProps) {
|
||||
const handleToolClick = (type: AnnotationType) => {
|
||||
if (disabled) return
|
||||
onSelectTool(selectedTool === type ? null : type)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-3 border-b border-slate-200 flex items-center justify-between bg-slate-50">
|
||||
{/* Annotation tools */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-slate-500 mr-2">Markieren:</span>
|
||||
{ANNOTATION_TOOLS.map(({ type, label, shortcut }) => {
|
||||
const isSelected = selectedTool === type
|
||||
const count = annotationCounts[type] || 0
|
||||
const color = ANNOTATION_COLORS[type]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => handleToolClick(type)}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
relative px-2 py-1.5 text-xs rounded border-2 transition-all
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'hover:opacity-80'}
|
||||
${isSelected ? 'ring-2 ring-offset-1 ring-slate-400' : ''}
|
||||
`}
|
||||
style={{
|
||||
borderColor: color,
|
||||
color: isSelected ? 'white' : color,
|
||||
backgroundColor: isSelected ? color : 'transparent',
|
||||
}}
|
||||
title={`${label} (${shortcut})`}
|
||||
>
|
||||
<span className="font-medium">{shortcut}</span>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className="absolute -top-2 -right-2 w-4 h-4 text-[10px] rounded-full flex items-center justify-center text-white"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Clear selection button */}
|
||||
{selectedTool && (
|
||||
<button
|
||||
onClick={() => onSelectTool(null)}
|
||||
className="ml-2 px-2 py-1 text-xs text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mode indicator */}
|
||||
{selectedTool && (
|
||||
<div
|
||||
className="px-3 py-1 text-xs rounded-full text-white"
|
||||
style={{ backgroundColor: ANNOTATION_COLORS[selectedTool] }}
|
||||
>
|
||||
{ANNOTATION_TOOLS.find((t) => t.type === selectedTool)?.label || selectedTool}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onZoomChange(Math.max(50, zoom - 10))}
|
||||
disabled={zoom <= 50}
|
||||
className="p-1 rounded hover:bg-slate-200 disabled:opacity-50"
|
||||
title="Verkleinern"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-sm w-12 text-center">{zoom}%</span>
|
||||
<button
|
||||
onClick={() => onZoomChange(Math.min(200, zoom + 10))}
|
||||
disabled={zoom >= 200}
|
||||
className="p-1 rounded hover:bg-slate-200 disabled:opacity-50"
|
||||
title="Vergroessern"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onZoomChange(100)}
|
||||
className="px-2 py-1 text-xs rounded hover:bg-slate-200"
|
||||
title="Zuruecksetzen"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* EHSuggestionPanel
|
||||
*
|
||||
* Panel for displaying Erwartungshorizont-based suggestions.
|
||||
* Uses RAG to find relevant passages from the linked EH.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { AnnotationType } from '../types'
|
||||
import { ANNOTATION_COLORS } from '../types'
|
||||
|
||||
interface EHSuggestion {
|
||||
id: string
|
||||
eh_id: string
|
||||
eh_title: string
|
||||
text: string
|
||||
score: number
|
||||
criterion: string
|
||||
source_chunk_index: number
|
||||
decrypted: boolean
|
||||
}
|
||||
|
||||
interface EHSuggestionPanelProps {
|
||||
studentId: string
|
||||
klausurId: string
|
||||
hasEH: boolean
|
||||
apiBase: string
|
||||
onInsertSuggestion?: (text: string, criterion: string) => void
|
||||
}
|
||||
|
||||
const CRITERIA = [
|
||||
{ id: 'allgemein', label: 'Alle Kriterien' },
|
||||
{ id: 'inhalt', label: 'Inhalt', color: '#16a34a' },
|
||||
{ id: 'struktur', label: 'Struktur', color: '#9333ea' },
|
||||
{ id: 'stil', label: 'Stil', color: '#ea580c' },
|
||||
]
|
||||
|
||||
export default function EHSuggestionPanel({
|
||||
studentId,
|
||||
klausurId,
|
||||
hasEH,
|
||||
apiBase,
|
||||
onInsertSuggestion,
|
||||
}: EHSuggestionPanelProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [suggestions, setSuggestions] = useState<EHSuggestion[]>([])
|
||||
const [selectedCriterion, setSelectedCriterion] = useState<string>('allgemein')
|
||||
const [passphrase, setPassphrase] = useState('')
|
||||
const [needsPassphrase, setNeedsPassphrase] = useState(false)
|
||||
const [queryPreview, setQueryPreview] = useState<string | null>(null)
|
||||
|
||||
const fetchSuggestions = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const res = await fetch(`${apiBase}/api/v1/students/${studentId}/eh-suggestions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
criterion: selectedCriterion === 'allgemein' ? null : selectedCriterion,
|
||||
passphrase: passphrase || null,
|
||||
limit: 5,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.detail || 'Fehler beim Laden der Vorschlaege')
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (data.needs_passphrase) {
|
||||
setNeedsPassphrase(true)
|
||||
setSuggestions([])
|
||||
setError(data.message)
|
||||
} else {
|
||||
setNeedsPassphrase(false)
|
||||
setSuggestions(data.suggestions || [])
|
||||
setQueryPreview(data.query_preview || null)
|
||||
|
||||
if (data.suggestions?.length === 0) {
|
||||
setError(data.message || 'Keine passenden Vorschlaege gefunden')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch EH suggestions:', err)
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [apiBase, studentId, selectedCriterion, passphrase])
|
||||
|
||||
const handleInsert = (suggestion: EHSuggestion) => {
|
||||
if (onInsertSuggestion) {
|
||||
onInsertSuggestion(suggestion.text, suggestion.criterion)
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasEH) {
|
||||
return (
|
||||
<div className="p-4 text-center">
|
||||
<div className="text-slate-400 mb-4">
|
||||
<svg className="w-12 h-12 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
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>
|
||||
<p className="text-sm">Kein Erwartungshorizont verknuepft</p>
|
||||
<p className="text-xs mt-1">Laden Sie einen EH in der RAG-Verwaltung hoch</p>
|
||||
</div>
|
||||
<a
|
||||
href="/ai/rag"
|
||||
className="inline-block px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
Zur RAG-Verwaltung
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Criterion selector */}
|
||||
<div className="p-3 border-b border-slate-200 bg-slate-50">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{CRITERIA.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setSelectedCriterion(c.id)}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
selectedCriterion === c.id
|
||||
? 'text-white'
|
||||
: 'bg-slate-200 text-slate-600 hover:bg-slate-300'
|
||||
}`}
|
||||
style={
|
||||
selectedCriterion === c.id
|
||||
? { backgroundColor: c.color || '#6366f1' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Passphrase input (if needed) */}
|
||||
{needsPassphrase && (
|
||||
<div className="p-3 bg-yellow-50 border-b border-yellow-200">
|
||||
<label className="block text-xs font-medium text-yellow-800 mb-1">
|
||||
EH-Passphrase (verschluesselt)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
placeholder="Passphrase eingeben..."
|
||||
className="flex-1 px-2 py-1 text-sm border border-yellow-300 rounded focus:ring-2 focus:ring-yellow-500"
|
||||
/>
|
||||
<button
|
||||
onClick={fetchSuggestions}
|
||||
disabled={!passphrase}
|
||||
className="px-3 py-1 text-xs bg-yellow-600 text-white rounded hover:bg-yellow-700 disabled:opacity-50"
|
||||
>
|
||||
Laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fetch button */}
|
||||
<div className="p-3 border-b border-slate-200">
|
||||
<button
|
||||
onClick={fetchSuggestions}
|
||||
disabled={loading}
|
||||
className="w-full py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Lade Vorschlaege...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" 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>
|
||||
EH-Vorschlaege laden
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Query preview */}
|
||||
{queryPreview && (
|
||||
<div className="px-3 py-2 bg-slate-50 border-b border-slate-200">
|
||||
<div className="text-xs text-slate-500 mb-1">Basierend auf:</div>
|
||||
<div className="text-xs text-slate-700 italic truncate">"{queryPreview}"</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && !needsPassphrase && (
|
||||
<div className="p-3 bg-red-50 border-b border-red-200">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions list */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{suggestions.length === 0 && !loading && !error && (
|
||||
<div className="p-4 text-center text-slate-400 text-sm">
|
||||
Klicken Sie auf "EH-Vorschlaege laden" um passende Stellen aus dem Erwartungshorizont zu
|
||||
finden.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suggestions.map((suggestion, idx) => (
|
||||
<div
|
||||
key={suggestion.id}
|
||||
className="p-3 border-b border-slate-100 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-slate-500">#{idx + 1}</span>
|
||||
<span
|
||||
className="px-1.5 py-0.5 text-[10px] rounded text-white"
|
||||
style={{
|
||||
backgroundColor:
|
||||
ANNOTATION_COLORS[suggestion.criterion as AnnotationType] || '#6366f1',
|
||||
}}
|
||||
>
|
||||
{suggestion.criterion}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400">
|
||||
Relevanz: {Math.round(suggestion.score * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
{!suggestion.decrypted && (
|
||||
<span className="text-[10px] text-yellow-600">Verschluesselt</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<p className="text-sm text-slate-700 mb-2 line-clamp-4">{suggestion.text}</p>
|
||||
|
||||
{/* Source */}
|
||||
<div className="flex items-center justify-between text-[10px] text-slate-400">
|
||||
<span>Quelle: {suggestion.eh_title}</span>
|
||||
{onInsertSuggestion && suggestion.decrypted && (
|
||||
<button
|
||||
onClick={() => handleInsert(suggestion)}
|
||||
className="px-2 py-1 bg-purple-100 text-purple-700 rounded hover:bg-purple-200"
|
||||
>
|
||||
Im Gutachten verwenden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as AnnotationLayer } from './AnnotationLayer'
|
||||
export { default as AnnotationPanel } from './AnnotationPanel'
|
||||
export { default as AnnotationToolbar } from './AnnotationToolbar'
|
||||
export { default as EHSuggestionPanel } from './EHSuggestionPanel'
|
||||
1121
admin-v2/app/(admin)/education/klausur-korrektur/page.tsx
Normal file
1121
admin-v2/app/(admin)/education/klausur-korrektur/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
195
admin-v2/app/(admin)/education/klausur-korrektur/types.ts
Normal file
195
admin-v2/app/(admin)/education/klausur-korrektur/types.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
// TypeScript Interfaces für Klausur-Korrektur
|
||||
|
||||
export interface Klausur {
|
||||
id: string
|
||||
title: string
|
||||
subject: string
|
||||
year: number
|
||||
semester: string
|
||||
modus: 'abitur' | 'vorabitur'
|
||||
eh_id?: string
|
||||
created_at: string
|
||||
student_count?: number
|
||||
completed_count?: number
|
||||
status?: 'draft' | 'in_progress' | 'completed'
|
||||
}
|
||||
|
||||
export interface StudentWork {
|
||||
id: string
|
||||
klausur_id: string
|
||||
anonym_id: string
|
||||
file_path: string
|
||||
file_type: 'pdf' | 'image'
|
||||
ocr_text: string
|
||||
criteria_scores: CriteriaScores
|
||||
gutachten: string
|
||||
status: StudentStatus
|
||||
raw_points: number
|
||||
grade_points: number
|
||||
grade_label?: string
|
||||
created_at: string
|
||||
examiner_id?: string
|
||||
second_examiner_id?: string
|
||||
second_examiner_grade?: number
|
||||
}
|
||||
|
||||
export type StudentStatus =
|
||||
| 'UPLOADED'
|
||||
| 'OCR_PROCESSING'
|
||||
| 'OCR_COMPLETE'
|
||||
| 'ANALYZING'
|
||||
| 'FIRST_EXAMINER'
|
||||
| 'SECOND_EXAMINER'
|
||||
| 'COMPLETED'
|
||||
| 'ERROR'
|
||||
|
||||
export interface CriteriaScores {
|
||||
rechtschreibung?: number
|
||||
grammatik?: number
|
||||
inhalt?: number
|
||||
struktur?: number
|
||||
stil?: number
|
||||
[key: string]: number | undefined
|
||||
}
|
||||
|
||||
export interface Criterion {
|
||||
id: string
|
||||
name: string
|
||||
weight: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface GradeInfo {
|
||||
thresholds: Record<number, number>
|
||||
labels: Record<number, string>
|
||||
criteria: Record<string, Criterion>
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
id: string
|
||||
student_work_id: string
|
||||
page: number
|
||||
position: AnnotationPosition
|
||||
type: AnnotationType
|
||||
text: string
|
||||
severity: 'minor' | 'major' | 'critical'
|
||||
suggestion?: string
|
||||
created_by: string
|
||||
created_at: string
|
||||
role: 'first_examiner' | 'second_examiner'
|
||||
linked_criterion?: string
|
||||
}
|
||||
|
||||
export interface AnnotationPosition {
|
||||
x: number // Prozent (0-100)
|
||||
y: number // Prozent (0-100)
|
||||
width: number // Prozent (0-100)
|
||||
height: number // Prozent (0-100)
|
||||
}
|
||||
|
||||
export type AnnotationType =
|
||||
| 'rechtschreibung'
|
||||
| 'grammatik'
|
||||
| 'inhalt'
|
||||
| 'struktur'
|
||||
| 'stil'
|
||||
| 'comment'
|
||||
| 'highlight'
|
||||
|
||||
export interface FairnessAnalysis {
|
||||
klausur_id: string
|
||||
student_count: number
|
||||
average_grade: number
|
||||
std_deviation: number
|
||||
spread: number
|
||||
outliers: OutlierInfo[]
|
||||
criteria_analysis: Record<string, CriteriaStats>
|
||||
fairness_score: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface OutlierInfo {
|
||||
student_id: string
|
||||
anonym_id: string
|
||||
grade_points: number
|
||||
deviation: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface CriteriaStats {
|
||||
min: number
|
||||
max: number
|
||||
average: number
|
||||
std_deviation: number
|
||||
}
|
||||
|
||||
export interface EHSuggestion {
|
||||
criterion: string
|
||||
excerpt: string
|
||||
relevance_score: number
|
||||
source_chunk_id: string
|
||||
}
|
||||
|
||||
export interface GutachtenSection {
|
||||
title: string
|
||||
content: string
|
||||
evidence_links?: string[]
|
||||
}
|
||||
|
||||
export interface Gutachten {
|
||||
einleitung: string
|
||||
hauptteil: string
|
||||
fazit: string
|
||||
staerken: string[]
|
||||
schwaechen: string[]
|
||||
generated_at?: string
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
export interface KlausurenResponse {
|
||||
klausuren: Klausur[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface StudentsResponse {
|
||||
students: StudentWork[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface AnnotationsResponse {
|
||||
annotations: Annotation[]
|
||||
}
|
||||
|
||||
// Color mapping for annotation types
|
||||
export const ANNOTATION_COLORS: Record<AnnotationType, string> = {
|
||||
rechtschreibung: '#dc2626', // Red
|
||||
grammatik: '#2563eb', // Blue
|
||||
inhalt: '#16a34a', // Green
|
||||
struktur: '#9333ea', // Purple
|
||||
stil: '#ea580c', // Orange
|
||||
comment: '#6b7280', // Gray
|
||||
highlight: '#eab308', // Yellow
|
||||
}
|
||||
|
||||
// Status colors
|
||||
export const STATUS_COLORS: Record<StudentStatus, string> = {
|
||||
UPLOADED: '#6b7280',
|
||||
OCR_PROCESSING: '#eab308',
|
||||
OCR_COMPLETE: '#3b82f6',
|
||||
ANALYZING: '#8b5cf6',
|
||||
FIRST_EXAMINER: '#f97316',
|
||||
SECOND_EXAMINER: '#06b6d4',
|
||||
COMPLETED: '#22c55e',
|
||||
ERROR: '#ef4444',
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<StudentStatus, string> = {
|
||||
UPLOADED: 'Hochgeladen',
|
||||
OCR_PROCESSING: 'OCR laeuft',
|
||||
OCR_COMPLETE: 'OCR fertig',
|
||||
ANALYZING: 'Analyse laeuft',
|
||||
FIRST_EXAMINER: 'Erstkorrektur',
|
||||
SECOND_EXAMINER: 'Zweitkorrektur',
|
||||
COMPLETED: 'Abgeschlossen',
|
||||
ERROR: 'Fehler',
|
||||
}
|
||||
181
admin-v2/app/(admin)/education/zeugnisse-crawler/page.tsx
Normal file
181
admin-v2/app/(admin)/education/zeugnisse-crawler/page.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Zeugnisse-Crawler Page
|
||||
* Verwaltet Zeugnis-Strukturen und -Vorlagen
|
||||
*/
|
||||
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { getModuleByHref } from '@/lib/navigation'
|
||||
import { FileText, Upload, Settings, Database, RefreshCw } from 'lucide-react'
|
||||
|
||||
export default function ZeugnisseCrawlerPage() {
|
||||
const moduleInfo = getModuleByHref('/education/zeugnisse-crawler')
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{moduleInfo && (
|
||||
<PagePurpose
|
||||
title={moduleInfo.module.name}
|
||||
purpose={moduleInfo.module.purpose}
|
||||
audience={moduleInfo.module.audience}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-blue-600">16</div>
|
||||
<div className="text-sm text-slate-500">Bundeslaender</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-green-600">48</div>
|
||||
<div className="text-sm text-slate-500">Zeugnis-Vorlagen</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-purple-600">12</div>
|
||||
<div className="text-sm text-slate-500">Schulformen</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-3xl font-bold text-orange-600">156</div>
|
||||
<div className="text-sm text-slate-500">Felder erkannt</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-900 mb-4">Zeugnis-Strukturen</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Upload Card */}
|
||||
<div className="border border-dashed border-slate-300 rounded-xl p-6 text-center hover:border-blue-500 hover:bg-blue-50/50 transition-colors cursor-pointer">
|
||||
<Upload className="w-10 h-10 mx-auto mb-3 text-slate-400" />
|
||||
<div className="font-medium text-slate-700">Zeugnis hochladen</div>
|
||||
<div className="text-sm text-slate-500 mt-1">PDF oder Bild</div>
|
||||
</div>
|
||||
|
||||
{/* Niedersachsen */}
|
||||
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">Niedersachsen</div>
|
||||
<div className="text-xs text-slate-500">12 Vorlagen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Grundschule</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gymnasium</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">IGS</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bayern */}
|
||||
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">Bayern</div>
|
||||
<div className="text-xs text-slate-500">10 Vorlagen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Grundschule</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gymnasium</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Realschule</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NRW */}
|
||||
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">Nordrhein-Westfalen</div>
|
||||
<div className="text-xs text-slate-500">14 Vorlagen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Grundschule</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gesamtschule</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gymnasium</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Baden-Württemberg */}
|
||||
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<FileText className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-900">Baden-Wuerttemberg</div>
|
||||
<div className="text-xs text-slate-500">8 Vorlagen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Grundschule</span>
|
||||
<span className="px-2 py-0.5 bg-slate-100 text-slate-600 text-xs rounded">Gymnasium</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weitere */}
|
||||
<div className="border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow bg-slate-50">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Database className="w-8 h-8 text-slate-400" />
|
||||
<div>
|
||||
<div className="font-medium text-slate-700">Weitere Bundeslaender</div>
|
||||
<div className="text-xs text-slate-500">4 Vorlagen</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
Hessen, Sachsen, Berlin, Hamburg...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Crawler Section */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
Crawler-Status
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">Schulportal NI</span>
|
||||
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full">Aktiv</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Letzter Crawl: vor 2 Stunden</div>
|
||||
</div>
|
||||
<div className="border border-slate-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-medium">KMK Vorlagen</span>
|
||||
<span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full">Aktiv</span>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Letzter Crawl: vor 1 Tag</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-blue-800 flex items-center gap-2">
|
||||
<Settings className="w-5 h-5" />
|
||||
Verwandte Module
|
||||
</h3>
|
||||
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<a href="/education/edu-search" className="block p-3 bg-white rounded-lg hover:shadow-md transition-shadow">
|
||||
<div className="font-medium text-slate-900">Education Search</div>
|
||||
<div className="text-sm text-slate-500">Bildungsdokumente durchsuchen</div>
|
||||
</a>
|
||||
<a href="/ai/rag-pipeline" className="block p-3 bg-white rounded-lg hover:shadow-md transition-shadow">
|
||||
<div className="font-medium text-slate-900">RAG Pipeline</div>
|
||||
<div className="text-sm text-slate-500">Dokumente indexieren</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { DevOpsPipelineSidebarResponsive } from '@/components/infrastructure/DevOpsPipelineSidebar'
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -364,6 +365,9 @@ export default function CICDPage() {
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* DevOps Pipeline Sidebar */}
|
||||
<DevOpsPipelineSidebarResponsive currentTool="ci-cd" />
|
||||
|
||||
{/* Messages */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { DevOpsPipelineSidebarResponsive } from '@/components/infrastructure/DevOpsPipelineSidebar'
|
||||
|
||||
interface Component {
|
||||
type: string
|
||||
@@ -71,6 +72,7 @@ const INFRASTRUCTURE_COMPONENTS: Component[] = [
|
||||
// ===== SECURITY =====
|
||||
{ type: 'service', name: 'HashiCorp Vault', version: '1.15', category: 'security', port: '8200', description: 'Secrets Management', license: 'BUSL-1.1', sourceUrl: 'https://github.com/hashicorp/vault' },
|
||||
{ type: 'service', name: 'Keycloak', version: '23.0', category: 'security', port: '8180', description: 'Identity Provider (SSO/OIDC)', license: 'Apache-2.0', sourceUrl: 'https://github.com/keycloak/keycloak' },
|
||||
{ type: 'service', name: 'NetBird', version: '0.64.5', category: 'security', port: '-', description: 'Zero-Trust Mesh VPN (WireGuard-basiert)', license: 'BSD-3-Clause', sourceUrl: 'https://github.com/netbirdio/netbird' },
|
||||
|
||||
// ===== COMMUNICATION =====
|
||||
{ type: 'service', name: 'Matrix Synapse', version: 'latest', category: 'communication', port: '8008', description: 'E2EE Messenger Server', license: 'AGPL-3.0', sourceUrl: 'https://github.com/element-hq/synapse' },
|
||||
@@ -110,6 +112,7 @@ const INFRASTRUCTURE_COMPONENTS: Component[] = [
|
||||
// ===== CI/CD & VERSION CONTROL =====
|
||||
{ type: 'service', name: 'Woodpecker CI', version: '2.x', category: 'cicd', port: '8082', description: 'Self-hosted CI/CD Pipeline (Drone Fork)', license: 'Apache-2.0', sourceUrl: 'https://github.com/woodpecker-ci/woodpecker' },
|
||||
{ type: 'service', name: 'Gitea', version: '1.21', category: 'cicd', port: '3003', description: 'Self-hosted Git Service', license: 'MIT', sourceUrl: 'https://github.com/go-gitea/gitea' },
|
||||
{ type: 'service', name: 'Dokploy', version: '0.26.7', category: 'cicd', port: '3000', description: 'Self-hosted PaaS (Vercel/Heroku Alternative)', license: 'Apache-2.0', sourceUrl: 'https://github.com/Dokploy/dokploy' },
|
||||
|
||||
// ===== DEVELOPMENT =====
|
||||
{ type: 'service', name: 'Mailpit', version: 'latest', category: 'development', port: '8025/1025', description: 'E-Mail Testing (SMTP Catch-All)', license: 'MIT', sourceUrl: 'https://github.com/axllent/mailpit' },
|
||||
@@ -216,6 +219,11 @@ const NODE_PACKAGES: Component[] = [
|
||||
{ type: 'library', name: 'Material Design Icons', version: 'latest', category: 'nodejs', description: 'Icon-System (Companion UI, Studio)', license: 'Apache-2.0', sourceUrl: 'https://github.com/google/material-design-icons' },
|
||||
{ type: 'library', name: 'Recharts', version: '2.12', category: 'nodejs', description: 'React Charts (Compliance Dashboard)', license: 'MIT', sourceUrl: 'https://github.com/recharts/recharts' },
|
||||
{ type: 'library', name: 'React Flow', version: '11.x', category: 'nodejs', description: 'Node-basierte Flow-Diagramme (Screen Flow)', license: 'MIT', sourceUrl: 'https://github.com/xyflow/xyflow' },
|
||||
{ type: 'library', name: 'Playwright', version: '1.50', category: 'nodejs', description: 'E2E Testing Framework (SDK Tests)', license: 'Apache-2.0', sourceUrl: 'https://github.com/microsoft/playwright' },
|
||||
{ type: 'library', name: 'Vitest', version: '4.x', category: 'nodejs', description: 'Unit Testing Framework', license: 'MIT', sourceUrl: 'https://github.com/vitest-dev/vitest' },
|
||||
{ type: 'library', name: 'jsPDF', version: '4.x', category: 'nodejs', description: 'PDF Generation (SDK Export)', license: 'MIT', sourceUrl: 'https://github.com/parallax/jsPDF' },
|
||||
{ type: 'library', name: 'JSZip', version: '3.x', category: 'nodejs', description: 'ZIP File Creation (SDK Export)', license: 'MIT/GPL-3.0', sourceUrl: 'https://github.com/Stuk/jszip' },
|
||||
{ type: 'library', name: 'Lucide React', version: '0.468', category: 'nodejs', description: 'Icon Library', license: 'ISC', sourceUrl: 'https://github.com/lucide-icons/lucide' },
|
||||
]
|
||||
|
||||
// Unity packages (Breakpilot Drive game engine)
|
||||
@@ -422,6 +430,9 @@ export default function SBOMPage() {
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* DevOps Pipeline Sidebar */}
|
||||
<DevOpsPipelineSidebarResponsive currentTool="sbom" />
|
||||
|
||||
{/* Wizard Link */}
|
||||
<div className="mb-6 flex justify-end">
|
||||
<Link
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { DevOpsPipelineSidebarResponsive } from '@/components/infrastructure/DevOpsPipelineSidebar'
|
||||
|
||||
interface ToolStatus {
|
||||
name: string
|
||||
@@ -304,6 +305,9 @@ export default function SecurityDashboardPage() {
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* DevOps Pipeline Sidebar */}
|
||||
<DevOpsPipelineSidebarResponsive currentTool="security" />
|
||||
|
||||
{/* Header with Status */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
|
||||
@@ -3,18 +3,22 @@
|
||||
/**
|
||||
* Test Dashboard - Zentrales Test-Registry
|
||||
*
|
||||
* Aggregiert alle 195+ Tests aus allen Services:
|
||||
* Aggregiert alle 280+ Tests aus allen Services:
|
||||
* - Go Unit Tests (~57)
|
||||
* - Python Tests (~50)
|
||||
* - BQAS Golden (97)
|
||||
* - BQAS RAG (~20)
|
||||
* - TypeScript Jest (~8)
|
||||
* - SDK Vitest Unit Tests (~43)
|
||||
* - SDK Playwright E2E (~25)
|
||||
* - E2E Playwright (~5)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { PagePurpose } from '@/components/common/PagePurpose'
|
||||
import { DevOpsPipelineSidebarResponsive } from '@/components/infrastructure/DevOpsPipelineSidebar'
|
||||
import type { LLMRoutingOption } from '@/types/infrastructure-modules'
|
||||
import type {
|
||||
ServiceTestInfo,
|
||||
TestRegistryStats,
|
||||
@@ -344,6 +348,7 @@ function FrameworkDistribution({ data }: { data: Record<string, number> }) {
|
||||
go_test: 'Go Tests',
|
||||
pytest: 'Python (pytest)',
|
||||
jest: 'Jest (TS)',
|
||||
vitest: 'Vitest (SDK)',
|
||||
playwright: 'Playwright (E2E)',
|
||||
bqas_golden: 'BQAS Golden',
|
||||
bqas_rag: 'BQAS RAG',
|
||||
@@ -354,6 +359,7 @@ function FrameworkDistribution({ data }: { data: Record<string, number> }) {
|
||||
go_test: 'bg-cyan-500',
|
||||
pytest: 'bg-yellow-500',
|
||||
jest: 'bg-blue-500',
|
||||
vitest: 'bg-orange-500',
|
||||
playwright: 'bg-purple-500',
|
||||
bqas_golden: 'bg-emerald-500',
|
||||
bqas_rag: 'bg-teal-500',
|
||||
@@ -454,15 +460,16 @@ function GuideTab() {
|
||||
Was ist das Test Dashboard?
|
||||
</h2>
|
||||
<p className="text-slate-700 leading-relaxed">
|
||||
Das <strong>Test Dashboard</strong> ist die zentrale Uebersicht fuer alle 195+ Tests im Breakpilot-System.
|
||||
Das <strong>Test Dashboard</strong> ist die zentrale Uebersicht fuer alle 260+ Tests im Breakpilot-System.
|
||||
Es aggregiert Tests aus verschiedenen Services (Go, Python, TypeScript) ohne diese physisch zu migrieren.
|
||||
Tests bleiben an ihren konventionellen Orten, werden aber hier zentral ueberwacht und ausgefuehrt.
|
||||
Seit 2026-02 inklusive AI Compliance SDK Unit Tests (Vitest) und E2E Tests (Playwright).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Test-Kategorien</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="p-4 bg-cyan-50 rounded-lg border border-cyan-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xl">🐹</span>
|
||||
@@ -508,41 +515,70 @@ function GuideTab() {
|
||||
Website Unit Tests fuer React-Komponenten
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-orange-50 rounded-lg border border-orange-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xl">⚡</span>
|
||||
<h4 className="font-medium text-orange-800">SDK Vitest (~43)</h4>
|
||||
</div>
|
||||
<p className="text-sm text-orange-700">
|
||||
AI Compliance SDK Unit Tests: Types, Export, Components, Reducer
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-purple-50 rounded-lg border border-purple-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xl">🎭</span>
|
||||
<h4 className="font-medium text-purple-800">E2E Playwright (~5)</h4>
|
||||
<h4 className="font-medium text-purple-800">SDK Playwright (~25)</h4>
|
||||
</div>
|
||||
<p className="text-sm text-purple-700">
|
||||
SDK E2E Tests: Navigation, Workflow, Command Bar, Export
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xl">🌐</span>
|
||||
<h4 className="font-medium text-slate-800">Website E2E (~5)</h4>
|
||||
</div>
|
||||
<p className="text-sm text-slate-700">
|
||||
End-to-End Tests fuer kritische User Flows
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-indigo-50 rounded-lg border border-indigo-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-xl">🔗</span>
|
||||
<h4 className="font-medium text-indigo-800">Integration Tests (~15)</h4>
|
||||
</div>
|
||||
<p className="text-sm text-indigo-700">
|
||||
Docker Compose basierte E2E-Tests mit Backend, Consent-Service, DB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Architektur</h3>
|
||||
<pre className="bg-slate-50 p-4 rounded-lg text-xs overflow-x-auto">
|
||||
{`┌────────────────────────────────────────────────────────────┐
|
||||
│ Admin-v2 Test Dashboard │
|
||||
│ /infrastructure/tests │
|
||||
├────────────────────────────────────────────────────────────┤
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Unit Tests │ │ Integration │ │ BQAS │ │
|
||||
│ │ (Go, Py) │ │ Tests │ │ (LLM/RAG) │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ▼ ▼ ▼ │
|
||||
│ ┌────────────────────────────────────────────────────┐ │
|
||||
│ │ Test Registry API │ │
|
||||
│ │ /backend/api/tests/registry.py │ │
|
||||
│ └────────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
{`┌────────────────────────────────────────────────────────────────────┐
|
||||
│ Admin-v2 Test Dashboard │
|
||||
│ /infrastructure/tests │
|
||||
├────────────────────────────────────────────────────────────────────┤
|
||||
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌─────────────┐ │
|
||||
│ │ Unit Tests │ │ SDK Tests │ │ BQAS │ │ E2E Tests │ │
|
||||
│ │ (Go, Py) │ │ (Vitest) │ │ (LLM/RAG) │ │ (Playwright)│ │
|
||||
│ └────────────┘ └────────────┘ └────────────┘ └─────────────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ ▼ ▼ ▼ ▼ │
|
||||
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Test Registry API │ │
|
||||
│ │ /backend/api/tests/registry.py │ │
|
||||
│ └──────────────────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Tests bleiben wo sie sind:
|
||||
- /consent-service/internal/**/*_test.go
|
||||
- /backend/tests/test_*.py
|
||||
- /voice-service/tests/bqas/`}
|
||||
- /voice-service/tests/bqas/
|
||||
- /admin-v2/components/sdk/__tests__/*.test.ts (Vitest)
|
||||
- /admin-v2/e2e/specs/*.spec.ts (Playwright)`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -792,6 +828,8 @@ function BacklogTab({
|
||||
const [filterStatus, setFilterStatus] = useState<string>('open')
|
||||
const [filterService, setFilterService] = useState<string>('all')
|
||||
const [filterPriority, setFilterPriority] = useState<string>('all')
|
||||
const [llmAutoAnalysis, setLlmAutoAnalysis] = useState<boolean>(true)
|
||||
const [llmRouting, setLlmRouting] = useState<LLMRoutingOption>('smart_routing')
|
||||
|
||||
// Nutze PostgreSQL-Backlog wenn verfuegbar, sonst Legacy
|
||||
const items = usePostgres && backlogItems ? backlogItems : failedTests
|
||||
@@ -881,6 +919,93 @@ function BacklogTab({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LLM Analysis Toggle */}
|
||||
<div className="bg-gradient-to-r from-violet-50 to-purple-50 border border-violet-200 rounded-xl p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-violet-100 rounded-lg flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-800">Automatische LLM-Analyse</h4>
|
||||
<p className="text-xs text-slate-500">KI-gestuetzte Fix-Vorschlaege fuer Backlog-Eintraege</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={llmAutoAnalysis}
|
||||
onChange={(e) => setLlmAutoAnalysis(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-slate-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-violet-300 rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-violet-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{llmAutoAnalysis && (
|
||||
<div className="mt-4 pt-4 border-t border-violet-200">
|
||||
<p className="text-xs text-slate-600 mb-3">LLM-Routing Strategie:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<label className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-colors ${
|
||||
llmRouting === 'local_only'
|
||||
? 'bg-violet-100 border-violet-300 text-violet-800'
|
||||
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="llm-routing"
|
||||
value="local_only"
|
||||
checked={llmRouting === 'local_only'}
|
||||
onChange={() => setLlmRouting('local_only')}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-sm font-medium">Nur lokales 32B LLM</span>
|
||||
<span className="text-xs px-1.5 py-0.5 bg-emerald-100 text-emerald-700 rounded">DSGVO</span>
|
||||
</label>
|
||||
<label className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-colors ${
|
||||
llmRouting === 'claude_preferred'
|
||||
? 'bg-violet-100 border-violet-300 text-violet-800'
|
||||
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="llm-routing"
|
||||
value="claude_preferred"
|
||||
checked={llmRouting === 'claude_preferred'}
|
||||
onChange={() => setLlmRouting('claude_preferred')}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-sm font-medium">Claude bevorzugt</span>
|
||||
<span className="text-xs px-1.5 py-0.5 bg-blue-100 text-blue-700 rounded">Qualitaet</span>
|
||||
</label>
|
||||
<label className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-colors ${
|
||||
llmRouting === 'smart_routing'
|
||||
? 'bg-violet-100 border-violet-300 text-violet-800'
|
||||
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="llm-routing"
|
||||
value="smart_routing"
|
||||
checked={llmRouting === 'smart_routing'}
|
||||
onChange={() => setLlmRouting('smart_routing')}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-sm font-medium">Smart Routing</span>
|
||||
<span className="text-xs px-1.5 py-0.5 bg-amber-100 text-amber-700 rounded">Empfohlen</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-2">
|
||||
{llmRouting === 'local_only' && 'Alle Analysen werden mit Qwen2.5-32B lokal durchgefuehrt. Keine Daten verlassen den Server.'}
|
||||
{llmRouting === 'claude_preferred' && 'Verwendet Claude fuer beste Fix-Qualitaet. Nur Code-Snippets werden uebertragen.'}
|
||||
{llmRouting === 'smart_routing' && 'Privacy Classifier entscheidet automatisch: Sensitive Daten → lokal, Code → Claude.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex flex-wrap gap-4 items-center">
|
||||
<div>
|
||||
@@ -1066,18 +1191,21 @@ export default function TestDashboardPage() {
|
||||
{ service: 'klausur-service', display_name: 'Klausur Service', port: 8086, language: 'python', total_tests: 8, passed_tests: 8, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 71.2, last_run: new Date().toISOString(), status: 'passed' },
|
||||
{ service: 'billing-service', display_name: 'Billing Service', port: 8082, language: 'go', total_tests: 5, passed_tests: 5, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 78.5, last_run: new Date().toISOString(), status: 'passed' },
|
||||
{ service: 'school-service', display_name: 'School Service', port: 8084, language: 'go', total_tests: 6, passed_tests: 6, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 81.4, last_run: new Date().toISOString(), status: 'passed' },
|
||||
{ service: 'sdk-unit', display_name: 'SDK Unit Tests (Vitest)', port: undefined, language: 'typescript', total_tests: 43, passed_tests: 43, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: 85.2, last_run: new Date().toISOString(), status: 'passed' },
|
||||
{ service: 'sdk-e2e', display_name: 'SDK E2E Tests (Playwright)', port: undefined, language: 'typescript', total_tests: 25, passed_tests: 25, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'passed' },
|
||||
{ service: 'integration-tests', display_name: 'Integration Tests', port: undefined, language: 'python', total_tests: 15, passed_tests: 15, failed_tests: 0, skipped_tests: 0, pass_rate: 100, coverage_percent: undefined, last_run: new Date().toISOString(), status: 'passed' },
|
||||
]
|
||||
|
||||
const DEMO_STATS: TestRegistryStats = {
|
||||
total_tests: 195,
|
||||
total_passed: 180,
|
||||
total_tests: 278,
|
||||
total_passed: 263,
|
||||
total_failed: 15,
|
||||
total_skipped: 0,
|
||||
overall_pass_rate: 92.3,
|
||||
average_coverage: 76.8,
|
||||
services_count: 8,
|
||||
by_category: { unit: 75, bqas: 117, e2e: 5 },
|
||||
by_framework: { go_test: 57, pytest: 53, bqas_golden: 97, bqas_rag: 20, jest: 8, playwright: 5 },
|
||||
overall_pass_rate: 94.6,
|
||||
average_coverage: 78.5,
|
||||
services_count: 11,
|
||||
by_category: { unit: 118, bqas: 117, e2e: 30, integration: 15 },
|
||||
by_framework: { go_test: 57, pytest: 68, bqas_golden: 97, bqas_rag: 20, jest: 8, vitest: 43, playwright: 30 },
|
||||
}
|
||||
|
||||
// Fetch data
|
||||
@@ -1460,21 +1588,25 @@ export default function TestDashboardPage() {
|
||||
|
||||
<PagePurpose
|
||||
title="Test Dashboard"
|
||||
purpose="Zentrales Dashboard fuer alle 195+ Tests. Aggregiert Unit Tests (Go, Python), Integration Tests, E2E (Playwright) und BQAS Quality Tests aus allen Services ohne physische Migration."
|
||||
purpose="Zentrales Dashboard fuer alle 260+ Tests. Aggregiert Unit Tests (Go, Python), SDK Tests (Vitest), E2E Tests (Playwright) und BQAS Quality Tests aus allen Services ohne physische Migration."
|
||||
audience={['Entwickler', 'QA', 'DevOps']}
|
||||
architecture={{
|
||||
services: ['Python Backend (Port 8000)', 'Voice Service (Port 8091)'],
|
||||
databases: ['PostgreSQL'],
|
||||
services: ['Python Backend (Port 8000)', 'Voice Service (Port 8091)', 'SDK Backend (Port 8085)'],
|
||||
databases: ['PostgreSQL', 'Qdrant'],
|
||||
}}
|
||||
relatedPages={[
|
||||
{ name: 'BQAS Dashboard', href: '/ai/test-quality', description: 'Detaillierte LLM-Qualitaetsmetriken' },
|
||||
{ name: 'CI/CD', href: '/infrastructure/ci-cd', description: 'Pipelines und Deployments' },
|
||||
{ name: 'Security', href: '/infrastructure/security', description: 'DevSecOps Dashboard' },
|
||||
{ name: 'Developer Portal', href: '/developers', description: 'SDK & API Dokumentation' },
|
||||
]}
|
||||
collapsible={true}
|
||||
defaultCollapsed={true}
|
||||
/>
|
||||
|
||||
{/* DevOps Pipeline Sidebar */}
|
||||
<DevOpsPipelineSidebarResponsive currentTool="tests" />
|
||||
|
||||
{error && (
|
||||
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 flex items-center gap-3">
|
||||
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import {
|
||||
Database,
|
||||
FileText,
|
||||
List,
|
||||
Table,
|
||||
AlertTriangle,
|
||||
Shield,
|
||||
Clock,
|
||||
Users,
|
||||
Globe,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
DataPoint,
|
||||
DataPointCategory,
|
||||
@@ -22,70 +56,79 @@ const PLACEHOLDERS = [
|
||||
{
|
||||
placeholder: '[DATENPUNKTE_TABLE]',
|
||||
label: { de: 'Tabelle', en: 'Table' },
|
||||
description: { de: 'Markdown-Tabelle mit allen Datenpunkten', en: 'Markdown table with all data points' },
|
||||
description: { de: 'Fügt eine Markdown-Tabelle mit allen Datenpunkten ein', en: 'Inserts a markdown table with all data points' },
|
||||
icon: Table,
|
||||
},
|
||||
{
|
||||
placeholder: '[DATENPUNKTE_LIST]',
|
||||
label: { de: 'Liste', en: 'List' },
|
||||
description: { de: 'Kommaseparierte Liste der Namen', en: 'Comma-separated list of names' },
|
||||
description: { de: 'Kommaseparierte Liste der Datenpunkt-Namen', en: 'Comma-separated list of data point names' },
|
||||
icon: List,
|
||||
},
|
||||
{
|
||||
placeholder: '[VERARBEITUNGSZWECKE]',
|
||||
label: { de: 'Zwecke', en: 'Purposes' },
|
||||
description: { de: 'Alle Verarbeitungszwecke', en: 'All processing purposes' },
|
||||
description: { de: 'Alle Verarbeitungszwecke (dedupliziert)', en: 'All processing purposes (deduplicated)' },
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
placeholder: '[RECHTSGRUNDLAGEN]',
|
||||
label: { de: 'Rechtsgrundlagen', en: 'Legal Bases' },
|
||||
description: { de: 'DSGVO-Artikel', en: 'GDPR articles' },
|
||||
description: { de: 'Verwendete DSGVO-Artikel', en: 'Used GDPR articles' },
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
placeholder: '[SPEICHERFRISTEN]',
|
||||
label: { de: 'Speicherfristen', en: 'Retention' },
|
||||
description: { de: 'Fristen nach Kategorie', en: 'Periods by category' },
|
||||
description: { de: 'Fristen gruppiert nach Kategorie', en: 'Periods grouped by category' },
|
||||
icon: Clock,
|
||||
},
|
||||
{
|
||||
placeholder: '[EMPFAENGER]',
|
||||
label: { de: 'Empfänger', en: 'Recipients' },
|
||||
description: { de: 'Liste aller Drittparteien', en: 'List of third parties' },
|
||||
description: { de: 'Liste aller Drittparteien', en: 'List of all third parties' },
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
placeholder: '[BESONDERE_KATEGORIEN]',
|
||||
label: { de: 'Art. 9', en: 'Art. 9' },
|
||||
description: { de: 'Abschnitt für sensible Daten', en: 'Section for sensitive data' },
|
||||
label: { de: 'Art. 9 Abschnitt', en: 'Art. 9 Section' },
|
||||
description: { de: 'DSGVO-konformer Abschnitt für sensible Daten', en: 'GDPR-compliant section for sensitive data' },
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
{
|
||||
placeholder: '[DRITTLAND_TRANSFERS]',
|
||||
label: { de: 'Drittländer', en: 'Third Countries' },
|
||||
description: { de: 'Datenübermittlung außerhalb EU', en: 'Data transfers outside EU' },
|
||||
description: { de: 'Abschnitt zu Datenübermittlung außerhalb EU', en: 'Section about data transfers outside EU' },
|
||||
icon: Globe,
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Risiko-Badge Farben
|
||||
* Risiko-Badge Varianten mapping
|
||||
*/
|
||||
function getRiskBadgeColor(riskLevel: RiskLevel): string {
|
||||
function getRiskBadgeVariant(riskLevel: RiskLevel): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
switch (riskLevel) {
|
||||
case 'HIGH':
|
||||
return 'bg-red-100 text-red-700 border-red-200'
|
||||
return 'destructive'
|
||||
case 'MEDIUM':
|
||||
return 'bg-yellow-100 text-yellow-700 border-yellow-200'
|
||||
return 'secondary'
|
||||
case 'LOW':
|
||||
default:
|
||||
return 'bg-green-100 text-green-700 border-green-200'
|
||||
return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DataPointsPreview Komponente
|
||||
*
|
||||
* Zeigt eine Vorschau der ausgewählten Einwilligungen-Datenpunkte im Dokumentengenerator.
|
||||
* Ermöglicht das schnelle Einfügen von Platzhaltern.
|
||||
*/
|
||||
export function DataPointsPreview({
|
||||
dataPoints,
|
||||
onInsertPlaceholder,
|
||||
language = 'de',
|
||||
}: DataPointsPreviewProps) {
|
||||
const [expandedCategories, setExpandedCategories] = useState<string[]>([])
|
||||
|
||||
// Gruppiere Datenpunkte nach Kategorie
|
||||
const byCategory = useMemo(() => {
|
||||
return dataPoints.reduce((acc, dp) => {
|
||||
@@ -101,15 +144,21 @@ export function DataPointsPreview({
|
||||
const stats = useMemo(() => {
|
||||
const riskCounts: Record<RiskLevel, number> = { LOW: 0, MEDIUM: 0, HIGH: 0 }
|
||||
let specialCategoryCount = 0
|
||||
let explicitConsentCount = 0
|
||||
const recipients = new Set<string>()
|
||||
|
||||
dataPoints.forEach(dp => {
|
||||
riskCounts[dp.riskLevel]++
|
||||
if (dp.isSpecialCategory) specialCategoryCount++
|
||||
if (dp.requiresExplicitConsent) explicitConsentCount++
|
||||
dp.thirdPartyRecipients?.forEach(r => recipients.add(r))
|
||||
})
|
||||
|
||||
return {
|
||||
riskCounts,
|
||||
specialCategoryCount,
|
||||
explicitConsentCount,
|
||||
recipientCount: recipients.size,
|
||||
categoryCount: Object.keys(byCategory).length,
|
||||
}
|
||||
}, [dataPoints, byCategory])
|
||||
@@ -123,173 +172,195 @@ export function DataPointsPreview({
|
||||
})
|
||||
}, [byCategory])
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setExpandedCategories(prev =>
|
||||
prev.includes(category)
|
||||
? prev.filter(c => c !== category)
|
||||
: [...prev, category]
|
||||
)
|
||||
}
|
||||
|
||||
if (dataPoints.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h4 className="font-semibold text-gray-900 flex items-center gap-2 mb-3">
|
||||
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
{language === 'de' ? 'Einwilligungen' : 'Consents'}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
{language === 'de'
|
||||
? 'Keine Datenpunkte ausgewählt. Wählen Sie Datenpunkte im Einwilligungs-Schritt aus.'
|
||||
: 'No data points selected. Select data points in the consent step.'}
|
||||
</p>
|
||||
</div>
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
{language === 'de' ? 'Einwilligungen' : 'Consents'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{language === 'de'
|
||||
? 'Keine Datenpunkte ausgewählt. Wählen Sie Datenpunkte im Einwilligungs-Schritt aus, um sie hier zu sehen.'
|
||||
: 'No data points selected. Select data points in the consent step to see them here.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 h-full flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<h4 className="font-semibold text-gray-900 flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4" />
|
||||
</svg>
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
{language === 'de' ? 'Einwilligungen' : 'Consents'}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{dataPoints.length} {language === 'de' ? 'Datenpunkte aus' : 'data points from'}{' '}
|
||||
{stats.categoryCount} {language === 'de' ? 'Kategorien' : 'categories'}
|
||||
</p>
|
||||
</div>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{/* Statistik-Badges */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{stats.riskCounts.HIGH > 0 && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">
|
||||
{stats.riskCounts.HIGH} {language === 'de' ? 'Hoch' : 'High'}
|
||||
</span>
|
||||
)}
|
||||
{stats.riskCounts.MEDIUM > 0 && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-700">
|
||||
{stats.riskCounts.MEDIUM} {language === 'de' ? 'Mittel' : 'Medium'}
|
||||
</span>
|
||||
)}
|
||||
{stats.riskCounts.LOW > 0 && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">
|
||||
{stats.riskCounts.LOW} {language === 'de' ? 'Niedrig' : 'Low'}
|
||||
</span>
|
||||
)}
|
||||
{stats.specialCategoryCount > 0 && (
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-orange-100 text-orange-700 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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
{stats.specialCategoryCount} Art. 9
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="flex-1 flex flex-col gap-4 overflow-hidden">
|
||||
{/* Statistik-Badges */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats.riskCounts.HIGH > 0 && (
|
||||
<Badge variant="destructive">
|
||||
{stats.riskCounts.HIGH} {language === 'de' ? 'Hoch' : 'High'}
|
||||
</Badge>
|
||||
)}
|
||||
{stats.riskCounts.MEDIUM > 0 && (
|
||||
<Badge variant="secondary">
|
||||
{stats.riskCounts.MEDIUM} {language === 'de' ? 'Mittel' : 'Medium'}
|
||||
</Badge>
|
||||
)}
|
||||
{stats.riskCounts.LOW > 0 && (
|
||||
<Badge variant="outline">
|
||||
{stats.riskCounts.LOW} {language === 'de' ? 'Niedrig' : 'Low'}
|
||||
</Badge>
|
||||
)}
|
||||
{stats.specialCategoryCount > 0 && (
|
||||
<Badge variant="destructive" className="bg-orange-500 hover:bg-orange-600">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
{stats.specialCategoryCount} Art. 9
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 my-3"></div>
|
||||
<Separator />
|
||||
|
||||
{/* Datenpunkte nach Kategorie */}
|
||||
<div className="flex-1 overflow-y-auto space-y-2 max-h-64">
|
||||
{sortedCategories.map(([category, points]) => {
|
||||
const metadata = CATEGORY_METADATA[category as DataPointCategory]
|
||||
if (!metadata) return null
|
||||
const isExpanded = expandedCategories.includes(category)
|
||||
{/* Datenpunkte nach Kategorie */}
|
||||
<ScrollArea className="flex-1 -mr-4 pr-4">
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{sortedCategories.map(([category, points]) => {
|
||||
const metadata = CATEGORY_METADATA[category as DataPointCategory]
|
||||
if (!metadata) return null
|
||||
|
||||
return (
|
||||
<div key={category} className="border border-gray-100 rounded-lg">
|
||||
<button
|
||||
onClick={() => toggleCategory(category)}
|
||||
className="w-full flex items-center justify-between p-2 text-sm hover:bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-gray-400">{metadata.code}</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{language === 'de' ? metadata.name.de : metadata.name.en}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-1.5 py-0.5 text-xs rounded bg-gray-100 text-gray-600">
|
||||
{points.length}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<ul className="px-2 pb-2 space-y-1">
|
||||
{points.map(dp => (
|
||||
<li
|
||||
key={dp.id}
|
||||
className="flex items-center justify-between text-sm py-1 pl-6"
|
||||
>
|
||||
<span className="truncate max-w-[160px] text-gray-700">
|
||||
{language === 'de' ? dp.name.de : dp.name.en}
|
||||
return (
|
||||
<AccordionItem key={category} value={category}>
|
||||
<AccordionTrigger className="text-sm hover:no-underline">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{metadata.code}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{dp.isSpecialCategory && (
|
||||
<svg className="w-3 h-3 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className={`px-1.5 py-0.5 text-[10px] rounded border ${getRiskBadgeColor(dp.riskLevel)}`}>
|
||||
{RISK_LEVEL_STYLING[dp.riskLevel].label[language]}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<span>
|
||||
{language === 'de' ? metadata.name.de : metadata.name.en}
|
||||
</span>
|
||||
<Badge variant="secondary" className="ml-auto mr-2">
|
||||
{points.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<ul className="space-y-1 pl-6">
|
||||
{points.map(dp => (
|
||||
<li
|
||||
key={dp.id}
|
||||
className="flex items-center justify-between text-sm py-1"
|
||||
>
|
||||
<span className="truncate max-w-[180px]">
|
||||
{language === 'de' ? dp.name.de : dp.name.en}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{dp.isSpecialCategory && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<AlertTriangle className="h-3 w-3 text-orange-500" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{language === 'de'
|
||||
? 'Besondere Kategorie (Art. 9 DSGVO)'
|
||||
: 'Special Category (Art. 9 GDPR)'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<Badge
|
||||
variant={getRiskBadgeVariant(dp.riskLevel)}
|
||||
className="text-xs px-1.5 py-0"
|
||||
>
|
||||
{RISK_LEVEL_STYLING[dp.riskLevel].label[language]}
|
||||
</Badge>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
})}
|
||||
</Accordion>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="border-t border-gray-200 my-3"></div>
|
||||
<Separator />
|
||||
|
||||
{/* Schnell-Einfügen Buttons */}
|
||||
<div>
|
||||
<p className="text-xs font-medium text-gray-500 mb-2">
|
||||
{language === 'de' ? 'Platzhalter einfügen:' : 'Insert placeholder:'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{PLACEHOLDERS.slice(0, 4).map(({ placeholder, label }) => (
|
||||
<button
|
||||
key={placeholder}
|
||||
onClick={() => onInsertPlaceholder(placeholder)}
|
||||
className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded hover:bg-purple-100 transition-colors"
|
||||
title={placeholder}
|
||||
>
|
||||
{language === 'de' ? label.de : label.en}
|
||||
</button>
|
||||
))}
|
||||
{/* Schnell-Einfügen Buttons */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{language === 'de' ? 'Platzhalter einfügen:' : 'Insert placeholder:'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<TooltipProvider>
|
||||
{PLACEHOLDERS.slice(0, 4).map(({ placeholder, label, description, icon: Icon }) => (
|
||||
<Tooltip key={placeholder}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={() => onInsertPlaceholder(placeholder)}
|
||||
>
|
||||
<Icon className="h-3 w-3 mr-1" />
|
||||
{language === 'de' ? label.de : label.en}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p className="text-xs">
|
||||
{language === 'de' ? description.de : description.en}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground font-mono mt-1">
|
||||
{placeholder}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<TooltipProvider>
|
||||
{PLACEHOLDERS.slice(4).map(({ placeholder, label, description, icon: Icon }) => (
|
||||
<Tooltip key={placeholder}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs px-2"
|
||||
onClick={() => onInsertPlaceholder(placeholder)}
|
||||
>
|
||||
<Icon className="h-3 w-3 mr-1" />
|
||||
{language === 'de' ? label.de : label.en}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p className="text-xs">
|
||||
{language === 'de' ? description.de : description.en}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground font-mono mt-1">
|
||||
{placeholder}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 mt-1.5">
|
||||
{PLACEHOLDERS.slice(4).map(({ placeholder, label }) => (
|
||||
<button
|
||||
key={placeholder}
|
||||
onClick={() => onInsertPlaceholder(placeholder)}
|
||||
className="px-2 py-1 text-xs bg-purple-50 text-purple-700 rounded hover:bg-purple-100 transition-colors"
|
||||
title={placeholder}
|
||||
>
|
||||
{language === 'de' ? label.de : label.en}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
ChevronDown,
|
||||
Lightbulb,
|
||||
Plus,
|
||||
} from 'lucide-react'
|
||||
import { DataPoint } from '@/lib/sdk/einwilligungen/types'
|
||||
import {
|
||||
validateDocument,
|
||||
@@ -14,6 +30,28 @@ interface DocumentValidationProps {
|
||||
onInsertPlaceholder?: (placeholder: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon für den Warnungstyp
|
||||
*/
|
||||
function getWarningIcon(type: ValidationWarning['type']) {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return AlertCircle
|
||||
case 'warning':
|
||||
return AlertTriangle
|
||||
case 'info':
|
||||
default:
|
||||
return Info
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert-Variante für den Warnungstyp
|
||||
*/
|
||||
function getAlertVariant(type: ValidationWarning['type']): 'default' | 'destructive' {
|
||||
return type === 'error' ? 'destructive' : 'default'
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder-Vorschlag aus der Warnung extrahieren
|
||||
*/
|
||||
@@ -24,6 +62,9 @@ function extractPlaceholderSuggestion(warning: ValidationWarning): string | null
|
||||
|
||||
/**
|
||||
* DocumentValidation Komponente
|
||||
*
|
||||
* Zeigt Validierungswarnungen basierend auf ausgewählten Datenpunkten und
|
||||
* dem generierten Dokumentinhalt.
|
||||
*/
|
||||
export function DocumentValidation({
|
||||
dataPoints,
|
||||
@@ -31,8 +72,6 @@ export function DocumentValidation({
|
||||
language = 'de',
|
||||
onInsertPlaceholder,
|
||||
}: DocumentValidationProps) {
|
||||
const [expandedWarnings, setExpandedWarnings] = useState<string[]>([])
|
||||
|
||||
// Führe Validierung durch
|
||||
const warnings = useMemo(() => {
|
||||
if (dataPoints.length === 0 || !documentContent) {
|
||||
@@ -46,33 +85,21 @@ export function DocumentValidation({
|
||||
const warningCount = warnings.filter(w => w.type === 'warning').length
|
||||
const infoCount = warnings.filter(w => w.type === 'info').length
|
||||
|
||||
const toggleWarning = (code: string) => {
|
||||
setExpandedWarnings(prev =>
|
||||
prev.includes(code) ? prev.filter(c => c !== code) : [...prev, code]
|
||||
)
|
||||
}
|
||||
|
||||
if (warnings.length === 0) {
|
||||
// Keine Warnungen - zeige Erfolgsmeldung wenn Datenpunkte vorhanden
|
||||
if (dataPoints.length > 0 && documentContent.length > 100) {
|
||||
return (
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-green-600 mt-0.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>
|
||||
<div>
|
||||
<h4 className="font-medium text-green-800">
|
||||
{language === 'de' ? 'Dokument valide' : 'Document valid'}
|
||||
</h4>
|
||||
<p className="text-sm text-green-700 mt-1">
|
||||
{language === 'de'
|
||||
? 'Alle notwendigen Abschnitte für die ausgewählten Datenpunkte sind vorhanden.'
|
||||
: 'All necessary sections for the selected data points are present.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Alert className="bg-green-50 border-green-200 dark:bg-green-950 dark:border-green-900">
|
||||
<Info className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<AlertTitle className="text-green-800 dark:text-green-200">
|
||||
{language === 'de' ? 'Dokument valide' : 'Document valid'}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-green-700 dark:text-green-300">
|
||||
{language === 'de'
|
||||
? 'Alle notwendigen Abschnitte für die ausgewählten Datenpunkte sind vorhanden.'
|
||||
: 'All necessary sections for the selected data points are present.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
return null
|
||||
@@ -82,126 +109,91 @@ export function DocumentValidation({
|
||||
<div className="space-y-3">
|
||||
{/* Zusammenfassung */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-gray-700">
|
||||
<span className="font-medium">
|
||||
{language === 'de' ? 'Validierung:' : 'Validation:'}
|
||||
</span>
|
||||
{errorCount > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-red-100 text-red-700">
|
||||
{errorCount} {language === 'de' ? 'Fehler' : 'Error'}{errorCount > 1 && 's'}
|
||||
</span>
|
||||
<Badge variant="destructive">
|
||||
{errorCount} {language === 'de' ? 'Fehler' : 'Error'}
|
||||
{errorCount > 1 && (language === 'de' ? '' : 's')}
|
||||
</Badge>
|
||||
)}
|
||||
{warningCount > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-yellow-100 text-yellow-700">
|
||||
{warningCount} {language === 'de' ? 'Warnung' : 'Warning'}{warningCount > 1 && (language === 'de' ? 'en' : 's')}
|
||||
</span>
|
||||
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
|
||||
{warningCount} {language === 'de' ? 'Warnung' : 'Warning'}
|
||||
{warningCount > 1 && (language === 'de' ? 'en' : 's')}
|
||||
</Badge>
|
||||
)}
|
||||
{infoCount > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-100 text-blue-700">
|
||||
{infoCount} {language === 'de' ? 'Hinweis' : 'Info'}{infoCount > 1 && (language === 'de' ? 'e' : 's')}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{infoCount} {language === 'de' ? 'Hinweis' : 'Info'}
|
||||
{infoCount > 1 && (language === 'de' ? 'e' : 's')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warnungen */}
|
||||
{warnings.map((warning, index) => {
|
||||
const Icon = getWarningIcon(warning.type)
|
||||
const placeholder = extractPlaceholderSuggestion(warning)
|
||||
const isExpanded = expandedWarnings.includes(warning.code)
|
||||
const isError = warning.type === 'error'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${warning.code}-${index}`}
|
||||
className={`rounded-xl border p-4 ${
|
||||
isError
|
||||
? 'bg-red-50 border-red-200'
|
||||
: 'bg-yellow-50 border-yellow-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Icon */}
|
||||
<svg
|
||||
className={`w-5 h-5 mt-0.5 ${isError ? 'text-red-600' : 'text-yellow-600'}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{isError ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
)}
|
||||
</svg>
|
||||
|
||||
<div className="flex-1">
|
||||
{/* Message */}
|
||||
<p className={`font-medium ${isError ? 'text-red-800' : 'text-yellow-800'}`}>
|
||||
{warning.message}
|
||||
</p>
|
||||
|
||||
{/* Suggestion */}
|
||||
<div className="flex items-start gap-2 mt-2">
|
||||
<svg className="w-4 h-4 mt-0.5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<span className="text-sm text-gray-600">{warning.suggestion}</span>
|
||||
</div>
|
||||
|
||||
{/* Quick-Fix Button */}
|
||||
{placeholder && onInsertPlaceholder && (
|
||||
<button
|
||||
onClick={() => onInsertPlaceholder(placeholder)}
|
||||
className="mt-3 inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{language === 'de' ? 'Platzhalter einfügen' : 'Insert placeholder'}
|
||||
<code className="ml-1 text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{placeholder}
|
||||
</code>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Betroffene Datenpunkte */}
|
||||
{warning.affectedDataPoints && warning.affectedDataPoints.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<button
|
||||
onClick={() => toggleWarning(warning.code)}
|
||||
className="flex items-center gap-1 text-xs text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
{warning.affectedDataPoints.length}{' '}
|
||||
{language === 'de' ? 'betroffene Datenpunkte' : 'affected data points'}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<ul className="mt-2 text-xs space-y-0.5 pl-4">
|
||||
{warning.affectedDataPoints.slice(0, 5).map(dp => (
|
||||
<li key={dp.id} className="list-disc text-gray-600">
|
||||
{language === 'de' ? dp.name.de : dp.name.en}
|
||||
</li>
|
||||
))}
|
||||
{warning.affectedDataPoints.length > 5 && (
|
||||
<li className="list-none text-gray-400">
|
||||
... {language === 'de' ? 'und' : 'and'}{' '}
|
||||
{warning.affectedDataPoints.length - 5}{' '}
|
||||
{language === 'de' ? 'weitere' : 'more'}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Alert key={`${warning.code}-${index}`} variant={getAlertVariant(warning.type)}>
|
||||
<Icon className="h-4 w-4" />
|
||||
<AlertTitle className="flex items-center justify-between">
|
||||
<span>{warning.message}</span>
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mt-2 space-y-2">
|
||||
{/* Vorschlag */}
|
||||
<div className="flex items-start gap-2">
|
||||
<Lightbulb className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-sm">{warning.suggestion}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick-Fix Button */}
|
||||
{placeholder && onInsertPlaceholder && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-2"
|
||||
onClick={() => onInsertPlaceholder(placeholder)}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{language === 'de' ? 'Platzhalter einfügen' : 'Insert placeholder'}
|
||||
<code className="ml-1 text-xs bg-muted px-1 rounded">
|
||||
{placeholder}
|
||||
</code>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Betroffene Datenpunkte */}
|
||||
{warning.affectedDataPoints && warning.affectedDataPoints.length > 0 && (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground">
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
{warning.affectedDataPoints.length}{' '}
|
||||
{language === 'de' ? 'betroffene Datenpunkte' : 'affected data points'}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="mt-2">
|
||||
<ul className="text-xs space-y-0.5 pl-4">
|
||||
{warning.affectedDataPoints.slice(0, 5).map(dp => (
|
||||
<li key={dp.id} className="list-disc">
|
||||
{language === 'de' ? dp.name.de : dp.name.en}
|
||||
</li>
|
||||
))}
|
||||
{warning.affectedDataPoints.length > 5 && (
|
||||
<li className="list-none text-muted-foreground">
|
||||
... {language === 'de' ? 'und' : 'and'}{' '}
|
||||
{warning.affectedDataPoints.length - 5}{' '}
|
||||
{language === 'de' ? 'weitere' : 'more'}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
1340
admin-v2/app/(sdk)/sdk/dsfa/[id]/page.tsx
Normal file
1340
admin-v2/app/(sdk)/sdk/dsfa/[id]/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,179 +1,177 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useSDK } from '@/lib/sdk'
|
||||
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
||||
import { DocumentUploadSection, type UploadedDocument } from '@/components/sdk'
|
||||
import { DSFACard } from '@/components/sdk/dsfa'
|
||||
import {
|
||||
DSFA,
|
||||
DSFAStatus,
|
||||
DSFA_STATUS_LABELS,
|
||||
DSFA_RISK_LEVEL_LABELS,
|
||||
} from '@/lib/sdk/dsfa/types'
|
||||
import {
|
||||
listDSFAs,
|
||||
deleteDSFA,
|
||||
exportDSFAAsJSON,
|
||||
getDSFAStats,
|
||||
createDSFAFromAssessment,
|
||||
getDSFAByAssessment,
|
||||
} from '@/lib/sdk/dsfa/api'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// UCCA TRIGGER WARNING COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
interface DSFA {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
status: 'draft' | 'in-review' | 'approved' | 'needs-update'
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
approvedBy: string | null
|
||||
riskLevel: 'low' | 'medium' | 'high' | 'critical'
|
||||
processingActivity: string
|
||||
dataCategories: string[]
|
||||
recipients: string[]
|
||||
measures: string[]
|
||||
interface UCCATriggerWarningProps {
|
||||
assessmentId: string
|
||||
triggeredRules: string[]
|
||||
existingDsfaId?: string
|
||||
onCreateDSFA: () => void
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA
|
||||
// =============================================================================
|
||||
|
||||
const mockDSFAs: DSFA[] = [
|
||||
{
|
||||
id: 'dsfa-1',
|
||||
title: 'DSFA - Bewerber-Management-System',
|
||||
description: 'Datenschutz-Folgenabschaetzung fuer das KI-gestuetzte Bewerber-Screening',
|
||||
status: 'in-review',
|
||||
createdAt: new Date('2024-01-10'),
|
||||
updatedAt: new Date('2024-01-20'),
|
||||
approvedBy: null,
|
||||
riskLevel: 'high',
|
||||
processingActivity: 'Automatisierte Bewertung von Bewerbungsunterlagen',
|
||||
dataCategories: ['Kontaktdaten', 'Beruflicher Werdegang', 'Qualifikationen'],
|
||||
recipients: ['HR-Abteilung', 'Fachabteilungen'],
|
||||
measures: ['Verschluesselung', 'Zugriffskontrolle', 'Menschliche Pruefung'],
|
||||
},
|
||||
{
|
||||
id: 'dsfa-2',
|
||||
title: 'DSFA - Video-Ueberwachung Buero',
|
||||
description: 'Datenschutz-Folgenabschaetzung fuer die Videoueberwachung im Buerogebaeude',
|
||||
status: 'approved',
|
||||
createdAt: new Date('2023-11-01'),
|
||||
updatedAt: new Date('2023-12-15'),
|
||||
approvedBy: 'DSB Mueller',
|
||||
riskLevel: 'medium',
|
||||
processingActivity: 'Videoueberwachung zu Sicherheitszwecken',
|
||||
dataCategories: ['Bilddaten', 'Bewegungsdaten'],
|
||||
recipients: ['Sicherheitsdienst'],
|
||||
measures: ['Loeschfristen', 'Zugriffsbeschraenkung', 'Hinweisschilder'],
|
||||
},
|
||||
{
|
||||
id: 'dsfa-3',
|
||||
title: 'DSFA - Kundenanalyse',
|
||||
description: 'Datenschutz-Folgenabschaetzung fuer Big-Data-Kundenanalysen',
|
||||
status: 'draft',
|
||||
createdAt: new Date('2024-01-22'),
|
||||
updatedAt: new Date('2024-01-22'),
|
||||
approvedBy: null,
|
||||
riskLevel: 'high',
|
||||
processingActivity: 'Analyse von Kundenverhalten fuer Marketing',
|
||||
dataCategories: ['Kaufhistorie', 'Nutzungsverhalten', 'Praeferenzen'],
|
||||
recipients: ['Marketing', 'Vertrieb'],
|
||||
measures: [],
|
||||
},
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// COMPONENTS
|
||||
// =============================================================================
|
||||
|
||||
function DSFACard({ dsfa }: { dsfa: DSFA }) {
|
||||
const statusColors = {
|
||||
draft: 'bg-gray-100 text-gray-600 border-gray-200',
|
||||
'in-review': 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||
approved: 'bg-green-100 text-green-700 border-green-200',
|
||||
'needs-update': 'bg-orange-100 text-orange-700 border-orange-200',
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
draft: 'Entwurf',
|
||||
'in-review': 'In Pruefung',
|
||||
approved: 'Genehmigt',
|
||||
'needs-update': 'Aktualisierung erforderlich',
|
||||
}
|
||||
|
||||
const riskColors = {
|
||||
low: 'bg-green-100 text-green-700',
|
||||
medium: 'bg-yellow-100 text-yellow-700',
|
||||
high: 'bg-orange-100 text-orange-700',
|
||||
critical: 'bg-red-100 text-red-700',
|
||||
function UCCATriggerWarning({
|
||||
assessmentId,
|
||||
triggeredRules,
|
||||
existingDsfaId,
|
||||
onCreateDSFA,
|
||||
}: UCCATriggerWarningProps) {
|
||||
if (existingDsfaId) {
|
||||
return (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-blue-600" 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>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-blue-800">DSFA bereits erstellt</h4>
|
||||
<p className="text-sm text-blue-600 mt-1">
|
||||
Fuer dieses Assessment wurde bereits eine DSFA angelegt.
|
||||
</p>
|
||||
<Link
|
||||
href={`/sdk/dsfa/${existingDsfaId}`}
|
||||
className="inline-flex items-center gap-1 mt-2 text-sm text-blue-700 hover:text-blue-800 font-medium"
|
||||
>
|
||||
DSFA oeffnen
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-xl border-2 p-6 ${
|
||||
dsfa.status === 'needs-update' ? 'border-orange-200' :
|
||||
dsfa.status === 'approved' ? 'border-green-200' : 'border-gray-200'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[dsfa.status]}`}>
|
||||
{statusLabels[dsfa.status]}
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-xl p-4 flex items-start gap-4">
|
||||
<div className="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-orange-800">DSFA erforderlich</h4>
|
||||
<p className="text-sm text-orange-600 mt-1">
|
||||
Das UCCA-Assessment hat folgende Trigger ausgeloest:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{triggeredRules.map(rule => (
|
||||
<span key={rule} className="px-2 py-0.5 text-xs bg-orange-100 text-orange-700 rounded">
|
||||
{rule}
|
||||
</span>
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${riskColors[dsfa.riskLevel]}`}>
|
||||
Risiko: {dsfa.riskLevel === 'low' ? 'Niedrig' :
|
||||
dsfa.riskLevel === 'medium' ? 'Mittel' :
|
||||
dsfa.riskLevel === 'high' ? 'Hoch' : 'Kritisch'}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{dsfa.title}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">{dsfa.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
<p><span className="text-gray-500">Verarbeitungstaetigkeit:</span> {dsfa.processingActivity}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{dsfa.dataCategories.map(cat => (
|
||||
<span key={cat} className="px-2 py-0.5 text-xs bg-blue-50 text-blue-600 rounded">
|
||||
{cat}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{dsfa.measures.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<span className="text-sm text-gray-500">Massnahmen:</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{dsfa.measures.map(m => (
|
||||
<span key={m} className="px-2 py-0.5 text-xs bg-green-50 text-green-600 rounded">
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
|
||||
<div className="text-gray-500">
|
||||
<span>Erstellt: {dsfa.createdAt.toLocaleDateString('de-DE')}</span>
|
||||
{dsfa.approvedBy && (
|
||||
<span className="ml-4">Genehmigt von: {dsfa.approvedBy}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="px-3 py-1 text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button className="px-3 py-1 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
Exportieren
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={onCreateDSFA}
|
||||
className="inline-flex items-center gap-1 mt-3 px-4 py-2 bg-orange-600 text-white rounded-lg hover:bg-orange-700 transition-colors text-sm font-medium"
|
||||
>
|
||||
DSFA aus Assessment erstellen
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GeneratorWizard({ onClose }: { onClose: () => void }) {
|
||||
// =============================================================================
|
||||
// GENERATOR WIZARD COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
function GeneratorWizard({ onClose, onCreated }: { onClose: () => void; onCreated: (dsfa: DSFA) => void }) {
|
||||
const [step, setStep] = useState(1)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
processingPurpose: '',
|
||||
dataCategories: [] as string[],
|
||||
legalBasis: '',
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const DATA_CATEGORIES = [
|
||||
'Kontaktdaten',
|
||||
'Identifikationsdaten',
|
||||
'Finanzdaten',
|
||||
'Gesundheitsdaten',
|
||||
'Standortdaten',
|
||||
'Nutzungsdaten',
|
||||
'Biometrische Daten',
|
||||
'Daten Minderjaehriger',
|
||||
]
|
||||
|
||||
const LEGAL_BASES = [
|
||||
{ value: 'consent', label: 'Einwilligung (Art. 6 Abs. 1 lit. a)' },
|
||||
{ value: 'contract', label: 'Vertrag (Art. 6 Abs. 1 lit. b)' },
|
||||
{ value: 'legal_obligation', label: 'Rechtliche Verpflichtung (Art. 6 Abs. 1 lit. c)' },
|
||||
{ value: 'legitimate_interest', label: 'Berechtigtes Interesse (Art. 6 Abs. 1 lit. f)' },
|
||||
]
|
||||
|
||||
const handleCategoryToggle = (cat: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
dataCategories: prev.dataCategories.includes(cat)
|
||||
? prev.dataCategories.filter(c => c !== cat)
|
||||
: [...prev.dataCategories, cat],
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
// For standalone DSFA, we use the regular create endpoint
|
||||
const response = await fetch('/api/sdk/v1/dsgvo/dsfas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
processing_purpose: formData.processingPurpose,
|
||||
data_categories: formData.dataCategories,
|
||||
legal_basis: formData.legalBasis,
|
||||
status: 'draft',
|
||||
}),
|
||||
})
|
||||
if (response.ok) {
|
||||
const dsfa = await response.json()
|
||||
onCreated(dsfa)
|
||||
onClose()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create DSFA:', error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Neue DSFA erstellen</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Neue Standalone-DSFA erstellen</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-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" />
|
||||
@@ -183,7 +181,7 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
|
||||
|
||||
{/* Progress Steps */}
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
{[1, 2, 3, 4].map(s => (
|
||||
{[1, 2, 3].map(s => (
|
||||
<React.Fragment key={s}>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
s < step ? 'bg-green-500 text-white' :
|
||||
@@ -195,7 +193,7 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
|
||||
</svg>
|
||||
) : s}
|
||||
</div>
|
||||
{s < 4 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
|
||||
{s < 3 && <div className={`flex-1 h-1 ${s < step ? 'bg-green-500' : 'bg-gray-200'}`} />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
@@ -205,9 +203,11 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel der DSFA</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Titel der DSFA *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="z.B. DSFA - Mitarbeiter-Monitoring"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
@@ -215,21 +215,38 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung der Verarbeitung</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
rows={3}
|
||||
placeholder="Beschreiben Sie die geplante Datenverarbeitung..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Verarbeitungszweck</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.processingPurpose}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, processingPurpose: e.target.value }))}
|
||||
placeholder="z.B. Automatisierte Bewerberauswahl"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Datenkategorien</label>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Datenkategorien *</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{['Kontaktdaten', 'Identifikationsdaten', 'Finanzdaten', 'Gesundheitsdaten', 'Standortdaten', 'Nutzungsdaten'].map(cat => (
|
||||
{DATA_CATEGORIES.map(cat => (
|
||||
<label key={cat} className="flex items-center gap-2 p-2 border rounded-lg hover:bg-gray-50">
|
||||
<input type="checkbox" className="w-4 h-4 text-purple-600" />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.dataCategories.includes(cat)}
|
||||
onChange={() => handleCategoryToggle(cat)}
|
||||
className="w-4 h-4 text-purple-600"
|
||||
/>
|
||||
<span className="text-sm">{cat}</span>
|
||||
</label>
|
||||
))}
|
||||
@@ -240,28 +257,19 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Risikobewertung</label>
|
||||
<p className="text-sm text-gray-500 mb-4">Bewerten Sie die Risiken fuer die Rechte und Freiheiten der Betroffenen.</p>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rechtsgrundlage *</label>
|
||||
<div className="space-y-2">
|
||||
{['Niedrig', 'Mittel', 'Hoch', 'Kritisch'].map(level => (
|
||||
<label key={level} className="flex items-center gap-3 p-3 border rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||
<input type="radio" name="risk" className="w-4 h-4 text-purple-600" />
|
||||
<span className="text-sm font-medium">{level}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{step === 4 && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Schutzmassnahmen</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{['Verschluesselung', 'Pseudonymisierung', 'Zugriffskontrolle', 'Loeschkonzept', 'Schulungen', 'Menschliche Pruefung'].map(m => (
|
||||
<label key={m} className="flex items-center gap-2 p-2 border rounded-lg hover:bg-gray-50">
|
||||
<input type="checkbox" className="w-4 h-4 text-purple-600" />
|
||||
<span className="text-sm">{m}</span>
|
||||
{LEGAL_BASES.map(basis => (
|
||||
<label key={basis.value} className="flex items-center gap-3 p-3 border rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="legalBasis"
|
||||
value={basis.value}
|
||||
checked={formData.legalBasis === basis.value}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, legalBasis: e.target.value }))}
|
||||
className="w-4 h-4 text-purple-600"
|
||||
/>
|
||||
<span className="text-sm font-medium">{basis.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -279,10 +287,11 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
|
||||
{step === 1 ? 'Abbrechen' : 'Zurueck'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => step < 4 ? setStep(step + 1) : onClose()}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
onClick={() => step < 3 ? setStep(step + 1) : handleSubmit()}
|
||||
disabled={(step === 1 && !formData.name) || (step === 2 && formData.dataCategories.length === 0) || (step === 3 && !formData.legalBasis) || isSubmitting}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed"
|
||||
>
|
||||
{step === 4 ? 'DSFA erstellen' : 'Weiter'}
|
||||
{isSubmitting ? 'Wird erstellt...' : step === 3 ? 'DSFA erstellen' : 'Weiter'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -296,28 +305,115 @@ function GeneratorWizard({ onClose }: { onClose: () => void }) {
|
||||
export default function DSFAPage() {
|
||||
const router = useRouter()
|
||||
const { state } = useSDK()
|
||||
const [dsfas] = useState<DSFA[]>(mockDSFAs)
|
||||
const [dsfas, setDsfas] = useState<DSFA[]>([])
|
||||
const [showGenerator, setShowGenerator] = useState(false)
|
||||
const [filter, setFilter] = useState<string>('all')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [stats, setStats] = useState({
|
||||
total: 0,
|
||||
draft: 0,
|
||||
in_review: 0,
|
||||
approved: 0,
|
||||
})
|
||||
|
||||
// Handle uploaded document
|
||||
const handleDocumentProcessed = useCallback((doc: UploadedDocument) => {
|
||||
console.log('[DSFA Page] Document processed:', doc)
|
||||
}, [])
|
||||
// UCCA trigger info (would come from SDK state)
|
||||
const [uccaTrigger, setUccaTrigger] = useState<{
|
||||
assessmentId: string
|
||||
triggeredRules: string[]
|
||||
existingDsfaId?: string
|
||||
} | null>(null)
|
||||
|
||||
// Open document in workflow editor
|
||||
const handleOpenInEditor = useCallback((doc: UploadedDocument) => {
|
||||
router.push(`/compliance/workflow?documentType=dsfa&documentId=${doc.id}&mode=change`)
|
||||
}, [router])
|
||||
// Load DSFAs
|
||||
const loadDSFAs = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [dsfaList, statsData] = await Promise.all([
|
||||
listDSFAs(filter === 'all' ? undefined : filter),
|
||||
getDSFAStats(),
|
||||
])
|
||||
setDsfas(dsfaList)
|
||||
setStats({
|
||||
total: statsData.total,
|
||||
draft: statsData.status_stats.draft || 0,
|
||||
in_review: statsData.status_stats.in_review || 0,
|
||||
approved: statsData.status_stats.approved || 0,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load DSFAs:', error)
|
||||
// Set empty state on error
|
||||
setDsfas([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [filter])
|
||||
|
||||
useEffect(() => {
|
||||
loadDSFAs()
|
||||
}, [loadDSFAs])
|
||||
|
||||
// Check for UCCA trigger from SDK state
|
||||
// TODO: Enable when UCCA integration is complete
|
||||
// useEffect(() => {
|
||||
// if (state?.uccaAssessment?.dsfa_recommended) {
|
||||
// const assessmentId = state.uccaAssessment.id
|
||||
// const triggeredRules = state.uccaAssessment.triggered_rules
|
||||
// ?.filter((r: { severity: string }) => r.severity === 'BLOCK' || r.severity === 'WARN')
|
||||
// ?.map((r: { code: string }) => r.code) || []
|
||||
//
|
||||
// // Check if DSFA already exists
|
||||
// getDSFAByAssessment(assessmentId).then(existingDsfa => {
|
||||
// setUccaTrigger({
|
||||
// assessmentId,
|
||||
// triggeredRules,
|
||||
// existingDsfaId: existingDsfa?.id,
|
||||
// })
|
||||
// })
|
||||
// }
|
||||
// }, [state?.uccaAssessment])
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = async (id: string) => {
|
||||
if (confirm('Moechten Sie diese DSFA wirklich loeschen?')) {
|
||||
try {
|
||||
await deleteDSFA(id)
|
||||
await loadDSFAs()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete DSFA:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle export
|
||||
const handleExport = async (id: string) => {
|
||||
try {
|
||||
const blob = await exportDSFAAsJSON(id)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `dsfa_${id.slice(0, 8)}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error('Failed to export DSFA:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle create from assessment
|
||||
const handleCreateFromAssessment = async () => {
|
||||
if (!uccaTrigger?.assessmentId) return
|
||||
|
||||
try {
|
||||
const response = await createDSFAFromAssessment(uccaTrigger.assessmentId)
|
||||
router.push(`/sdk/dsfa/${response.dsfa.id}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to create DSFA from assessment:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredDSFAs = filter === 'all'
|
||||
? dsfas
|
||||
: dsfas.filter(d => d.status === filter)
|
||||
|
||||
const draftCount = dsfas.filter(d => d.status === 'draft').length
|
||||
const inReviewCount = dsfas.filter(d => d.status === 'in-review').length
|
||||
const approvedCount = dsfas.filter(d => d.status === 'approved').length
|
||||
|
||||
const stepInfo = STEP_EXPLANATIONS['dsfa']
|
||||
|
||||
return (
|
||||
@@ -330,55 +426,67 @@ export default function DSFAPage() {
|
||||
explanation={stepInfo.explanation}
|
||||
tips={stepInfo.tips}
|
||||
>
|
||||
{!showGenerator && (
|
||||
<button
|
||||
onClick={() => setShowGenerator(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Neue DSFA
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{!showGenerator && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowGenerator(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
Standalone DSFA
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</StepHeader>
|
||||
|
||||
{/* UCCA Trigger Warning */}
|
||||
{uccaTrigger && (
|
||||
<UCCATriggerWarning
|
||||
assessmentId={uccaTrigger.assessmentId}
|
||||
triggeredRules={uccaTrigger.triggeredRules}
|
||||
existingDsfaId={uccaTrigger.existingDsfaId}
|
||||
onCreateDSFA={handleCreateFromAssessment}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Generator */}
|
||||
{showGenerator && (
|
||||
<GeneratorWizard onClose={() => setShowGenerator(false)} />
|
||||
<GeneratorWizard
|
||||
onClose={() => setShowGenerator(false)}
|
||||
onCreated={(dsfa) => {
|
||||
router.push(`/sdk/dsfa/${dsfa.id}`)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Document Upload Section */}
|
||||
<DocumentUploadSection
|
||||
documentType="dsfa"
|
||||
onDocumentProcessed={handleDocumentProcessed}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
/>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{dsfas.length}</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{stats.total}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Entwuerfe</div>
|
||||
<div className="text-3xl font-bold text-gray-500">{draftCount}</div>
|
||||
<div className="text-3xl font-bold text-gray-500">{stats.draft}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-yellow-200 p-6">
|
||||
<div className="text-sm text-yellow-600">In Pruefung</div>
|
||||
<div className="text-3xl font-bold text-yellow-600">{inReviewCount}</div>
|
||||
<div className="text-3xl font-bold text-yellow-600">{stats.in_review}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Genehmigt</div>
|
||||
<div className="text-3xl font-bold text-green-600">{approvedCount}</div>
|
||||
<div className="text-3xl font-bold text-green-600">{stats.approved}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Filter:</span>
|
||||
{['all', 'draft', 'in-review', 'approved', 'needs-update'].map(f => (
|
||||
{['all', 'draft', 'in_review', 'approved', 'needs_update'].map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
@@ -388,22 +496,31 @@ export default function DSFAPage() {
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{f === 'all' ? 'Alle' :
|
||||
f === 'draft' ? 'Entwuerfe' :
|
||||
f === 'in-review' ? 'In Pruefung' :
|
||||
f === 'approved' ? 'Genehmigt' : 'Update erforderlich'}
|
||||
{f === 'all' ? 'Alle' : DSFA_STATUS_LABELS[f as DSFAStatus] || f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* DSFA List */}
|
||||
<div className="space-y-4">
|
||||
{filteredDSFAs.map(dsfa => (
|
||||
<DSFACard key={dsfa.id} dsfa={dsfa} />
|
||||
))}
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-4 border-purple-600 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{filteredDSFAs.map(dsfa => (
|
||||
<DSFACard
|
||||
key={dsfa.id}
|
||||
dsfa={dsfa}
|
||||
onDelete={handleDelete}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredDSFAs.length === 0 && !showGenerator && (
|
||||
{/* Empty State */}
|
||||
{!isLoading && filteredDSFAs.length === 0 && !showGenerator && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto bg-purple-100 rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -411,13 +528,19 @@ export default function DSFAPage() {
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">Keine DSFAs gefunden</h3>
|
||||
<p className="mt-2 text-gray-500">Erstellen Sie eine neue Datenschutz-Folgenabschaetzung.</p>
|
||||
<button
|
||||
onClick={() => setShowGenerator(true)}
|
||||
className="mt-4 px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Erste DSFA erstellen
|
||||
</button>
|
||||
<p className="mt-2 text-gray-500">
|
||||
{filter !== 'all'
|
||||
? `Keine DSFAs mit Status "${DSFA_STATUS_LABELS[filter as DSFAStatus]}".`
|
||||
: 'Erstellen Sie eine neue Datenschutz-Folgenabschaetzung.'}
|
||||
</p>
|
||||
{filter === 'all' && (
|
||||
<button
|
||||
onClick={() => setShowGenerator(true)}
|
||||
className="mt-4 px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
Erste DSFA erstellen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSDK, SDK_PACKAGES, getStepsForPackage } from '@/lib/sdk'
|
||||
import { CustomerTypeSelector } from '@/components/sdk/CustomerTypeSelector'
|
||||
import type { CustomerType, SDKPackageId } from '@/lib/sdk/types'
|
||||
import { useSDK, getStepsForPhase } from '@/lib/sdk'
|
||||
|
||||
// =============================================================================
|
||||
// DASHBOARD CARDS
|
||||
@@ -37,65 +35,49 @@ function StatCard({
|
||||
)
|
||||
}
|
||||
|
||||
function PackageCard({
|
||||
pkg,
|
||||
function PhaseCard({
|
||||
phase,
|
||||
title,
|
||||
description,
|
||||
completion,
|
||||
stepsCount,
|
||||
isLocked,
|
||||
steps,
|
||||
href,
|
||||
}: {
|
||||
pkg: (typeof SDK_PACKAGES)[number]
|
||||
phase: number
|
||||
title: string
|
||||
description: string
|
||||
completion: number
|
||||
stepsCount: number
|
||||
isLocked: boolean
|
||||
steps: number
|
||||
href: string
|
||||
}) {
|
||||
const steps = getStepsForPackage(pkg.id)
|
||||
const firstStep = steps[0]
|
||||
const href = firstStep?.url || '/sdk'
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`block bg-white rounded-xl border-2 p-6 transition-all ${
|
||||
isLocked
|
||||
? 'border-gray-100 opacity-60 cursor-not-allowed'
|
||||
: completion === 100
|
||||
? 'border-green-200 hover:border-green-300 hover:shadow-lg'
|
||||
: 'border-gray-200 hover:border-purple-300 hover:shadow-lg'
|
||||
}`}
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="block bg-white rounded-xl border border-gray-200 p-6 hover:border-purple-300 hover:shadow-lg transition-all"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={`w-14 h-14 rounded-xl flex items-center justify-center text-2xl ${
|
||||
isLocked
|
||||
? 'bg-gray-100 text-gray-400'
|
||||
: completion === 100
|
||||
className={`w-12 h-12 rounded-xl flex items-center justify-center text-xl font-bold ${
|
||||
completion === 100
|
||||
? 'bg-green-100 text-green-600'
|
||||
: 'bg-purple-100 text-purple-600'
|
||||
}`}
|
||||
>
|
||||
{isLocked ? (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
) : completion === 100 ? (
|
||||
{completion === 100 ? (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
pkg.icon
|
||||
phase
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">{pkg.order}.</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{pkg.name}</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">{pkg.description}</p>
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">{description}</p>
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-gray-500">{stepsCount} Schritte</span>
|
||||
<span className={`font-medium ${completion === 100 ? 'text-green-600' : 'text-purple-600'}`}>
|
||||
{completion}%
|
||||
</span>
|
||||
<span className="text-gray-500">{steps} Schritte</span>
|
||||
<span className="font-medium text-purple-600">{completion}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
@@ -106,23 +88,8 @@ function PackageCard({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!isLocked && (
|
||||
<p className="mt-3 text-xs text-gray-400">
|
||||
Ergebnis: {pkg.result}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isLocked) {
|
||||
return content
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href}>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -162,63 +129,24 @@ function QuickActionCard({
|
||||
// =============================================================================
|
||||
|
||||
export default function SDKDashboard() {
|
||||
const { state, packageCompletion, completionPercentage, setCustomerType } = useSDK()
|
||||
const { state, phase1Completion, phase2Completion, completionPercentage } = useSDK()
|
||||
|
||||
// Calculate total steps
|
||||
const totalSteps = SDK_PACKAGES.reduce((sum, pkg) => {
|
||||
const steps = getStepsForPackage(pkg.id)
|
||||
// Filter import step for new customers
|
||||
return sum + steps.filter(s => !(s.id === 'import' && state.customerType === 'new')).length
|
||||
}, 0)
|
||||
const phase1Steps = getStepsForPhase(1)
|
||||
const phase2Steps = getStepsForPhase(2)
|
||||
|
||||
// Calculate stats
|
||||
const completedCheckpoints = Object.values(state.checkpoints).filter(cp => cp.passed).length
|
||||
const totalRisks = state.risks.length
|
||||
const criticalRisks = state.risks.filter(r => r.severity === 'CRITICAL' || r.severity === 'HIGH').length
|
||||
|
||||
const isPackageLocked = (packageId: SDKPackageId): boolean => {
|
||||
if (state.preferences?.allowParallelWork) return false
|
||||
const pkg = SDK_PACKAGES.find(p => p.id === packageId)
|
||||
if (!pkg || pkg.order === 1) return false
|
||||
|
||||
// Check if previous package is complete
|
||||
const prevPkg = SDK_PACKAGES.find(p => p.order === pkg.order - 1)
|
||||
if (!prevPkg) return false
|
||||
|
||||
return packageCompletion[prevPkg.id] < 100
|
||||
}
|
||||
|
||||
// Show customer type selector if not set
|
||||
if (!state.customerType) {
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-200px)] flex items-center justify-center py-12">
|
||||
<CustomerTypeSelector
|
||||
onSelect={(type: CustomerType) => {
|
||||
setCustomerType(type)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">AI Compliance SDK</h1>
|
||||
<p className="mt-1 text-gray-500">
|
||||
{state.customerType === 'new'
|
||||
? 'Neukunden-Modus: Erstellen Sie alle Compliance-Dokumente von Grund auf.'
|
||||
: 'Bestandskunden-Modus: Erweitern Sie bestehende Dokumente.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setCustomerType(state.customerType === 'new' ? 'existing' : 'new')}
|
||||
className="text-sm text-purple-600 hover:text-purple-700 underline"
|
||||
>
|
||||
{state.customerType === 'new' ? 'Zu Bestandskunden wechseln' : 'Zu Neukunden wechseln'}
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">AI Compliance SDK</h1>
|
||||
<p className="mt-1 text-gray-500">
|
||||
Willkommen zum Compliance Assessment. Starten Sie mit Phase 1 oder setzen Sie Ihre Arbeit fort.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
@@ -226,7 +154,7 @@ export default function SDKDashboard() {
|
||||
<StatCard
|
||||
title="Gesamtfortschritt"
|
||||
value={`${completionPercentage}%`}
|
||||
subtitle={`${state.completedSteps.length} von ${totalSteps} Schritten`}
|
||||
subtitle={`${state.completedSteps.length} von ${phase1Steps.length + phase2Steps.length} Schritten`}
|
||||
icon={
|
||||
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
@@ -247,7 +175,7 @@ export default function SDKDashboard() {
|
||||
/>
|
||||
<StatCard
|
||||
title="Checkpoints"
|
||||
value={`${completedCheckpoints}/${totalSteps}`}
|
||||
value={`${completedCheckpoints}/${phase1Steps.length + phase2Steps.length}`}
|
||||
subtitle="Validiert"
|
||||
icon={
|
||||
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -269,85 +197,26 @@ export default function SDKDashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bestandskunden: Gap Analysis Banner */}
|
||||
{state.customerType === 'existing' && state.importedDocuments.length === 0 && (
|
||||
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 border border-indigo-200 rounded-xl p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-indigo-100 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-2xl">📄</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Bestehende Dokumente importieren</h3>
|
||||
<p className="mt-1 text-gray-600">
|
||||
Laden Sie Ihre vorhandenen Compliance-Dokumente hoch. Unsere KI analysiert sie und zeigt Ihnen, welche Erweiterungen fuer KI-Compliance erforderlich sind.
|
||||
</p>
|
||||
<Link
|
||||
href="/sdk/import"
|
||||
className="inline-flex items-center gap-2 mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
Dokumente hochladen
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gap Analysis Results */}
|
||||
{state.gapAnalysis && (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<span className="text-xl">📊</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Gap-Analyse Ergebnis</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{state.gapAnalysis.totalGaps} Luecken gefunden
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="text-center p-3 bg-red-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-red-600">{state.gapAnalysis.criticalGaps}</div>
|
||||
<div className="text-xs text-red-600">Kritisch</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-orange-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-orange-600">{state.gapAnalysis.highGaps}</div>
|
||||
<div className="text-xs text-orange-600">Hoch</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-yellow-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-yellow-600">{state.gapAnalysis.mediumGaps}</div>
|
||||
<div className="text-xs text-yellow-600">Mittel</div>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-green-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-green-600">{state.gapAnalysis.lowGaps}</div>
|
||||
<div className="text-xs text-green-600">Niedrig</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 5 Packages */}
|
||||
{/* Phases */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Compliance-Pakete</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{SDK_PACKAGES.map(pkg => {
|
||||
const steps = getStepsForPackage(pkg.id)
|
||||
const visibleSteps = steps.filter(s => !(s.id === 'import' && state.customerType === 'new'))
|
||||
|
||||
return (
|
||||
<PackageCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
completion={packageCompletion[pkg.id]}
|
||||
stepsCount={visibleSteps.length}
|
||||
isLocked={isPackageLocked(pkg.id)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Phasen</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<PhaseCard
|
||||
phase={1}
|
||||
title="Compliance Assessment"
|
||||
description="Use Case erfassen, Screening durchführen, Risiken bewerten"
|
||||
completion={phase1Completion}
|
||||
steps={phase1Steps.length}
|
||||
href="/sdk/advisory-board"
|
||||
/>
|
||||
<PhaseCard
|
||||
phase={2}
|
||||
title="Dokumentengenerierung"
|
||||
description="DSFA, TOMs, VVT, Cookie Banner und mehr generieren"
|
||||
completion={phase2Completion}
|
||||
steps={phase2Steps.length}
|
||||
href="/sdk/ai-act"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -379,7 +248,7 @@ export default function SDKDashboard() {
|
||||
/>
|
||||
<QuickActionCard
|
||||
title="DSFA generieren"
|
||||
description="Datenschutz-Folgenabschaetzung erstellen"
|
||||
description="Datenschutz-Folgenabschätzung erstellen"
|
||||
icon={
|
||||
<svg className="w-6 h-6 text-blue-600" 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" />
|
||||
@@ -405,7 +274,7 @@ export default function SDKDashboard() {
|
||||
{/* Recent Activity */}
|
||||
{state.commandBarHistory.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Letzte Aktivitaeten</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Letzte Aktivitäten</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
|
||||
{state.commandBarHistory.slice(0, 5).map(entry => (
|
||||
<div key={entry.id} className="flex items-center gap-4 px-4 py-3">
|
||||
|
||||
129
admin-v2/app/api/admin/companion/feedback/route.ts
Normal file
129
admin-v2/app/api/admin/companion/feedback/route.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* POST /api/admin/companion/feedback
|
||||
* Submit feedback (bug report, feature request, general feedback)
|
||||
* Proxy to backend /api/feedback
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.type || !body.title || !body.description) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Missing required fields: type, title, description',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate feedback type
|
||||
const validTypes = ['bug', 'feature', 'feedback']
|
||||
if (!validTypes.includes(body.type)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid feedback type. Must be: bug, feature, or feedback',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
// const response = await fetch(`${backendUrl}/api/feedback`, {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// // Add auth headers
|
||||
// },
|
||||
// body: JSON.stringify({
|
||||
// type: body.type,
|
||||
// title: body.title,
|
||||
// description: body.description,
|
||||
// screenshot: body.screenshot,
|
||||
// sessionId: body.sessionId,
|
||||
// metadata: {
|
||||
// ...body.metadata,
|
||||
// source: 'companion',
|
||||
// timestamp: new Date().toISOString(),
|
||||
// userAgent: request.headers.get('user-agent'),
|
||||
// },
|
||||
// }),
|
||||
// })
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`Backend responded with ${response.status}`)
|
||||
// }
|
||||
//
|
||||
// const data = await response.json()
|
||||
// return NextResponse.json(data)
|
||||
|
||||
// Mock response - just acknowledge the submission
|
||||
const feedbackId = `fb-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
console.log('Feedback received:', {
|
||||
id: feedbackId,
|
||||
type: body.type,
|
||||
title: body.title,
|
||||
description: body.description.substring(0, 100) + '...',
|
||||
hasScreenshot: !!body.screenshot,
|
||||
sessionId: body.sessionId,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Feedback submitted successfully',
|
||||
data: {
|
||||
feedbackId,
|
||||
submittedAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Submit feedback error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/companion/feedback
|
||||
* Get feedback history (admin only)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const type = searchParams.get('type')
|
||||
const limit = parseInt(searchParams.get('limit') || '10')
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
|
||||
// Mock response - empty list for now
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
feedback: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get feedback error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
194
admin-v2/app/api/admin/companion/lesson/route.ts
Normal file
194
admin-v2/app/api/admin/companion/lesson/route.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* POST /api/admin/companion/lesson
|
||||
* Start a new lesson session
|
||||
* Proxy to backend /api/classroom/sessions
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
// const response = await fetch(`${backendUrl}/api/classroom/sessions`, {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// },
|
||||
// body: JSON.stringify(body),
|
||||
// })
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`Backend responded with ${response.status}`)
|
||||
// }
|
||||
//
|
||||
// const data = await response.json()
|
||||
// return NextResponse.json(data)
|
||||
|
||||
// Mock response - create a new session
|
||||
const sessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
const mockSession = {
|
||||
success: true,
|
||||
data: {
|
||||
sessionId,
|
||||
classId: body.classId,
|
||||
className: body.className || body.classId,
|
||||
subject: body.subject,
|
||||
topic: body.topic,
|
||||
startTime: new Date().toISOString(),
|
||||
phases: [
|
||||
{ phase: 'einstieg', duration: 8, status: 'active', actualTime: 0 },
|
||||
{ phase: 'erarbeitung', duration: 20, status: 'planned', actualTime: 0 },
|
||||
{ phase: 'sicherung', duration: 10, status: 'planned', actualTime: 0 },
|
||||
{ phase: 'transfer', duration: 7, status: 'planned', actualTime: 0 },
|
||||
{ phase: 'reflexion', duration: 5, status: 'planned', actualTime: 0 },
|
||||
],
|
||||
totalPlannedDuration: 50,
|
||||
currentPhaseIndex: 0,
|
||||
elapsedTime: 0,
|
||||
isPaused: false,
|
||||
pauseDuration: 0,
|
||||
overtimeMinutes: 0,
|
||||
status: 'in_progress',
|
||||
homeworkList: [],
|
||||
materials: [],
|
||||
},
|
||||
}
|
||||
|
||||
return NextResponse.json(mockSession)
|
||||
} catch (error) {
|
||||
console.error('Start lesson error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/companion/lesson
|
||||
* Get current lesson session or list of recent sessions
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const sessionId = searchParams.get('sessionId')
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
// const url = sessionId
|
||||
// ? `${backendUrl}/api/classroom/sessions/${sessionId}`
|
||||
// : `${backendUrl}/api/classroom/sessions`
|
||||
//
|
||||
// const response = await fetch(url)
|
||||
// const data = await response.json()
|
||||
// return NextResponse.json(data)
|
||||
|
||||
// Mock response
|
||||
if (sessionId) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: null, // No active session stored on server in mock
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
sessions: [], // Empty list for now
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get lesson error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/companion/lesson
|
||||
* Update lesson session (timer state, phase changes, etc.)
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { sessionId, ...updates } = body
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Session ID required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
// const response = await fetch(`${backendUrl}/api/classroom/sessions/${sessionId}`, {
|
||||
// method: 'PATCH',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify(updates),
|
||||
// })
|
||||
//
|
||||
// const data = await response.json()
|
||||
// return NextResponse.json(data)
|
||||
|
||||
// Mock response - just acknowledge the update
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Session updated',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Update lesson error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/companion/lesson
|
||||
* End/delete a lesson session
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const sessionId = searchParams.get('sessionId')
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Session ID required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Session ended',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('End lesson error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
102
admin-v2/app/api/admin/companion/route.ts
Normal file
102
admin-v2/app/api/admin/companion/route.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* GET /api/admin/companion
|
||||
* Proxy to backend /api/state/dashboard for companion dashboard data
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
// TODO: Replace with actual backend call when endpoint is available
|
||||
// const response = await fetch(`${backendUrl}/api/state/dashboard`, {
|
||||
// method: 'GET',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// },
|
||||
// })
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`Backend responded with ${response.status}`)
|
||||
// }
|
||||
//
|
||||
// const data = await response.json()
|
||||
// return NextResponse.json(data)
|
||||
|
||||
// Mock response for development
|
||||
const mockData = {
|
||||
success: true,
|
||||
data: {
|
||||
context: {
|
||||
currentPhase: 'erarbeitung',
|
||||
phaseDisplayName: 'Erarbeitung',
|
||||
},
|
||||
stats: {
|
||||
classesCount: 4,
|
||||
studentsCount: 96,
|
||||
learningUnitsCreated: 23,
|
||||
gradesEntered: 156,
|
||||
},
|
||||
phases: [
|
||||
{ id: 'einstieg', shortName: 'E', displayName: 'Einstieg', duration: 8, status: 'completed', color: '#4A90E2' },
|
||||
{ id: 'erarbeitung', shortName: 'A', displayName: 'Erarbeitung', duration: 20, status: 'active', color: '#F5A623' },
|
||||
{ id: 'sicherung', shortName: 'S', displayName: 'Sicherung', duration: 10, status: 'planned', color: '#7ED321' },
|
||||
{ id: 'transfer', shortName: 'T', displayName: 'Transfer', duration: 7, status: 'planned', color: '#9013FE' },
|
||||
{ id: 'reflexion', shortName: 'R', displayName: 'Reflexion', duration: 5, status: 'planned', color: '#6B7280' },
|
||||
],
|
||||
progress: {
|
||||
percentage: 65,
|
||||
completed: 13,
|
||||
total: 20,
|
||||
},
|
||||
suggestions: [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Klausuren korrigieren',
|
||||
description: 'Deutsch LK - 12 unkorrigierte Arbeiten warten',
|
||||
priority: 'urgent',
|
||||
icon: 'ClipboardCheck',
|
||||
actionTarget: '/ai/klausur-korrektur',
|
||||
estimatedTime: 120,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Elternsprechtag vorbereiten',
|
||||
description: 'Notenuebersicht fuer 8b erstellen',
|
||||
priority: 'high',
|
||||
icon: 'Users',
|
||||
actionTarget: '/education/grades',
|
||||
estimatedTime: 30,
|
||||
},
|
||||
],
|
||||
upcomingEvents: [
|
||||
{
|
||||
id: 'e1',
|
||||
title: 'Mathe-Test 9b',
|
||||
date: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
type: 'exam',
|
||||
inDays: 2,
|
||||
},
|
||||
{
|
||||
id: 'e2',
|
||||
title: 'Elternsprechtag',
|
||||
date: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
type: 'parent_meeting',
|
||||
inDays: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
return NextResponse.json(mockData)
|
||||
} catch (error) {
|
||||
console.error('Companion dashboard error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
137
admin-v2/app/api/admin/companion/settings/route.ts
Normal file
137
admin-v2/app/api/admin/companion/settings/route.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
defaultPhaseDurations: {
|
||||
einstieg: 8,
|
||||
erarbeitung: 20,
|
||||
sicherung: 10,
|
||||
transfer: 7,
|
||||
reflexion: 5,
|
||||
},
|
||||
preferredLessonLength: 45,
|
||||
autoAdvancePhases: true,
|
||||
soundNotifications: true,
|
||||
showKeyboardShortcuts: true,
|
||||
highContrastMode: false,
|
||||
onboardingCompleted: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/companion/settings
|
||||
* Get teacher settings
|
||||
* Proxy to backend /api/teacher/settings
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
// TODO: Replace with actual backend call
|
||||
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
// const response = await fetch(`${backendUrl}/api/teacher/settings`, {
|
||||
// method: 'GET',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// // Add auth headers
|
||||
// },
|
||||
// })
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`Backend responded with ${response.status}`)
|
||||
// }
|
||||
//
|
||||
// const data = await response.json()
|
||||
// return NextResponse.json(data)
|
||||
|
||||
// Mock response - return default settings
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: DEFAULT_SETTINGS,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get settings error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/admin/companion/settings
|
||||
* Update teacher settings
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Validate the settings structure
|
||||
if (!body || typeof body !== 'object') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid settings data' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
// const response = await fetch(`${backendUrl}/api/teacher/settings`, {
|
||||
// method: 'PUT',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// // Add auth headers
|
||||
// },
|
||||
// body: JSON.stringify(body),
|
||||
// })
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`Backend responded with ${response.status}`)
|
||||
// }
|
||||
//
|
||||
// const data = await response.json()
|
||||
// return NextResponse.json(data)
|
||||
|
||||
// Mock response - just acknowledge the save
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Settings saved',
|
||||
data: body,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Save settings error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/companion/settings
|
||||
* Partially update teacher settings
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Settings updated',
|
||||
data: body,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Update settings error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
215
admin-v2/app/api/ai/rag-pipeline/route.ts
Normal file
215
admin-v2/app/api/ai/rag-pipeline/route.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
// Backend URL - klausur-service
|
||||
const KLAUSUR_SERVICE_URL = process.env.KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
// Helper to proxy requests to backend
|
||||
async function proxyRequest(
|
||||
endpoint: string,
|
||||
method: string = 'GET',
|
||||
body?: any
|
||||
): Promise<Response> {
|
||||
const url = `${KLAUSUR_SERVICE_URL}${endpoint}`
|
||||
const options: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
|
||||
if (body) {
|
||||
options.body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
return fetch(url, options)
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const action = searchParams.get('action')
|
||||
const jobId = searchParams.get('job_id')
|
||||
const versionId = searchParams.get('version_id')
|
||||
|
||||
try {
|
||||
let response: Response
|
||||
|
||||
switch (action) {
|
||||
case 'jobs':
|
||||
response = await proxyRequest('/api/v1/admin/training/jobs')
|
||||
break
|
||||
|
||||
case 'job':
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: 'job_id required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}`)
|
||||
break
|
||||
|
||||
case 'models':
|
||||
response = await proxyRequest('/api/v1/admin/training/models')
|
||||
break
|
||||
|
||||
case 'model':
|
||||
if (!versionId) {
|
||||
return NextResponse.json({ error: 'version_id required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest(`/api/v1/admin/training/models/${versionId}`)
|
||||
break
|
||||
|
||||
case 'dataset-stats':
|
||||
response = await proxyRequest('/api/v1/admin/training/dataset/stats')
|
||||
break
|
||||
|
||||
case 'status':
|
||||
response = await proxyRequest('/api/v1/admin/training/status')
|
||||
break
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unknown action',
|
||||
validActions: ['jobs', 'job', 'models', 'model', 'dataset-stats', 'status']
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error(`Backend error: ${response.status} - ${errorText}`)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend error', detail: errorText },
|
||||
{ status: response.status }
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Training API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend', detail: String(error) },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const action = searchParams.get('action')
|
||||
const jobId = searchParams.get('job_id')
|
||||
const versionId = searchParams.get('version_id')
|
||||
|
||||
try {
|
||||
let response: Response
|
||||
let body: any = null
|
||||
|
||||
// Parse body if present
|
||||
try {
|
||||
const text = await request.text()
|
||||
if (text) {
|
||||
body = JSON.parse(text)
|
||||
}
|
||||
} catch {
|
||||
// No body or invalid JSON
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'create-job':
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: 'Body required for job creation' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest('/api/v1/admin/training/jobs', 'POST', body)
|
||||
break
|
||||
|
||||
case 'pause':
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: 'job_id required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}/pause`, 'POST')
|
||||
break
|
||||
|
||||
case 'resume':
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: 'job_id required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}/resume`, 'POST')
|
||||
break
|
||||
|
||||
case 'cancel':
|
||||
if (!jobId) {
|
||||
return NextResponse.json({ error: 'job_id required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}/cancel`, 'POST')
|
||||
break
|
||||
|
||||
case 'activate-model':
|
||||
if (!versionId) {
|
||||
return NextResponse.json({ error: 'version_id required' }, { status: 400 })
|
||||
}
|
||||
response = await proxyRequest(`/api/v1/admin/training/models/${versionId}/activate`, 'POST')
|
||||
break
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Unknown action',
|
||||
validActions: ['create-job', 'pause', 'resume', 'cancel', 'activate-model']
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
||||
return NextResponse.json(errorData, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Training API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend', detail: String(error) },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const jobId = searchParams.get('job_id')
|
||||
const versionId = searchParams.get('version_id')
|
||||
|
||||
try {
|
||||
let response: Response
|
||||
|
||||
if (jobId) {
|
||||
response = await proxyRequest(`/api/v1/admin/training/jobs/${jobId}`, 'DELETE')
|
||||
} else if (versionId) {
|
||||
response = await proxyRequest(`/api/v1/admin/training/models/${versionId}`, 'DELETE')
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Either job_id or version_id required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
||||
return NextResponse.json(errorData, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Training API proxy error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to connect to backend', detail: String(error) },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
68
admin-v2/app/api/development/content/route.ts
Normal file
68
admin-v2/app/api/development/content/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Content API Route
|
||||
*
|
||||
* GET: Load current website content
|
||||
* POST: Save changed content (Admin only)
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getContent, saveContent } from '@/lib/content'
|
||||
import type { WebsiteContent } from '@/lib/content-types'
|
||||
|
||||
// GET - Load content
|
||||
export async function GET() {
|
||||
try {
|
||||
const content = getContent()
|
||||
return NextResponse.json(content)
|
||||
} catch (error) {
|
||||
console.error('Error loading content:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to load content' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Save content
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// Simple admin check via header or query
|
||||
// In production: JWT/Session-based auth
|
||||
const adminKey = request.headers.get('x-admin-key')
|
||||
const expectedKey = process.env.ADMIN_API_KEY || 'breakpilot-admin-2024'
|
||||
|
||||
if (adminKey !== expectedKey) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const content: WebsiteContent = await request.json()
|
||||
|
||||
// Validation
|
||||
if (!content.hero || !content.features || !content.faq || !content.pricing) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid content structure' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = saveContent(content)
|
||||
|
||||
if (result.success) {
|
||||
return NextResponse.json({ success: true, message: 'Content saved' })
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: result.error || 'Failed to save content' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving content:', error)
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to save content' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
246
admin-v2/app/api/education/abitur-archiv/route.ts
Normal file
246
admin-v2/app/api/education/abitur-archiv/route.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* Abitur-Archiv API Route
|
||||
* Extends abitur-docs with theme search and enhanced filtering
|
||||
*/
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
// Check for theme/semantic search
|
||||
const thema = searchParams.get('thema')
|
||||
|
||||
if (thema) {
|
||||
// Use semantic search endpoint
|
||||
return await handleSemanticSearch(thema, searchParams)
|
||||
}
|
||||
|
||||
// Forward all query params to backend abitur-docs
|
||||
const queryString = searchParams.toString()
|
||||
const url = `${BACKEND_URL}/api/abitur-docs/${queryString ? `?${queryString}` : ''}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Return mock data for development if backend is not available
|
||||
if (response.status === 404 || response.status === 502) {
|
||||
return NextResponse.json(getMockDocuments(searchParams))
|
||||
}
|
||||
throw new Error(`Backend responded with ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// If backend returns empty, use mock data for demo
|
||||
if (data.documents && Array.isArray(data.documents) && data.documents.length === 0 && data.total === 0) {
|
||||
return NextResponse.json(getMockDocuments(searchParams))
|
||||
}
|
||||
|
||||
// Enhance response with theme information
|
||||
return NextResponse.json({
|
||||
...data,
|
||||
themes: extractThemes(data.documents || [])
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Abitur-Archiv error:', error)
|
||||
return NextResponse.json(getMockDocuments(new URL(request.url).searchParams))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSemanticSearch(thema: string, searchParams: URLSearchParams) {
|
||||
try {
|
||||
// Try to call RAG search endpoint
|
||||
const url = `${BACKEND_URL}/api/rag/search`
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: thema,
|
||||
collection: 'abitur_documents',
|
||||
limit: parseInt(searchParams.get('limit') || '20'),
|
||||
filters: {
|
||||
fach: searchParams.get('fach') || undefined,
|
||||
jahr: searchParams.get('jahr') ? parseInt(searchParams.get('jahr')!) : undefined,
|
||||
bundesland: searchParams.get('bundesland') || undefined,
|
||||
niveau: searchParams.get('niveau') || undefined,
|
||||
typ: searchParams.get('typ') || undefined,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
return NextResponse.json({
|
||||
documents: data.results || [],
|
||||
total: data.total || 0,
|
||||
page: 1,
|
||||
limit: parseInt(searchParams.get('limit') || '20'),
|
||||
total_pages: 1,
|
||||
search_query: thema
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('RAG search not available, falling back to mock')
|
||||
}
|
||||
|
||||
// Fallback to filtered mock data
|
||||
return NextResponse.json(getMockDocumentsWithTheme(thema, searchParams))
|
||||
}
|
||||
|
||||
function getMockDocuments(searchParams: URLSearchParams) {
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const fach = searchParams.get('fach')
|
||||
const jahr = searchParams.get('jahr')
|
||||
const bundesland = searchParams.get('bundesland')
|
||||
const niveau = searchParams.get('niveau')
|
||||
const typ = searchParams.get('typ')
|
||||
|
||||
// Generate mock documents
|
||||
const allDocs = generateMockDocs()
|
||||
|
||||
// Apply filters
|
||||
let filtered = allDocs
|
||||
if (fach) filtered = filtered.filter(d => d.fach === fach)
|
||||
if (jahr) filtered = filtered.filter(d => d.jahr === parseInt(jahr))
|
||||
if (bundesland) filtered = filtered.filter(d => d.bundesland === bundesland)
|
||||
if (niveau) filtered = filtered.filter(d => d.niveau === niveau)
|
||||
if (typ) filtered = filtered.filter(d => d.typ === typ)
|
||||
|
||||
// Paginate
|
||||
const start = (page - 1) * limit
|
||||
const docs = filtered.slice(start, start + limit)
|
||||
|
||||
return {
|
||||
documents: docs,
|
||||
total: filtered.length,
|
||||
page,
|
||||
limit,
|
||||
total_pages: Math.ceil(filtered.length / limit),
|
||||
themes: extractThemes(docs)
|
||||
}
|
||||
}
|
||||
|
||||
function getMockDocumentsWithTheme(thema: string, searchParams: URLSearchParams) {
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const allDocs = generateMockDocs()
|
||||
|
||||
// Simple theme matching (in production this would be semantic search)
|
||||
const themaLower = thema.toLowerCase()
|
||||
let filtered = allDocs
|
||||
|
||||
// Match theme to aufgabentyp keywords
|
||||
if (themaLower.includes('gedicht')) {
|
||||
filtered = filtered.filter(d => d.themes?.includes('gedichtanalyse'))
|
||||
} else if (themaLower.includes('drama')) {
|
||||
filtered = filtered.filter(d => d.themes?.includes('dramenanalyse'))
|
||||
} else if (themaLower.includes('prosa') || themaLower.includes('roman')) {
|
||||
filtered = filtered.filter(d => d.themes?.includes('prosaanalyse'))
|
||||
} else if (themaLower.includes('eroerterung')) {
|
||||
filtered = filtered.filter(d => d.themes?.includes('eroerterung'))
|
||||
} else if (themaLower.includes('text') || themaLower.includes('analyse')) {
|
||||
filtered = filtered.filter(d => d.themes?.includes('textanalyse'))
|
||||
}
|
||||
|
||||
// Apply additional filters
|
||||
const fach = searchParams.get('fach')
|
||||
const jahr = searchParams.get('jahr')
|
||||
if (fach) filtered = filtered.filter(d => d.fach === fach)
|
||||
if (jahr) filtered = filtered.filter(d => d.jahr === parseInt(jahr))
|
||||
|
||||
return {
|
||||
documents: filtered.slice(0, limit),
|
||||
total: filtered.length,
|
||||
page: 1,
|
||||
limit,
|
||||
total_pages: Math.ceil(filtered.length / limit),
|
||||
search_query: thema,
|
||||
themes: extractThemes(filtered)
|
||||
}
|
||||
}
|
||||
|
||||
function generateMockDocs() {
|
||||
const faecher = ['deutsch', 'englisch']
|
||||
const jahre = [2021, 2022, 2023, 2024, 2025]
|
||||
const niveaus: Array<'eA' | 'gA'> = ['eA', 'gA']
|
||||
const typen: Array<'aufgabe' | 'erwartungshorizont'> = ['aufgabe', 'erwartungshorizont']
|
||||
const aufgabentypen = [
|
||||
{ nummer: 'I', themes: ['textanalyse', 'sachtext'] },
|
||||
{ nummer: 'II', themes: ['gedichtanalyse', 'lyrik'] },
|
||||
{ nummer: 'III', themes: ['prosaanalyse', 'epik'] },
|
||||
]
|
||||
|
||||
const docs = []
|
||||
let id = 1
|
||||
|
||||
for (const jahr of jahre) {
|
||||
for (const fach of faecher) {
|
||||
for (const niveau of niveaus) {
|
||||
for (const aufgabe of aufgabentypen) {
|
||||
for (const typ of typen) {
|
||||
const suffix = typ === 'erwartungshorizont' ? '_EWH' : ''
|
||||
const dateiname = `${jahr}_${capitalize(fach)}_${niveau}_Aufgabe_${aufgabe.nummer}${suffix}.pdf`
|
||||
|
||||
docs.push({
|
||||
id: `doc-${id++}`,
|
||||
dateiname,
|
||||
original_dateiname: dateiname,
|
||||
bundesland: 'niedersachsen',
|
||||
fach,
|
||||
jahr,
|
||||
niveau,
|
||||
typ,
|
||||
aufgaben_nummer: aufgabe.nummer,
|
||||
themes: aufgabe.themes,
|
||||
status: 'indexed' as const,
|
||||
confidence: 0.92 + Math.random() * 0.08,
|
||||
file_path: `/api/education/abitur-archiv/file/${dateiname}`,
|
||||
file_size: Math.floor(Math.random() * 500000) + 100000,
|
||||
indexed: true,
|
||||
vector_ids: [`vec-${id}-1`, `vec-${id}-2`],
|
||||
created_at: new Date(Date.now() - Math.random() * 365 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return docs
|
||||
}
|
||||
|
||||
function extractThemes(documents: any[]) {
|
||||
const themeCounts = new Map<string, number>()
|
||||
|
||||
for (const doc of documents) {
|
||||
const themes = doc.themes || []
|
||||
for (const theme of themes) {
|
||||
themeCounts.set(theme, (themeCounts.get(theme) || 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(themeCounts.entries())
|
||||
.map(([label, count]) => ({
|
||||
label: capitalize(label),
|
||||
count,
|
||||
aufgabentyp: label,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10)
|
||||
}
|
||||
|
||||
function capitalize(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
105
admin-v2/app/api/education/abitur-archiv/suggest/route.ts
Normal file
105
admin-v2/app/api/education/abitur-archiv/suggest/route.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* Theme Suggestions API for Abitur-Archiv
|
||||
* Returns autocomplete suggestions for semantic search
|
||||
*/
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const query = searchParams.get('q') || ''
|
||||
|
||||
if (query.length < 2) {
|
||||
return NextResponse.json({ suggestions: [], query })
|
||||
}
|
||||
|
||||
// Try to get suggestions from backend
|
||||
try {
|
||||
const url = `${BACKEND_URL}/api/abitur-archiv/suggest?q=${encodeURIComponent(query)}`
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Backend suggest not available, using static suggestions')
|
||||
}
|
||||
|
||||
// Fallback to static suggestions
|
||||
return NextResponse.json({
|
||||
suggestions: getStaticSuggestions(query),
|
||||
query
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Suggest error:', error)
|
||||
return NextResponse.json({ suggestions: [], query: '' })
|
||||
}
|
||||
}
|
||||
|
||||
function getStaticSuggestions(query: string) {
|
||||
const allSuggestions = [
|
||||
// Textanalyse
|
||||
{ label: 'Textanalyse', count: 45, aufgabentyp: 'textanalyse', kategorie: 'Analyse' },
|
||||
{ label: 'Textanalyse Sachtext', count: 28, aufgabentyp: 'textanalyse_pragmatisch', kategorie: 'Analyse' },
|
||||
{ label: 'Textanalyse Rede', count: 12, aufgabentyp: 'textanalyse_rede', kategorie: 'Analyse' },
|
||||
{ label: 'Textanalyse Kommentar', count: 8, aufgabentyp: 'textanalyse_kommentar', kategorie: 'Analyse' },
|
||||
|
||||
// Gedichtanalyse
|
||||
{ label: 'Gedichtanalyse', count: 38, aufgabentyp: 'gedichtanalyse', kategorie: 'Lyrik' },
|
||||
{ label: 'Gedichtanalyse Romantik', count: 15, aufgabentyp: 'gedichtanalyse', zeitraum: 'Romantik', kategorie: 'Lyrik' },
|
||||
{ label: 'Gedichtanalyse Expressionismus', count: 12, aufgabentyp: 'gedichtanalyse', zeitraum: 'Expressionismus', kategorie: 'Lyrik' },
|
||||
{ label: 'Gedichtanalyse Barock', count: 8, aufgabentyp: 'gedichtanalyse', zeitraum: 'Barock', kategorie: 'Lyrik' },
|
||||
{ label: 'Gedichtanalyse Klassik', count: 10, aufgabentyp: 'gedichtanalyse', zeitraum: 'Klassik', kategorie: 'Lyrik' },
|
||||
{ label: 'Gedichtanalyse Moderne', count: 14, aufgabentyp: 'gedichtanalyse', zeitraum: 'Moderne', kategorie: 'Lyrik' },
|
||||
{ label: 'Gedichtvergleich', count: 18, aufgabentyp: 'gedichtvergleich', kategorie: 'Lyrik' },
|
||||
|
||||
// Dramenanalyse
|
||||
{ label: 'Dramenanalyse', count: 28, aufgabentyp: 'dramenanalyse', kategorie: 'Drama' },
|
||||
{ label: 'Dramenanalyse Faust', count: 14, aufgabentyp: 'dramenanalyse', kategorie: 'Drama' },
|
||||
{ label: 'Dramenanalyse Woyzeck', count: 8, aufgabentyp: 'dramenanalyse', kategorie: 'Drama' },
|
||||
{ label: 'Episches Theater Brecht', count: 10, aufgabentyp: 'dramenanalyse', kategorie: 'Drama' },
|
||||
{ label: 'Szenenanalyse', count: 22, aufgabentyp: 'szenenanalyse', kategorie: 'Drama' },
|
||||
|
||||
// Prosaanalyse
|
||||
{ label: 'Prosaanalyse', count: 25, aufgabentyp: 'prosaanalyse', kategorie: 'Epik' },
|
||||
{ label: 'Romananalyse', count: 18, aufgabentyp: 'prosaanalyse', kategorie: 'Epik' },
|
||||
{ label: 'Kurzgeschichte', count: 20, aufgabentyp: 'prosaanalyse', kategorie: 'Epik' },
|
||||
{ label: 'Novelle', count: 12, aufgabentyp: 'prosaanalyse', kategorie: 'Epik' },
|
||||
{ label: 'Erzaehlung', count: 15, aufgabentyp: 'prosaanalyse', kategorie: 'Epik' },
|
||||
|
||||
// Eroerterung
|
||||
{ label: 'Eroerterung', count: 32, aufgabentyp: 'eroerterung', kategorie: 'Argumentation' },
|
||||
{ label: 'Eroerterung textgebunden', count: 18, aufgabentyp: 'eroerterung_textgebunden', kategorie: 'Argumentation' },
|
||||
{ label: 'Eroerterung materialgestuetzt', count: 14, aufgabentyp: 'eroerterung_materialgestuetzt', kategorie: 'Argumentation' },
|
||||
{ label: 'Stellungnahme', count: 10, aufgabentyp: 'stellungnahme', kategorie: 'Argumentation' },
|
||||
|
||||
// Sprachreflexion
|
||||
{ label: 'Sprachreflexion', count: 15, aufgabentyp: 'sprachreflexion', kategorie: 'Sprache' },
|
||||
{ label: 'Sprachwandel', count: 8, aufgabentyp: 'sprachreflexion', kategorie: 'Sprache' },
|
||||
{ label: 'Sprachkritik', count: 6, aufgabentyp: 'sprachreflexion', kategorie: 'Sprache' },
|
||||
{ label: 'Kommunikation', count: 10, aufgabentyp: 'kommunikation', kategorie: 'Sprache' },
|
||||
|
||||
// Vergleich
|
||||
{ label: 'Vergleichende Analyse', count: 20, aufgabentyp: 'vergleich', kategorie: 'Vergleich' },
|
||||
{ label: 'Epochenvergleich', count: 12, aufgabentyp: 'epochenvergleich', kategorie: 'Vergleich' },
|
||||
]
|
||||
|
||||
const queryLower = query.toLowerCase()
|
||||
|
||||
// Filter suggestions based on query
|
||||
return allSuggestions
|
||||
.filter(s =>
|
||||
s.label.toLowerCase().includes(queryLower) ||
|
||||
s.aufgabentyp.toLowerCase().includes(queryLower) ||
|
||||
(s.zeitraum && s.zeitraum.toLowerCase().includes(queryLower)) ||
|
||||
s.kategorie.toLowerCase().includes(queryLower)
|
||||
)
|
||||
.slice(0, 8)
|
||||
}
|
||||
139
admin-v2/app/api/education/abitur-docs/route.ts
Normal file
139
admin-v2/app/api/education/abitur-docs/route.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* Proxy to backend /api/abitur-docs
|
||||
* Lists and manages Abitur documents (NiBiS, etc.)
|
||||
*/
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
// Forward all query params to backend
|
||||
const queryString = searchParams.toString()
|
||||
const url = `${BACKEND_URL}/api/abitur-docs/${queryString ? `?${queryString}` : ''}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// Return mock data for development if backend is not available
|
||||
if (response.status === 404 || response.status === 502) {
|
||||
return NextResponse.json(getMockDocuments(searchParams))
|
||||
}
|
||||
throw new Error(`Backend responded with ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// If backend returns empty array, use mock data for demo purposes
|
||||
// (Backend uses in-memory storage which is lost on restart)
|
||||
if (Array.isArray(data) && data.length === 0) {
|
||||
console.log('Backend returned empty array, using mock data')
|
||||
return NextResponse.json(getMockDocuments(searchParams))
|
||||
}
|
||||
|
||||
// Handle paginated response with empty documents
|
||||
if (data.documents && Array.isArray(data.documents) && data.documents.length === 0 && data.total === 0) {
|
||||
console.log('Backend returned empty documents, using mock data')
|
||||
return NextResponse.json(getMockDocuments(searchParams))
|
||||
}
|
||||
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Abitur docs list error:', error)
|
||||
// Return mock data for development
|
||||
return NextResponse.json(getMockDocuments(new URL(request.url).searchParams))
|
||||
}
|
||||
}
|
||||
|
||||
function getMockDocuments(searchParams: URLSearchParams) {
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const fach = searchParams.get('fach')
|
||||
const jahr = searchParams.get('jahr')
|
||||
const bundesland = searchParams.get('bundesland')
|
||||
|
||||
// Generate mock documents
|
||||
const allDocs = generateMockDocs()
|
||||
|
||||
// Apply filters
|
||||
let filtered = allDocs
|
||||
if (fach) {
|
||||
filtered = filtered.filter(d => d.fach === fach)
|
||||
}
|
||||
if (jahr) {
|
||||
filtered = filtered.filter(d => d.jahr === parseInt(jahr))
|
||||
}
|
||||
if (bundesland) {
|
||||
filtered = filtered.filter(d => d.bundesland === bundesland)
|
||||
}
|
||||
|
||||
// Paginate
|
||||
const start = (page - 1) * limit
|
||||
const docs = filtered.slice(start, start + limit)
|
||||
|
||||
return {
|
||||
documents: docs,
|
||||
total: filtered.length,
|
||||
page,
|
||||
limit,
|
||||
total_pages: Math.ceil(filtered.length / limit),
|
||||
}
|
||||
}
|
||||
|
||||
function generateMockDocs() {
|
||||
const faecher = ['deutsch', 'mathematik', 'englisch', 'biologie', 'physik', 'chemie', 'geschichte']
|
||||
const jahre = [2024, 2025]
|
||||
const niveaus = ['eA', 'gA']
|
||||
const typen = ['aufgabe', 'erwartungshorizont']
|
||||
const nummern = ['I', 'II', 'III']
|
||||
|
||||
const docs = []
|
||||
let id = 1
|
||||
|
||||
for (const jahr of jahre) {
|
||||
for (const fach of faecher) {
|
||||
for (const niveau of niveaus) {
|
||||
for (const nummer of nummern) {
|
||||
for (const typ of typen) {
|
||||
const suffix = typ === 'erwartungshorizont' ? '_EWH' : ''
|
||||
const dateiname = `${jahr}_${capitalize(fach)}_${niveau}_${nummer}${suffix}.pdf`
|
||||
|
||||
docs.push({
|
||||
id: `doc-${id++}`,
|
||||
dateiname,
|
||||
original_dateiname: dateiname,
|
||||
bundesland: 'niedersachsen',
|
||||
fach,
|
||||
jahr,
|
||||
niveau,
|
||||
typ,
|
||||
aufgaben_nummer: nummer,
|
||||
status: 'indexed',
|
||||
confidence: 0.95,
|
||||
file_path: `/tmp/abitur-docs/${dateiname}`,
|
||||
file_size: Math.floor(Math.random() * 500000) + 100000,
|
||||
indexed: true,
|
||||
vector_ids: [`vec-${id}-1`, `vec-${id}-2`],
|
||||
created_at: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return docs
|
||||
}
|
||||
|
||||
function capitalize(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
395
admin-v2/app/api/infrastructure/log-extract/extract/route.ts
Normal file
395
admin-v2/app/api/infrastructure/log-extract/extract/route.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import type { ExtractedError, ErrorCategory, LogExtractionResponse } from '@/types/infrastructure-modules'
|
||||
|
||||
// Woodpecker API configuration
|
||||
const WOODPECKER_URL = process.env.WOODPECKER_URL || 'http://woodpecker-server:8000'
|
||||
const WOODPECKER_TOKEN = process.env.WOODPECKER_TOKEN || ''
|
||||
|
||||
// =============================================================================
|
||||
// Error Pattern Matching
|
||||
// =============================================================================
|
||||
|
||||
interface ErrorPattern {
|
||||
pattern: RegExp
|
||||
category: ErrorCategory
|
||||
extractMessage?: (match: RegExpMatchArray, line: string) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Patterns fuer verschiedene Fehlertypen in CI/CD Logs
|
||||
*/
|
||||
const ERROR_PATTERNS: ErrorPattern[] = [
|
||||
// Test Failures
|
||||
{
|
||||
pattern: /^(FAIL|FAILED|ERROR):?\s+(.+)$/i,
|
||||
category: 'test_failure',
|
||||
extractMessage: (match, line) => match[2] || line,
|
||||
},
|
||||
{
|
||||
pattern: /^---\s+FAIL:\s+(.+)\s+\([\d.]+s\)$/,
|
||||
category: 'test_failure',
|
||||
extractMessage: (match) => `Test failed: ${match[1]}`,
|
||||
},
|
||||
{
|
||||
pattern: /pytest.*FAILED\s+(.+)$/,
|
||||
category: 'test_failure',
|
||||
extractMessage: (match) => `pytest: ${match[1]}`,
|
||||
},
|
||||
{
|
||||
pattern: /AssertionError:\s+(.+)$/,
|
||||
category: 'test_failure',
|
||||
extractMessage: (match) => `Assertion failed: ${match[1]}`,
|
||||
},
|
||||
{
|
||||
pattern: /FAIL\s+[\w\/]+\s+\[build failed\]/,
|
||||
category: 'build_error',
|
||||
},
|
||||
|
||||
// Build Errors
|
||||
{
|
||||
pattern: /^(error|Error)\[[\w-]+\]:\s+(.+)$/,
|
||||
category: 'build_error',
|
||||
extractMessage: (match) => match[2],
|
||||
},
|
||||
{
|
||||
pattern: /cannot find (module|package)\s+["'](.+)["']/i,
|
||||
category: 'build_error',
|
||||
extractMessage: (match) => `Missing ${match[1]}: ${match[2]}`,
|
||||
},
|
||||
{
|
||||
pattern: /undefined:\s+(.+)$/,
|
||||
category: 'build_error',
|
||||
extractMessage: (match) => `Undefined: ${match[1]}`,
|
||||
},
|
||||
{
|
||||
pattern: /compilation failed/i,
|
||||
category: 'build_error',
|
||||
},
|
||||
{
|
||||
pattern: /npm ERR!\s+(.+)$/,
|
||||
category: 'build_error',
|
||||
extractMessage: (match) => `npm error: ${match[1]}`,
|
||||
},
|
||||
{
|
||||
pattern: /go:\s+(.+):\s+(.+)$/,
|
||||
category: 'build_error',
|
||||
extractMessage: (match) => `Go: ${match[1]}: ${match[2]}`,
|
||||
},
|
||||
|
||||
// Security Warnings
|
||||
{
|
||||
pattern: /\[CRITICAL\]\s+(.+)$/i,
|
||||
category: 'security_warning',
|
||||
extractMessage: (match) => `Critical: ${match[1]}`,
|
||||
},
|
||||
{
|
||||
pattern: /\[HIGH\]\s+(.+)$/i,
|
||||
category: 'security_warning',
|
||||
extractMessage: (match) => `High severity: ${match[1]}`,
|
||||
},
|
||||
{
|
||||
pattern: /CVE-\d{4}-\d+/,
|
||||
category: 'security_warning',
|
||||
extractMessage: (match, line) => line.trim(),
|
||||
},
|
||||
{
|
||||
pattern: /vulnerability found/i,
|
||||
category: 'security_warning',
|
||||
},
|
||||
{
|
||||
pattern: /secret.*detected/i,
|
||||
category: 'security_warning',
|
||||
},
|
||||
{
|
||||
pattern: /gitleaks.*found/i,
|
||||
category: 'security_warning',
|
||||
},
|
||||
{
|
||||
pattern: /semgrep.*finding/i,
|
||||
category: 'security_warning',
|
||||
},
|
||||
|
||||
// License Violations
|
||||
{
|
||||
pattern: /license.*violation/i,
|
||||
category: 'license_violation',
|
||||
},
|
||||
{
|
||||
pattern: /incompatible license/i,
|
||||
category: 'license_violation',
|
||||
},
|
||||
{
|
||||
pattern: /AGPL|GPL-3|SSPL/,
|
||||
category: 'license_violation',
|
||||
extractMessage: (match, line) => `Potentially problematic license found: ${match[0]}`,
|
||||
},
|
||||
|
||||
// Dependency Issues
|
||||
{
|
||||
pattern: /dependency.*not found/i,
|
||||
category: 'dependency_issue',
|
||||
},
|
||||
{
|
||||
pattern: /outdated.*dependency/i,
|
||||
category: 'dependency_issue',
|
||||
},
|
||||
{
|
||||
pattern: /version conflict/i,
|
||||
category: 'dependency_issue',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Patterns to extract file paths from error lines
|
||||
*/
|
||||
const FILE_PATH_PATTERNS = [
|
||||
/([\/\w.-]+\.(go|py|ts|tsx|js|jsx|rs)):(\d+)/,
|
||||
/File "([^"]+)", line (\d+)/,
|
||||
/at ([\/\w.-]+):(\d+):\d+/,
|
||||
]
|
||||
|
||||
/**
|
||||
* Patterns to extract service names from log lines or paths
|
||||
*/
|
||||
const SERVICE_PATTERNS = [
|
||||
/service[s]?\/([a-z-]+)/i,
|
||||
/\/([a-z-]+-service)\//i,
|
||||
/^([a-z-]+):\s/,
|
||||
]
|
||||
|
||||
// =============================================================================
|
||||
// Log Parsing Functions
|
||||
// =============================================================================
|
||||
|
||||
interface LogLine {
|
||||
pos: number
|
||||
out: string
|
||||
time: number
|
||||
}
|
||||
|
||||
function extractFilePath(line: string): { path?: string; lineNumber?: number } {
|
||||
for (const pattern of FILE_PATH_PATTERNS) {
|
||||
const match = line.match(pattern)
|
||||
if (match) {
|
||||
return {
|
||||
path: match[1],
|
||||
lineNumber: parseInt(match[2] || match[3], 10) || undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
function extractService(line: string, filePath?: string): string | undefined {
|
||||
// First try to extract from file path
|
||||
if (filePath) {
|
||||
for (const pattern of SERVICE_PATTERNS) {
|
||||
const match = filePath.match(pattern)
|
||||
if (match) return match[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Then try from the line itself
|
||||
for (const pattern of SERVICE_PATTERNS) {
|
||||
const match = line.match(pattern)
|
||||
if (match) return match[1]
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseLogLines(logs: LogLine[], stepName: string): ExtractedError[] {
|
||||
const errors: ExtractedError[] = []
|
||||
const seenMessages = new Set<string>()
|
||||
|
||||
for (const logLine of logs) {
|
||||
const line = logLine.out.trim()
|
||||
if (!line) continue
|
||||
|
||||
for (const errorPattern of ERROR_PATTERNS) {
|
||||
const match = line.match(errorPattern.pattern)
|
||||
if (match) {
|
||||
const message = errorPattern.extractMessage
|
||||
? errorPattern.extractMessage(match, line)
|
||||
: line
|
||||
|
||||
// Deduplicate similar errors
|
||||
const messageKey = `${errorPattern.category}:${message.substring(0, 100)}`
|
||||
if (seenMessages.has(messageKey)) continue
|
||||
seenMessages.add(messageKey)
|
||||
|
||||
const fileInfo = extractFilePath(line)
|
||||
const service = extractService(line, fileInfo.path)
|
||||
|
||||
errors.push({
|
||||
step: stepName,
|
||||
line: logLine.pos,
|
||||
message,
|
||||
category: errorPattern.category,
|
||||
file_path: fileInfo.path,
|
||||
service,
|
||||
})
|
||||
|
||||
break // Only match first pattern per line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Handler
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* POST /api/infrastructure/logs/extract
|
||||
*
|
||||
* Extrahiert Fehler aus Woodpecker Pipeline Logs.
|
||||
*
|
||||
* Request Body:
|
||||
* - pipeline_number: number (required)
|
||||
* - repo_id?: string (default: '1')
|
||||
*
|
||||
* Response:
|
||||
* - errors: ExtractedError[]
|
||||
* - pipeline_number: number
|
||||
* - extracted_at: string
|
||||
* - lines_parsed: number
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { pipeline_number, repo_id = '1' } = body
|
||||
|
||||
if (!pipeline_number) {
|
||||
return NextResponse.json(
|
||||
{ error: 'pipeline_number ist erforderlich' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// 1. Fetch pipeline details to get step IDs
|
||||
const pipelineResponse = await fetch(
|
||||
`${WOODPECKER_URL}/api/repos/${repo_id}/pipelines/${pipeline_number}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${WOODPECKER_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
}
|
||||
)
|
||||
|
||||
if (!pipelineResponse.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Pipeline ${pipeline_number} nicht gefunden` },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const pipeline = await pipelineResponse.json()
|
||||
|
||||
// 2. Extract step IDs from workflows
|
||||
const failedSteps: { id: number; name: string }[] = []
|
||||
|
||||
if (pipeline.workflows) {
|
||||
for (const workflow of pipeline.workflows) {
|
||||
if (workflow.children) {
|
||||
for (const child of workflow.children) {
|
||||
if (child.state === 'failure' || child.state === 'error') {
|
||||
failedSteps.push({
|
||||
id: child.id,
|
||||
name: child.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fetch logs for each failed step
|
||||
const allErrors: ExtractedError[] = []
|
||||
let totalLinesParsed = 0
|
||||
|
||||
for (const step of failedSteps) {
|
||||
try {
|
||||
const logsResponse = await fetch(
|
||||
`${WOODPECKER_URL}/api/repos/${repo_id}/pipelines/${pipeline_number}/logs/${step.id}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${WOODPECKER_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (logsResponse.ok) {
|
||||
const logs: LogLine[] = await logsResponse.json()
|
||||
totalLinesParsed += logs.length
|
||||
|
||||
const stepErrors = parseLogLines(logs, step.name)
|
||||
allErrors.push(...stepErrors)
|
||||
}
|
||||
} catch (logError) {
|
||||
console.error(`Failed to fetch logs for step ${step.name}:`, logError)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Sort errors by severity (security > license > build > test > dependency)
|
||||
const categoryPriority: Record<ErrorCategory, number> = {
|
||||
'security_warning': 1,
|
||||
'license_violation': 2,
|
||||
'build_error': 3,
|
||||
'test_failure': 4,
|
||||
'dependency_issue': 5,
|
||||
}
|
||||
|
||||
allErrors.sort((a, b) => categoryPriority[a.category] - categoryPriority[b.category])
|
||||
|
||||
const response: LogExtractionResponse = {
|
||||
errors: allErrors,
|
||||
pipeline_number,
|
||||
extracted_at: new Date().toISOString(),
|
||||
lines_parsed: totalLinesParsed,
|
||||
}
|
||||
|
||||
return NextResponse.json(response)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Log extraction error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Fehler bei der Log-Extraktion' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/infrastructure/logs/extract?pipeline_number=123
|
||||
*
|
||||
* Convenience method - calls POST internally
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const pipeline_number = searchParams.get('pipeline_number')
|
||||
const repo_id = searchParams.get('repo_id') || '1'
|
||||
|
||||
if (!pipeline_number) {
|
||||
return NextResponse.json(
|
||||
{ error: 'pipeline_number Query-Parameter ist erforderlich' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Create a mock request with JSON body
|
||||
const mockRequest = new NextRequest(request.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ pipeline_number: parseInt(pipeline_number, 10), repo_id }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
return POST(mockRequest)
|
||||
}
|
||||
@@ -9,18 +9,10 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
// Checkpoint definitions
|
||||
const CHECKPOINTS = {
|
||||
'CP-PROF': {
|
||||
id: 'CP-PROF',
|
||||
step: 'company-profile',
|
||||
name: 'Unternehmensprofil Checkpoint',
|
||||
type: 'REQUIRED',
|
||||
blocksProgress: true,
|
||||
requiresReview: 'NONE',
|
||||
},
|
||||
'CP-UC': {
|
||||
id: 'CP-UC',
|
||||
step: 'use-case-assessment',
|
||||
name: 'Anwendungsfall Checkpoint',
|
||||
step: 'use-case-workshop',
|
||||
name: 'Use Case Checkpoint',
|
||||
type: 'REQUIRED',
|
||||
blocksProgress: true,
|
||||
requiresReview: 'NONE',
|
||||
|
||||
@@ -10,15 +10,14 @@ import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const SDK_STEPS = [
|
||||
// Phase 1
|
||||
{ id: 'company-profile', phase: 1, order: 1, name: 'Unternehmensprofil', url: '/sdk/company-profile' },
|
||||
{ id: 'use-case-assessment', phase: 1, order: 2, name: 'Anwendungsfall-Erfassung', url: '/sdk/advisory-board' },
|
||||
{ id: 'screening', phase: 1, order: 3, name: 'System Screening', url: '/sdk/screening' },
|
||||
{ id: 'modules', phase: 1, order: 4, name: 'Compliance Modules', url: '/sdk/modules' },
|
||||
{ id: 'requirements', phase: 1, order: 5, name: 'Requirements', url: '/sdk/requirements' },
|
||||
{ id: 'controls', phase: 1, order: 6, name: 'Controls', url: '/sdk/controls' },
|
||||
{ id: 'evidence', phase: 1, order: 7, name: 'Evidence', url: '/sdk/evidence' },
|
||||
{ id: 'audit-checklist', phase: 1, order: 8, name: 'Audit Checklist', url: '/sdk/audit-checklist' },
|
||||
{ id: 'risks', phase: 1, order: 9, name: 'Risk Matrix', url: '/sdk/risks' },
|
||||
{ id: 'use-case-workshop', phase: 1, order: 1, name: 'Use Case Workshop', url: '/sdk/advisory-board' },
|
||||
{ id: 'screening', phase: 1, order: 2, name: 'System Screening', url: '/sdk/screening' },
|
||||
{ id: 'modules', phase: 1, order: 3, name: 'Compliance Modules', url: '/sdk/modules' },
|
||||
{ id: 'requirements', phase: 1, order: 4, name: 'Requirements', url: '/sdk/requirements' },
|
||||
{ id: 'controls', phase: 1, order: 5, name: 'Controls', url: '/sdk/controls' },
|
||||
{ id: 'evidence', phase: 1, order: 6, name: 'Evidence', url: '/sdk/evidence' },
|
||||
{ id: 'audit-checklist', phase: 1, order: 7, name: 'Audit Checklist', url: '/sdk/audit-checklist' },
|
||||
{ id: 'risks', phase: 1, order: 8, name: 'Risk Matrix', url: '/sdk/risks' },
|
||||
// Phase 2
|
||||
{ id: 'ai-act', phase: 2, order: 1, name: 'AI Act Klassifizierung', url: '/sdk/ai-act' },
|
||||
{ id: 'obligations', phase: 2, order: 2, name: 'Pflichtenübersicht', url: '/sdk/obligations' },
|
||||
@@ -56,7 +55,7 @@ function getPreviousStep(currentStepId: string) {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const currentStepId = searchParams.get('currentStep') || 'company-profile'
|
||||
const currentStepId = searchParams.get('currentStep') || 'use-case-workshop'
|
||||
|
||||
const currentStep = SDK_STEPS.find(s => s.id === currentStepId)
|
||||
const nextStep = getNextStep(currentStepId)
|
||||
|
||||
273
admin-v2/app/api/webhooks/woodpecker/route.ts
Normal file
273
admin-v2/app/api/webhooks/woodpecker/route.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import type { WoodpeckerWebhookPayload, ExtractedError, BacklogSource } from '@/types/infrastructure-modules'
|
||||
|
||||
// =============================================================================
|
||||
// Configuration
|
||||
// =============================================================================
|
||||
|
||||
// Webhook secret for verification (optional but recommended)
|
||||
const WEBHOOK_SECRET = process.env.WOODPECKER_WEBHOOK_SECRET || ''
|
||||
|
||||
// Internal API URL for log extraction
|
||||
const LOG_EXTRACT_URL = process.env.NEXT_PUBLIC_APP_URL
|
||||
? `${process.env.NEXT_PUBLIC_APP_URL}/api/infrastructure/log-extract/extract`
|
||||
: 'http://localhost:3002/api/infrastructure/log-extract/extract'
|
||||
|
||||
// Test service API URL for backlog insertion
|
||||
const TEST_SERVICE_URL = process.env.TEST_SERVICE_URL || 'http://localhost:8086'
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Verify webhook signature (if secret is configured)
|
||||
*/
|
||||
function verifySignature(request: NextRequest, body: string): boolean {
|
||||
if (!WEBHOOK_SECRET) return true // Skip verification if no secret configured
|
||||
|
||||
const signature = request.headers.get('X-Woodpecker-Signature')
|
||||
if (!signature) return false
|
||||
|
||||
// Simple HMAC verification (Woodpecker uses SHA256)
|
||||
const crypto = require('crypto')
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', WEBHOOK_SECRET)
|
||||
.update(body)
|
||||
.digest('hex')
|
||||
|
||||
return signature === `sha256=${expectedSignature}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Map error category to backlog priority
|
||||
*/
|
||||
function categoryToPriority(category: string): 'critical' | 'high' | 'medium' | 'low' {
|
||||
switch (category) {
|
||||
case 'security_warning':
|
||||
return 'critical'
|
||||
case 'build_error':
|
||||
return 'high'
|
||||
case 'license_violation':
|
||||
return 'high'
|
||||
case 'test_failure':
|
||||
return 'medium'
|
||||
case 'dependency_issue':
|
||||
return 'low'
|
||||
default:
|
||||
return 'medium'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map error category to error_type for backlog
|
||||
*/
|
||||
function categoryToErrorType(category: string): string {
|
||||
switch (category) {
|
||||
case 'security_warning':
|
||||
return 'security'
|
||||
case 'build_error':
|
||||
return 'build'
|
||||
case 'license_violation':
|
||||
return 'license'
|
||||
case 'test_failure':
|
||||
return 'test'
|
||||
case 'dependency_issue':
|
||||
return 'dependency'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert extracted errors into backlog
|
||||
*/
|
||||
async function insertIntoBacklog(
|
||||
errors: ExtractedError[],
|
||||
pipelineNumber: number,
|
||||
source: BacklogSource
|
||||
): Promise<{ inserted: number; failed: number }> {
|
||||
let inserted = 0
|
||||
let failed = 0
|
||||
|
||||
for (const error of errors) {
|
||||
try {
|
||||
// Create backlog item
|
||||
const backlogItem = {
|
||||
test_name: error.message.substring(0, 200), // Truncate long messages
|
||||
test_file: error.file_path || null,
|
||||
service: error.service || 'unknown',
|
||||
framework: `ci_cd_pipeline_${pipelineNumber}`,
|
||||
error_message: error.message,
|
||||
error_type: categoryToErrorType(error.category),
|
||||
status: 'open',
|
||||
priority: categoryToPriority(error.category),
|
||||
fix_suggestion: error.suggested_fix || null,
|
||||
notes: `Auto-generated from pipeline #${pipelineNumber}, step: ${error.step}, line: ${error.line}`,
|
||||
source, // Custom field to track origin
|
||||
}
|
||||
|
||||
// Try to insert into test service backlog
|
||||
const response = await fetch(`${TEST_SERVICE_URL}/api/v1/backlog`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(backlogItem),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
inserted++
|
||||
} else {
|
||||
console.warn(`Failed to insert backlog item: ${response.status}`)
|
||||
failed++
|
||||
}
|
||||
} catch (insertError) {
|
||||
console.error('Backlog insertion error:', insertError)
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
return { inserted, failed }
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API Handler
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/woodpecker
|
||||
*
|
||||
* Webhook endpoint fuer Woodpecker CI/CD Events.
|
||||
*
|
||||
* Bei Pipeline-Failure:
|
||||
* 1. Extrahiert Logs mit /api/infrastructure/logs/extract
|
||||
* 2. Parsed Fehler nach Kategorie
|
||||
* 3. Traegt automatisch in Backlog ein
|
||||
*
|
||||
* Request Body (Woodpecker Webhook Format):
|
||||
* - event: 'pipeline_success' | 'pipeline_failure' | 'pipeline_started'
|
||||
* - repo_id: number
|
||||
* - pipeline_number: number
|
||||
* - branch?: string
|
||||
* - commit?: string
|
||||
* - author?: string
|
||||
* - message?: string
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const bodyText = await request.text()
|
||||
|
||||
// Verify webhook signature
|
||||
if (!verifySignature(request, bodyText)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid webhook signature' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const payload: WoodpeckerWebhookPayload = JSON.parse(bodyText)
|
||||
|
||||
// Log all events for debugging
|
||||
console.log(`Woodpecker webhook: ${payload.event} for pipeline #${payload.pipeline_number}`)
|
||||
|
||||
// Only process pipeline_failure events
|
||||
if (payload.event !== 'pipeline_failure') {
|
||||
return NextResponse.json({
|
||||
status: 'ignored',
|
||||
message: `Event ${payload.event} wird nicht verarbeitet`,
|
||||
pipeline_number: payload.pipeline_number,
|
||||
})
|
||||
}
|
||||
|
||||
// 1. Extract logs from failed pipeline
|
||||
console.log(`Extracting logs for failed pipeline #${payload.pipeline_number}`)
|
||||
|
||||
const extractResponse = await fetch(LOG_EXTRACT_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pipeline_number: payload.pipeline_number,
|
||||
repo_id: String(payload.repo_id),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!extractResponse.ok) {
|
||||
const errorText = await extractResponse.text()
|
||||
console.error('Log extraction failed:', errorText)
|
||||
return NextResponse.json({
|
||||
status: 'error',
|
||||
message: 'Log-Extraktion fehlgeschlagen',
|
||||
pipeline_number: payload.pipeline_number,
|
||||
}, { status: 500 })
|
||||
}
|
||||
|
||||
const extractionResult = await extractResponse.json()
|
||||
const errors: ExtractedError[] = extractionResult.errors || []
|
||||
|
||||
console.log(`Extracted ${errors.length} errors from pipeline #${payload.pipeline_number}`)
|
||||
|
||||
// 2. Insert errors into backlog
|
||||
if (errors.length > 0) {
|
||||
const backlogResult = await insertIntoBacklog(
|
||||
errors,
|
||||
payload.pipeline_number,
|
||||
'ci_cd'
|
||||
)
|
||||
|
||||
console.log(`Backlog: ${backlogResult.inserted} inserted, ${backlogResult.failed} failed`)
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'processed',
|
||||
pipeline_number: payload.pipeline_number,
|
||||
branch: payload.branch,
|
||||
commit: payload.commit,
|
||||
errors_found: errors.length,
|
||||
backlog_inserted: backlogResult.inserted,
|
||||
backlog_failed: backlogResult.failed,
|
||||
categories: {
|
||||
test_failure: errors.filter(e => e.category === 'test_failure').length,
|
||||
build_error: errors.filter(e => e.category === 'build_error').length,
|
||||
security_warning: errors.filter(e => e.category === 'security_warning').length,
|
||||
license_violation: errors.filter(e => e.category === 'license_violation').length,
|
||||
dependency_issue: errors.filter(e => e.category === 'dependency_issue').length,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'processed',
|
||||
pipeline_number: payload.pipeline_number,
|
||||
message: 'Keine Fehler extrahiert',
|
||||
errors_found: 0,
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Webhook processing error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Webhook-Verarbeitung fehlgeschlagen' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/webhooks/woodpecker
|
||||
*
|
||||
* Health check endpoint
|
||||
*/
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: 'ready',
|
||||
endpoint: '/api/webhooks/woodpecker',
|
||||
events: ['pipeline_failure'],
|
||||
description: 'Woodpecker CI/CD Webhook Handler',
|
||||
configured: {
|
||||
webhook_secret: WEBHOOK_SECRET ? 'yes' : 'no',
|
||||
log_extract_url: LOG_EXTRACT_URL,
|
||||
test_service_url: TEST_SERVICE_URL,
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user