[split-required] Split 500-1000 LOC files across all services

backend-lehrer (5 files):
- alerts_agent/db/repository.py (992 → 5), abitur_docs_api.py (956 → 3)
- teacher_dashboard_api.py (951 → 3), services/pdf_service.py (916 → 3)
- mail/mail_db.py (987 → 6)

klausur-service (5 files):
- legal_templates_ingestion.py (942 → 3), ocr_pipeline_postprocess.py (929 → 4)
- ocr_pipeline_words.py (876 → 3), ocr_pipeline_ocr_merge.py (616 → 2)
- KorrekturPage.tsx (956 → 6)

website (5 pages):
- mail (985 → 9), edu-search (958 → 8), mac-mini (950 → 7)
- ocr-labeling (946 → 7), audit-workspace (871 → 4)

studio-v2 (5 files + 1 deleted):
- page.tsx (946 → 5), MessagesContext.tsx (925 → 4)
- korrektur (914 → 6), worksheet-cleanup (899 → 6)
- useVocabWorksheet.ts (888 → 3)
- Deleted dead page-original.tsx (934 LOC)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-24 23:35:37 +02:00
parent 6811264756
commit b6983ab1dc
99 changed files with 13484 additions and 16106 deletions

View File

@@ -0,0 +1,306 @@
'use client'
import { useState, useEffect } from 'react'
import type { Requirement, Regulation, RequirementUpdate, AIInterpretation } from '../types'
import { IMPLEMENTATION_STATUS, AUDIT_STATUS } from '../types'
export default function RequirementDetailPanel({
requirement,
regulation,
onUpdate,
saving,
}: {
requirement: Requirement
regulation: Regulation | undefined
onUpdate: (updates: RequirementUpdate) => void
saving: boolean
}) {
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
const [editMode, setEditMode] = useState(false)
const [aiLoading, setAiLoading] = useState(false)
const [aiInterpretation, setAiInterpretation] = useState<AIInterpretation | null>(null)
const [showAiPanel, setShowAiPanel] = useState(false)
const [localData, setLocalData] = useState({
implementation_status: requirement.implementation_status,
implementation_details: requirement.implementation_details || '',
evidence_description: requirement.evidence_description || '',
audit_status: requirement.audit_status,
auditor_notes: requirement.auditor_notes || '',
is_applicable: requirement.is_applicable,
applicability_reason: requirement.applicability_reason || '',
})
const [newCodeRef, setNewCodeRef] = useState({ file: '', line: '', description: '' })
useEffect(() => {
setLocalData({
implementation_status: requirement.implementation_status,
implementation_details: requirement.implementation_details || '',
evidence_description: requirement.evidence_description || '',
audit_status: requirement.audit_status,
auditor_notes: requirement.auditor_notes || '',
is_applicable: requirement.is_applicable,
applicability_reason: requirement.applicability_reason || '',
})
setEditMode(false)
setAiInterpretation(null)
setShowAiPanel(false)
}, [requirement.id])
const generateAiInterpretation = async () => {
setAiLoading(true)
setShowAiPanel(true)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/ai/interpret`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requirement_id: requirement.id }),
})
if (res.ok) {
setAiInterpretation(await res.json())
} else {
const err = await res.json()
setAiInterpretation({
summary: '', applicability: '', technical_measures: [],
affected_modules: [], risk_level: 'unknown', implementation_hints: [],
confidence_score: 0, error: err.detail || 'Fehler bei AI-Analyse'
})
}
} catch (err) {
setAiInterpretation({
summary: '', applicability: '', technical_measures: [],
affected_modules: [], risk_level: 'unknown', implementation_hints: [],
confidence_score: 0, error: 'Netzwerkfehler bei AI-Analyse'
})
} finally {
setAiLoading(false)
}
}
const handleSave = () => {
onUpdate(localData)
setEditMode(false)
}
const addCodeReference = () => {
if (!newCodeRef.file) return
const refs = requirement.code_references || []
onUpdate({
code_references: [...refs, {
file: newCodeRef.file,
line: newCodeRef.line ? parseInt(newCodeRef.line) : undefined,
description: newCodeRef.description,
}],
})
setNewCodeRef({ file: '', line: '', description: '' })
}
return (
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-slate-200 bg-slate-50">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3">
<span className="font-mono text-lg font-semibold text-slate-900">
{requirement.article}{requirement.paragraph ? ` ${requirement.paragraph}` : ''}
</span>
<span className={`px-2 py-0.5 text-xs font-medium rounded ${
AUDIT_STATUS[requirement.audit_status as keyof typeof AUDIT_STATUS]?.color || 'bg-slate-200'
} text-white`}>
{AUDIT_STATUS[requirement.audit_status as keyof typeof AUDIT_STATUS]?.label || requirement.audit_status}
</span>
<span className={`px-2 py-0.5 text-xs font-medium rounded ${
IMPLEMENTATION_STATUS[requirement.implementation_status as keyof typeof IMPLEMENTATION_STATUS]?.color || 'bg-slate-200'
} text-white`}>
{IMPLEMENTATION_STATUS[requirement.implementation_status as keyof typeof IMPLEMENTATION_STATUS]?.label || requirement.implementation_status}
</span>
</div>
<h2 className="text-lg font-medium text-slate-900 mt-1">{requirement.title}</h2>
</div>
<div className="flex gap-2">
{editMode ? (
<>
<button onClick={() => setEditMode(false)} className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-800">Abbrechen</button>
<button onClick={handleSave} disabled={saving} className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50">
{saving ? 'Speichern...' : 'Speichern'}
</button>
</>
) : (
<button onClick={() => setEditMode(true)} className="px-3 py-1.5 text-sm bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200">Bearbeiten</button>
)}
</div>
</div>
</div>
<div className="p-4 space-y-6">
{/* Original Requirement Text */}
<section>
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2 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="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>
Originaler Anforderungstext
</h3>
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
<p className="text-sm text-slate-700 whitespace-pre-wrap">{requirement.requirement_text || 'Kein Originaltext hinterlegt'}</p>
{requirement.source_page && (
<p className="text-xs text-slate-500 mt-2">Quelle: {regulation?.code} Seite {requirement.source_page}{requirement.source_section ? `, ${requirement.source_section}` : ''}</p>
)}
</div>
</section>
{/* Applicability */}
<section>
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2">Anwendbarkeit auf Breakpilot</h3>
{editMode ? (
<div className="space-y-2">
<label className="flex items-center gap-2">
<input type="checkbox" checked={localData.is_applicable} onChange={(e) => setLocalData({ ...localData, is_applicable: e.target.checked })} className="rounded" />
<span className="text-sm text-slate-700">Anwendbar</span>
</label>
<textarea value={localData.applicability_reason} onChange={(e) => setLocalData({ ...localData, applicability_reason: e.target.value })} placeholder="Begruendung fuer Anwendbarkeit..." className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" rows={2} />
</div>
) : (
<div className="flex items-start gap-3">
<span className={`px-2 py-1 text-xs font-medium rounded ${requirement.is_applicable ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-600'}`}>
{requirement.is_applicable ? 'Anwendbar' : 'Nicht anwendbar'}
</span>
{requirement.applicability_reason && <p className="text-sm text-slate-600">{requirement.applicability_reason}</p>}
</div>
)}
</section>
{/* Interpretation & AI Analysis */}
<section>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide 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="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>
Interpretation
</h3>
<button onClick={generateAiInterpretation} disabled={aiLoading} className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-gradient-to-r from-purple-600 to-pink-600 rounded-lg hover:from-purple-700 hover:to-pink-700 disabled:opacity-50 transition-all">
{aiLoading ? (
<><svg className="animate-spin h-3 w-3" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg> AI analysiert...</>
) : (
<><svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg> AI Analyse</>
)}
</button>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-3">
<p className="text-sm text-blue-800">{requirement.breakpilot_interpretation || 'Keine Interpretation hinterlegt'}</p>
</div>
{/* AI Panel */}
{showAiPanel && (
<div className="bg-gradient-to-br from-purple-50 to-pink-50 border border-purple-200 rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-purple-800 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="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>
AI-generierte Analyse
</h4>
{aiInterpretation?.confidence_score ? <span className="text-xs text-purple-600">Konfidenz: {Math.round(aiInterpretation.confidence_score * 100)}%</span> : null}
</div>
{aiLoading && <div className="text-center py-4"><div className="animate-pulse text-purple-600">Claude analysiert die Anforderung...</div></div>}
{aiInterpretation?.error && <div className="bg-red-100 text-red-700 p-3 rounded text-sm">{aiInterpretation.error}</div>}
{aiInterpretation && !aiInterpretation.error && !aiLoading && (
<div className="space-y-3 text-sm">
{aiInterpretation.summary && <div><div className="font-medium text-purple-700 mb-1">Zusammenfassung</div><p className="text-slate-700">{aiInterpretation.summary}</p></div>}
{aiInterpretation.applicability && <div><div className="font-medium text-purple-700 mb-1">Anwendbarkeit auf Breakpilot</div><p className="text-slate-700">{aiInterpretation.applicability}</p></div>}
{aiInterpretation.risk_level && (
<div className="flex items-center gap-2">
<span className="font-medium text-purple-700">Risiko:</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${aiInterpretation.risk_level === 'critical' ? 'bg-red-100 text-red-700' : aiInterpretation.risk_level === 'high' ? 'bg-orange-100 text-orange-700' : aiInterpretation.risk_level === 'medium' ? 'bg-yellow-100 text-yellow-700' : 'bg-green-100 text-green-700'}`}>{aiInterpretation.risk_level}</span>
</div>
)}
{aiInterpretation.technical_measures?.length > 0 && <div><div className="font-medium text-purple-700 mb-1">Technische Massnahmen</div><ul className="list-disc list-inside text-slate-700 space-y-1">{aiInterpretation.technical_measures.map((m, i) => <li key={i}>{m}</li>)}</ul></div>}
{aiInterpretation.affected_modules?.length > 0 && <div><div className="font-medium text-purple-700 mb-1">Betroffene Module</div><div className="flex flex-wrap gap-1">{aiInterpretation.affected_modules.map((m, i) => <span key={i} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">{m}</span>)}</div></div>}
{aiInterpretation.implementation_hints?.length > 0 && <div><div className="font-medium text-purple-700 mb-1">Implementierungshinweise</div><ul className="list-disc list-inside text-slate-700 space-y-1">{aiInterpretation.implementation_hints.map((h, i) => <li key={i}>{h}</li>)}</ul></div>}
</div>
)}
</div>
)}
</section>
{/* Implementation Details */}
<section>
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2 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="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>
Umsetzung (fuer Auditor)
</h3>
{editMode ? (
<div className="space-y-3">
<div>
<label className="block text-xs text-slate-500 mb-1">Implementierungsstatus</label>
<select value={localData.implementation_status} onChange={(e) => setLocalData({ ...localData, implementation_status: e.target.value })} className="px-3 py-2 border border-slate-300 rounded-lg text-sm">
{Object.entries(IMPLEMENTATION_STATUS).map(([key, { label }]) => <option key={key} value={key}>{label}</option>)}
</select>
</div>
<textarea value={localData.implementation_details} onChange={(e) => setLocalData({ ...localData, implementation_details: e.target.value })} placeholder="Beschreiben Sie, wie diese Anforderung in Breakpilot umgesetzt wurde..." className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" rows={4} />
</div>
) : (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-sm text-green-800 whitespace-pre-wrap">{requirement.implementation_details || 'Noch keine Umsetzungsdetails dokumentiert'}</p>
</div>
)}
</section>
{/* Code References */}
<section>
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2">Code-Referenzen</h3>
<div className="space-y-2">
{(requirement.code_references || []).map((ref, idx) => (
<div key={idx} className="flex items-center gap-2 bg-slate-50 p-2 rounded-lg text-sm">
<code className="text-primary-600">{ref.file}{ref.line ? `:${ref.line}` : ''}</code>
<span className="text-slate-500">-</span>
<span className="text-slate-700">{ref.description}</span>
</div>
))}
{editMode && (
<div className="flex gap-2">
<input type="text" value={newCodeRef.file} onChange={(e) => setNewCodeRef({ ...newCodeRef, file: e.target.value })} placeholder="Datei (z.B. backend/auth.py)" className="flex-1 px-2 py-1.5 text-sm border border-slate-300 rounded" />
<input type="text" value={newCodeRef.line} onChange={(e) => setNewCodeRef({ ...newCodeRef, line: e.target.value })} placeholder="Zeile" className="w-20 px-2 py-1.5 text-sm border border-slate-300 rounded" />
<input type="text" value={newCodeRef.description} onChange={(e) => setNewCodeRef({ ...newCodeRef, description: e.target.value })} placeholder="Beschreibung" className="flex-1 px-2 py-1.5 text-sm border border-slate-300 rounded" />
<button onClick={addCodeReference} className="px-3 py-1.5 bg-primary-600 text-white rounded text-sm">+</button>
</div>
)}
</div>
</section>
{/* Evidence */}
<section>
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2 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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
Nachweis / Evidence
</h3>
{editMode ? (
<textarea value={localData.evidence_description} onChange={(e) => setLocalData({ ...localData, evidence_description: e.target.value })} placeholder="Welche Nachweise belegen die Erfuellung dieser Anforderung?" className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" rows={3} />
) : (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p className="text-sm text-amber-800">{requirement.evidence_description || 'Keine Nachweise beschrieben'}</p>
</div>
)}
</section>
{/* Auditor Section */}
<section className="border-t border-slate-200 pt-4">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2 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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
Auditor-Bereich
</h3>
{editMode ? (
<div className="space-y-3">
<div>
<label className="block text-xs text-slate-500 mb-1">Audit-Status</label>
<select value={localData.audit_status} onChange={(e) => setLocalData({ ...localData, audit_status: e.target.value })} className="px-3 py-2 border border-slate-300 rounded-lg text-sm">
{Object.entries(AUDIT_STATUS).map(([key, { label }]) => <option key={key} value={key}>{label}</option>)}
</select>
</div>
<textarea value={localData.auditor_notes} onChange={(e) => setLocalData({ ...localData, auditor_notes: e.target.value })} placeholder="Notizen des Auditors..." className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm" rows={3} />
</div>
) : (
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
<p className="text-sm text-slate-700">{requirement.auditor_notes || 'Keine Auditor-Notizen'}</p>
</div>
)}
</section>
</div>
</div>
)
}

View File

@@ -0,0 +1,163 @@
'use client'
import type { Requirement, Regulation, Category } from '../types'
import { AUDIT_STATUS, IMPLEMENTATION_STATUS, PRIORITY_LABELS } from '../types'
export default function RequirementList({
regulations,
selectedRegulation,
setSelectedRegulation,
filteredRequirements,
selectedRequirement,
setSelectedRequirement,
searchQuery,
setSearchQuery,
filterAuditStatus,
setFilterAuditStatus,
filterImplStatus,
setFilterImplStatus,
}: {
regulations: Regulation[]
selectedRegulation: string | null
setSelectedRegulation: (code: string) => void
filteredRequirements: Requirement[]
selectedRequirement: Requirement | null
setSelectedRequirement: (req: Requirement) => void
searchQuery: string
setSearchQuery: (q: string) => void
filterAuditStatus: string
setFilterAuditStatus: (s: string) => void
filterImplStatus: string
setFilterImplStatus: (s: string) => void
}) {
const currentRegulation = regulations.find(r => r.code === selectedRegulation)
return (
<div className="col-span-4 space-y-4">
{/* Regulation Selector */}
<div className="bg-white rounded-lg border border-slate-200 p-4">
<label className="block text-sm font-medium text-slate-700 mb-2">
Verordnung / Standard
</label>
<select
value={selectedRegulation || ''}
onChange={(e) => setSelectedRegulation(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
{regulations.map(reg => (
<option key={reg.code} value={reg.code}>
{reg.code} - {reg.name} ({reg.requirement_count})
</option>
))}
</select>
{currentRegulation?.source_url && (
<a
href={currentRegulation.source_url}
target="_blank"
rel="noopener noreferrer"
className="mt-2 text-sm text-primary-600 hover:text-primary-800 flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Originaldokument oeffnen
</a>
)}
{currentRegulation?.local_pdf_path && (
<a
href={`/docs/${currentRegulation.local_pdf_path}`}
target="_blank"
className="mt-1 text-sm text-slate-600 hover:text-slate-800 flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Lokale PDF
</a>
)}
</div>
{/* Filters */}
<div className="bg-white rounded-lg border border-slate-200 p-4 space-y-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Suche</label>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Artikel, Titel..."
className="w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Audit-Status</label>
<select
value={filterAuditStatus}
onChange={(e) => setFilterAuditStatus(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded-lg"
>
<option value="all">Alle</option>
{Object.entries(AUDIT_STATUS).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Impl.-Status</label>
<select
value={filterImplStatus}
onChange={(e) => setFilterImplStatus(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded-lg"
>
<option value="all">Alle</option>
{Object.entries(IMPLEMENTATION_STATUS).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
</div>
</div>
{/* Requirements List */}
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="p-3 border-b border-slate-200 bg-slate-50">
<span className="text-sm font-medium text-slate-700">
Anforderungen ({filteredRequirements.length})
</span>
</div>
<div className="max-h-[500px] overflow-y-auto">
{filteredRequirements.map(req => (
<button
key={req.id}
onClick={() => setSelectedRequirement(req)}
className={`w-full text-left p-3 border-b border-slate-100 hover:bg-slate-50 transition-colors ${
selectedRequirement?.id === req.id ? 'bg-primary-50 border-l-4 border-l-primary-500' : ''
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-slate-600">
{req.article}{req.paragraph ? ` ${req.paragraph}` : ''}
</span>
<span className={`text-xs ${PRIORITY_LABELS[req.priority]?.color || 'text-slate-500'}`}>
{PRIORITY_LABELS[req.priority]?.label || ''}
</span>
</div>
<p className="text-sm text-slate-900 truncate mt-0.5">{req.title}</p>
</div>
<div className="flex flex-col items-end gap-1">
<span className={`w-2 h-2 rounded-full ${AUDIT_STATUS[req.audit_status as keyof typeof AUDIT_STATUS]?.color || 'bg-slate-300'}`} />
<span className={`w-2 h-2 rounded-full ${IMPLEMENTATION_STATUS[req.implementation_status as keyof typeof IMPLEMENTATION_STATUS]?.color || 'bg-slate-300'}`} />
</div>
</div>
</button>
))}
</div>
</div>
</div>
)
}

View File

@@ -15,72 +15,9 @@
import { useState, useEffect } from 'react'
import Link from 'next/link'
import AdminLayout from '@/components/admin/AdminLayout'
// Types
interface Regulation {
id: string
code: string
name: string
full_name: string
regulation_type: string
source_url: string | null
local_pdf_path: string | null
requirement_count: number
}
interface Requirement {
id: string
regulation_id: string
regulation_code?: string
article: string
paragraph: string | null
title: string
description: string | null
requirement_text: string | null
breakpilot_interpretation: string | null
implementation_status: string
implementation_details: string | null
code_references: Array<{ file: string; line?: number; description: string }> | null
evidence_description: string | null
audit_status: string
auditor_notes: string | null
is_applicable: boolean
applicability_reason: string | null
priority: number
source_page: number | null
source_section: string | null
}
interface RequirementUpdate {
implementation_status?: string
implementation_details?: string
code_references?: Array<{ file: string; line?: number; description: string }>
evidence_description?: string
audit_status?: string
auditor_notes?: string
is_applicable?: boolean
applicability_reason?: string
}
const IMPLEMENTATION_STATUS = {
not_started: { label: 'Nicht gestartet', color: 'bg-slate-400' },
in_progress: { label: 'In Arbeit', color: 'bg-yellow-500' },
implemented: { label: 'Implementiert', color: 'bg-blue-500' },
verified: { label: 'Verifiziert', color: 'bg-green-500' },
}
const AUDIT_STATUS = {
pending: { label: 'Ausstehend', color: 'bg-slate-400' },
in_review: { label: 'In Pruefung', color: 'bg-yellow-500' },
approved: { label: 'Genehmigt', color: 'bg-green-500' },
rejected: { label: 'Abgelehnt', color: 'bg-red-500' },
}
const PRIORITY_LABELS: Record<number, { label: string; color: string }> = {
1: { label: 'Kritisch', color: 'text-red-600' },
2: { label: 'Hoch', color: 'text-orange-600' },
3: { label: 'Mittel', color: 'text-yellow-600' },
}
import type { Regulation, Requirement, RequirementUpdate } from './types'
import RequirementList from './_components/RequirementList'
import RequirementDetailPanel from './_components/RequirementDetailPanel'
export default function AuditWorkspacePage() {
const [regulations, setRegulations] = useState<Regulation[]>([])
@@ -95,14 +32,10 @@ export default function AuditWorkspacePage() {
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
useEffect(() => {
loadRegulations()
}, [])
useEffect(() => { loadRegulations() }, [])
useEffect(() => {
if (selectedRegulation) {
loadRequirements(selectedRegulation)
}
if (selectedRegulation) loadRequirements(selectedRegulation)
}, [selectedRegulation])
const loadRegulations = async () => {
@@ -111,10 +44,7 @@ export default function AuditWorkspacePage() {
if (res.ok) {
const data = await res.json()
setRegulations(data.regulations || [])
// Select first regulation by default
if (data.regulations?.length > 0) {
setSelectedRegulation(data.regulations[0].code)
}
if (data.regulations?.length > 0) setSelectedRegulation(data.regulations[0].code)
}
} catch (err) {
console.error('Failed to load regulations:', err)
@@ -143,9 +73,7 @@ export default function AuditWorkspacePage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
})
if (res.ok) {
const updated = await res.json()
setRequirements(prev => prev.map(r => r.id === reqId ? { ...r, ...updates } : r))
if (selectedRequirement?.id === reqId) {
setSelectedRequirement({ ...selectedRequirement, ...updates })
@@ -174,7 +102,6 @@ export default function AuditWorkspacePage() {
const currentRegulation = regulations.find(r => r.code === selectedRegulation)
// Statistics
const stats = {
total: requirements.length,
verified: requirements.filter(r => r.implementation_status === 'verified').length,
@@ -204,135 +131,22 @@ export default function AuditWorkspacePage() {
</div>
<div className="grid grid-cols-12 gap-6">
{/* Left Sidebar - Regulation & Requirement List */}
<div className="col-span-4 space-y-4">
{/* Regulation Selector */}
<div className="bg-white rounded-lg border border-slate-200 p-4">
<label className="block text-sm font-medium text-slate-700 mb-2">
Verordnung / Standard
</label>
<select
value={selectedRegulation || ''}
onChange={(e) => setSelectedRegulation(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
{regulations.map(reg => (
<option key={reg.code} value={reg.code}>
{reg.code} - {reg.name} ({reg.requirement_count})
</option>
))}
</select>
<RequirementList
regulations={regulations}
selectedRegulation={selectedRegulation}
setSelectedRegulation={setSelectedRegulation}
filteredRequirements={filteredRequirements}
selectedRequirement={selectedRequirement}
setSelectedRequirement={setSelectedRequirement}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
filterAuditStatus={filterAuditStatus}
setFilterAuditStatus={setFilterAuditStatus}
filterImplStatus={filterImplStatus}
setFilterImplStatus={setFilterImplStatus}
/>
{currentRegulation?.source_url && (
<a
href={currentRegulation.source_url}
target="_blank"
rel="noopener noreferrer"
className="mt-2 text-sm text-primary-600 hover:text-primary-800 flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
Originaldokument oeffnen
</a>
)}
{currentRegulation?.local_pdf_path && (
<a
href={`/docs/${currentRegulation.local_pdf_path}`}
target="_blank"
className="mt-1 text-sm text-slate-600 hover:text-slate-800 flex items-center gap-1"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
Lokale PDF
</a>
)}
</div>
{/* Filters */}
<div className="bg-white rounded-lg border border-slate-200 p-4 space-y-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Suche</label>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Artikel, Titel..."
className="w-full px-3 py-1.5 text-sm border border-slate-300 rounded-lg"
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Audit-Status</label>
<select
value={filterAuditStatus}
onChange={(e) => setFilterAuditStatus(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded-lg"
>
<option value="all">Alle</option>
{Object.entries(AUDIT_STATUS).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Impl.-Status</label>
<select
value={filterImplStatus}
onChange={(e) => setFilterImplStatus(e.target.value)}
className="w-full px-2 py-1.5 text-sm border border-slate-300 rounded-lg"
>
<option value="all">Alle</option>
{Object.entries(IMPLEMENTATION_STATUS).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
</div>
</div>
{/* Requirements List */}
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<div className="p-3 border-b border-slate-200 bg-slate-50">
<span className="text-sm font-medium text-slate-700">
Anforderungen ({filteredRequirements.length})
</span>
</div>
<div className="max-h-[500px] overflow-y-auto">
{filteredRequirements.map(req => (
<button
key={req.id}
onClick={() => setSelectedRequirement(req)}
className={`w-full text-left p-3 border-b border-slate-100 hover:bg-slate-50 transition-colors ${
selectedRequirement?.id === req.id ? 'bg-primary-50 border-l-4 border-l-primary-500' : ''
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-slate-600">
{req.article}{req.paragraph ? ` ${req.paragraph}` : ''}
</span>
<span className={`text-xs ${PRIORITY_LABELS[req.priority]?.color || 'text-slate-500'}`}>
{PRIORITY_LABELS[req.priority]?.label || ''}
</span>
</div>
<p className="text-sm text-slate-900 truncate mt-0.5">{req.title}</p>
</div>
<div className="flex flex-col items-end gap-1">
<span className={`w-2 h-2 rounded-full ${AUDIT_STATUS[req.audit_status as keyof typeof AUDIT_STATUS]?.color || 'bg-slate-300'}`} />
<span className={`w-2 h-2 rounded-full ${IMPLEMENTATION_STATUS[req.implementation_status as keyof typeof IMPLEMENTATION_STATUS]?.color || 'bg-slate-300'}`} />
</div>
</div>
</button>
))}
</div>
</div>
</div>
{/* Right Panel - Requirement Detail */}
{/* Right Panel */}
<div className="col-span-8">
{selectedRequirement ? (
<RequirementDetailPanel
@@ -354,518 +168,3 @@ export default function AuditWorkspacePage() {
</AdminLayout>
)
}
// AI Interpretation Types
interface AIInterpretation {
summary: string
applicability: string
technical_measures: string[]
affected_modules: string[]
risk_level: string
implementation_hints: string[]
confidence_score: number
error?: string
}
// Requirement Detail Panel Component
function RequirementDetailPanel({
requirement,
regulation,
onUpdate,
saving,
}: {
requirement: Requirement
regulation: Regulation | undefined
onUpdate: (updates: RequirementUpdate) => void
saving: boolean
}) {
const BACKEND_URL = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:8000'
const [editMode, setEditMode] = useState(false)
const [aiLoading, setAiLoading] = useState(false)
const [aiInterpretation, setAiInterpretation] = useState<AIInterpretation | null>(null)
const [showAiPanel, setShowAiPanel] = useState(false)
const [localData, setLocalData] = useState({
implementation_status: requirement.implementation_status,
implementation_details: requirement.implementation_details || '',
evidence_description: requirement.evidence_description || '',
audit_status: requirement.audit_status,
auditor_notes: requirement.auditor_notes || '',
is_applicable: requirement.is_applicable,
applicability_reason: requirement.applicability_reason || '',
})
const [newCodeRef, setNewCodeRef] = useState({ file: '', line: '', description: '' })
useEffect(() => {
setLocalData({
implementation_status: requirement.implementation_status,
implementation_details: requirement.implementation_details || '',
evidence_description: requirement.evidence_description || '',
audit_status: requirement.audit_status,
auditor_notes: requirement.auditor_notes || '',
is_applicable: requirement.is_applicable,
applicability_reason: requirement.applicability_reason || '',
})
setEditMode(false)
setAiInterpretation(null)
setShowAiPanel(false)
}, [requirement.id])
const generateAiInterpretation = async () => {
setAiLoading(true)
setShowAiPanel(true)
try {
const res = await fetch(`${BACKEND_URL}/api/v1/compliance/ai/interpret`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requirement_id: requirement.id }),
})
if (res.ok) {
const data = await res.json()
setAiInterpretation(data)
} else {
const err = await res.json()
setAiInterpretation({
summary: '', applicability: '', technical_measures: [],
affected_modules: [], risk_level: 'unknown', implementation_hints: [],
confidence_score: 0, error: err.detail || 'Fehler bei AI-Analyse'
})
}
} catch (err) {
setAiInterpretation({
summary: '', applicability: '', technical_measures: [],
affected_modules: [], risk_level: 'unknown', implementation_hints: [],
confidence_score: 0, error: 'Netzwerkfehler bei AI-Analyse'
})
} finally {
setAiLoading(false)
}
}
const handleSave = () => {
onUpdate(localData)
setEditMode(false)
}
const addCodeReference = () => {
if (!newCodeRef.file) return
const refs = requirement.code_references || []
onUpdate({
code_references: [...refs, {
file: newCodeRef.file,
line: newCodeRef.line ? parseInt(newCodeRef.line) : undefined,
description: newCodeRef.description,
}],
})
setNewCodeRef({ file: '', line: '', description: '' })
}
return (
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-slate-200 bg-slate-50">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-3">
<span className="font-mono text-lg font-semibold text-slate-900">
{requirement.article}{requirement.paragraph ? ` ${requirement.paragraph}` : ''}
</span>
<span className={`px-2 py-0.5 text-xs font-medium rounded ${
AUDIT_STATUS[requirement.audit_status as keyof typeof AUDIT_STATUS]?.color || 'bg-slate-200'
} text-white`}>
{AUDIT_STATUS[requirement.audit_status as keyof typeof AUDIT_STATUS]?.label || requirement.audit_status}
</span>
<span className={`px-2 py-0.5 text-xs font-medium rounded ${
IMPLEMENTATION_STATUS[requirement.implementation_status as keyof typeof IMPLEMENTATION_STATUS]?.color || 'bg-slate-200'
} text-white`}>
{IMPLEMENTATION_STATUS[requirement.implementation_status as keyof typeof IMPLEMENTATION_STATUS]?.label || requirement.implementation_status}
</span>
</div>
<h2 className="text-lg font-medium text-slate-900 mt-1">{requirement.title}</h2>
</div>
<div className="flex gap-2">
{editMode ? (
<>
<button
onClick={() => setEditMode(false)}
className="px-3 py-1.5 text-sm text-slate-600 hover:text-slate-800"
>
Abbrechen
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</>
) : (
<button
onClick={() => setEditMode(true)}
className="px-3 py-1.5 text-sm bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200"
>
Bearbeiten
</button>
)}
</div>
</div>
</div>
<div className="p-4 space-y-6">
{/* Original Requirement Text */}
<section>
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2 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="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>
Originaler Anforderungstext
</h3>
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
<p className="text-sm text-slate-700 whitespace-pre-wrap">
{requirement.requirement_text || 'Kein Originaltext hinterlegt'}
</p>
{requirement.source_page && (
<p className="text-xs text-slate-500 mt-2">
Quelle: {regulation?.code} Seite {requirement.source_page}
{requirement.source_section ? `, ${requirement.source_section}` : ''}
</p>
)}
</div>
</section>
{/* Applicability */}
<section>
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2">
Anwendbarkeit auf Breakpilot
</h3>
{editMode ? (
<div className="space-y-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={localData.is_applicable}
onChange={(e) => setLocalData({ ...localData, is_applicable: e.target.checked })}
className="rounded"
/>
<span className="text-sm text-slate-700">Anwendbar</span>
</label>
<textarea
value={localData.applicability_reason}
onChange={(e) => setLocalData({ ...localData, applicability_reason: e.target.value })}
placeholder="Begruendung fuer Anwendbarkeit..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={2}
/>
</div>
) : (
<div className="flex items-start gap-3">
<span className={`px-2 py-1 text-xs font-medium rounded ${
requirement.is_applicable ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-600'
}`}>
{requirement.is_applicable ? 'Anwendbar' : 'Nicht anwendbar'}
</span>
{requirement.applicability_reason && (
<p className="text-sm text-slate-600">{requirement.applicability_reason}</p>
)}
</div>
)}
</section>
{/* Breakpilot Interpretation & AI Analysis */}
<section>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide 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="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>
Interpretation
</h3>
<button
onClick={generateAiInterpretation}
disabled={aiLoading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-gradient-to-r from-purple-600 to-pink-600 rounded-lg hover:from-purple-700 hover:to-pink-700 disabled:opacity-50 transition-all"
>
{aiLoading ? (
<>
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
AI analysiert...
</>
) : (
<>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
AI Analyse
</>
)}
</button>
</div>
{/* Existing interpretation */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-3">
<p className="text-sm text-blue-800">
{requirement.breakpilot_interpretation || 'Keine Interpretation hinterlegt'}
</p>
</div>
{/* AI Interpretation Panel */}
{showAiPanel && (
<div className="bg-gradient-to-br from-purple-50 to-pink-50 border border-purple-200 rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-purple-800 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="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>
AI-generierte Analyse
</h4>
{aiInterpretation?.confidence_score && (
<span className="text-xs text-purple-600">
Konfidenz: {Math.round(aiInterpretation.confidence_score * 100)}%
</span>
)}
</div>
{aiLoading && (
<div className="text-center py-4">
<div className="animate-pulse text-purple-600">Claude analysiert die Anforderung...</div>
</div>
)}
{aiInterpretation?.error && (
<div className="bg-red-100 text-red-700 p-3 rounded text-sm">
{aiInterpretation.error}
</div>
)}
{aiInterpretation && !aiInterpretation.error && !aiLoading && (
<div className="space-y-3 text-sm">
{/* Summary */}
{aiInterpretation.summary && (
<div>
<div className="font-medium text-purple-700 mb-1">Zusammenfassung</div>
<p className="text-slate-700">{aiInterpretation.summary}</p>
</div>
)}
{/* Applicability */}
{aiInterpretation.applicability && (
<div>
<div className="font-medium text-purple-700 mb-1">Anwendbarkeit auf Breakpilot</div>
<p className="text-slate-700">{aiInterpretation.applicability}</p>
</div>
)}
{/* Risk Level */}
{aiInterpretation.risk_level && (
<div className="flex items-center gap-2">
<span className="font-medium text-purple-700">Risiko:</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
aiInterpretation.risk_level === 'critical' ? 'bg-red-100 text-red-700' :
aiInterpretation.risk_level === 'high' ? 'bg-orange-100 text-orange-700' :
aiInterpretation.risk_level === 'medium' ? 'bg-yellow-100 text-yellow-700' :
'bg-green-100 text-green-700'
}`}>
{aiInterpretation.risk_level}
</span>
</div>
)}
{/* Technical Measures */}
{aiInterpretation.technical_measures?.length > 0 && (
<div>
<div className="font-medium text-purple-700 mb-1">Technische Massnahmen</div>
<ul className="list-disc list-inside text-slate-700 space-y-1">
{aiInterpretation.technical_measures.map((m, i) => (
<li key={i}>{m}</li>
))}
</ul>
</div>
)}
{/* Affected Modules */}
{aiInterpretation.affected_modules?.length > 0 && (
<div>
<div className="font-medium text-purple-700 mb-1">Betroffene Module</div>
<div className="flex flex-wrap gap-1">
{aiInterpretation.affected_modules.map((m, i) => (
<span key={i} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
{m}
</span>
))}
</div>
</div>
)}
{/* Implementation Hints */}
{aiInterpretation.implementation_hints?.length > 0 && (
<div>
<div className="font-medium text-purple-700 mb-1">Implementierungshinweise</div>
<ul className="list-disc list-inside text-slate-700 space-y-1">
{aiInterpretation.implementation_hints.map((h, i) => (
<li key={i}>{h}</li>
))}
</ul>
</div>
)}
</div>
)}
</div>
)}
</section>
{/* Implementation Details */}
<section>
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2 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="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
Umsetzung (fuer Auditor)
</h3>
{editMode ? (
<div className="space-y-3">
<div>
<label className="block text-xs text-slate-500 mb-1">Implementierungsstatus</label>
<select
value={localData.implementation_status}
onChange={(e) => setLocalData({ ...localData, implementation_status: e.target.value })}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
{Object.entries(IMPLEMENTATION_STATUS).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
<textarea
value={localData.implementation_details}
onChange={(e) => setLocalData({ ...localData, implementation_details: e.target.value })}
placeholder="Beschreiben Sie, wie diese Anforderung in Breakpilot umgesetzt wurde..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={4}
/>
</div>
) : (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-sm text-green-800 whitespace-pre-wrap">
{requirement.implementation_details || 'Noch keine Umsetzungsdetails dokumentiert'}
</p>
</div>
)}
</section>
{/* Code References */}
<section>
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2">
Code-Referenzen
</h3>
<div className="space-y-2">
{(requirement.code_references || []).map((ref, idx) => (
<div key={idx} className="flex items-center gap-2 bg-slate-50 p-2 rounded-lg text-sm">
<code className="text-primary-600">{ref.file}{ref.line ? `:${ref.line}` : ''}</code>
<span className="text-slate-500">-</span>
<span className="text-slate-700">{ref.description}</span>
</div>
))}
{editMode && (
<div className="flex gap-2">
<input
type="text"
value={newCodeRef.file}
onChange={(e) => setNewCodeRef({ ...newCodeRef, file: e.target.value })}
placeholder="Datei (z.B. backend/auth.py)"
className="flex-1 px-2 py-1.5 text-sm border border-slate-300 rounded"
/>
<input
type="text"
value={newCodeRef.line}
onChange={(e) => setNewCodeRef({ ...newCodeRef, line: e.target.value })}
placeholder="Zeile"
className="w-20 px-2 py-1.5 text-sm border border-slate-300 rounded"
/>
<input
type="text"
value={newCodeRef.description}
onChange={(e) => setNewCodeRef({ ...newCodeRef, description: e.target.value })}
placeholder="Beschreibung"
className="flex-1 px-2 py-1.5 text-sm border border-slate-300 rounded"
/>
<button
onClick={addCodeReference}
className="px-3 py-1.5 bg-primary-600 text-white rounded text-sm"
>
+
</button>
</div>
)}
</div>
</section>
{/* Evidence */}
<section>
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2 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="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
Nachweis / Evidence
</h3>
{editMode ? (
<textarea
value={localData.evidence_description}
onChange={(e) => setLocalData({ ...localData, evidence_description: e.target.value })}
placeholder="Welche Nachweise belegen die Erfuellung dieser Anforderung?"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={3}
/>
) : (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p className="text-sm text-amber-800">
{requirement.evidence_description || 'Keine Nachweise beschrieben'}
</p>
</div>
)}
</section>
{/* Auditor Section */}
<section className="border-t border-slate-200 pt-4">
<h3 className="text-sm font-semibold text-slate-700 uppercase tracking-wide mb-2 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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Auditor-Bereich
</h3>
{editMode ? (
<div className="space-y-3">
<div>
<label className="block text-xs text-slate-500 mb-1">Audit-Status</label>
<select
value={localData.audit_status}
onChange={(e) => setLocalData({ ...localData, audit_status: e.target.value })}
className="px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
{Object.entries(AUDIT_STATUS).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
<textarea
value={localData.auditor_notes}
onChange={(e) => setLocalData({ ...localData, auditor_notes: e.target.value })}
placeholder="Notizen des Auditors..."
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
rows={3}
/>
</div>
) : (
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
<p className="text-sm text-slate-700">
{requirement.auditor_notes || 'Keine Auditor-Notizen'}
</p>
</div>
)}
</section>
</div>
</div>
)
}

View File

@@ -0,0 +1,75 @@
export interface Regulation {
id: string
code: string
name: string
full_name: string
regulation_type: string
source_url: string | null
local_pdf_path: string | null
requirement_count: number
}
export interface Requirement {
id: string
regulation_id: string
regulation_code?: string
article: string
paragraph: string | null
title: string
description: string | null
requirement_text: string | null
breakpilot_interpretation: string | null
implementation_status: string
implementation_details: string | null
code_references: Array<{ file: string; line?: number; description: string }> | null
evidence_description: string | null
audit_status: string
auditor_notes: string | null
is_applicable: boolean
applicability_reason: string | null
priority: number
source_page: number | null
source_section: string | null
}
export interface RequirementUpdate {
implementation_status?: string
implementation_details?: string
code_references?: Array<{ file: string; line?: number; description: string }>
evidence_description?: string
audit_status?: string
auditor_notes?: string
is_applicable?: boolean
applicability_reason?: string
}
export interface AIInterpretation {
summary: string
applicability: string
technical_measures: string[]
affected_modules: string[]
risk_level: string
implementation_hints: string[]
confidence_score: number
error?: string
}
export const IMPLEMENTATION_STATUS = {
not_started: { label: 'Nicht gestartet', color: 'bg-slate-400' },
in_progress: { label: 'In Arbeit', color: 'bg-yellow-500' },
implemented: { label: 'Implementiert', color: 'bg-blue-500' },
verified: { label: 'Verifiziert', color: 'bg-green-500' },
}
export const AUDIT_STATUS = {
pending: { label: 'Ausstehend', color: 'bg-slate-400' },
in_review: { label: 'In Pruefung', color: 'bg-yellow-500' },
approved: { label: 'Genehmigt', color: 'bg-green-500' },
rejected: { label: 'Abgelehnt', color: 'bg-red-500' },
}
export const PRIORITY_LABELS: Record<number, { label: string; color: string }> = {
1: { label: 'Kritisch', color: 'text-red-600' },
2: { label: 'Hoch', color: 'text-orange-600' },
3: { label: 'Mittel', color: 'text-yellow-600' },
}

View File

@@ -0,0 +1,99 @@
'use client'
import type { CrawlStats } from '../types'
export default function CrawlTab({
stats,
loading,
onStartCrawl,
}: {
stats: CrawlStats
loading: boolean
onStartCrawl: () => void
}) {
return (
<div className="space-y-6">
{/* Crawl Status */}
<div className="bg-slate-50 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-slate-900">Crawl-Status</h3>
<p className="text-sm text-slate-500">
Letzter Crawl: {stats.lastCrawlTime ? new Date(stats.lastCrawlTime).toLocaleString('de-DE') : 'Noch nie'}
</p>
</div>
<div className={`px-3 py-1.5 rounded-full text-sm font-medium ${
stats.crawlStatus === 'running' ? 'bg-blue-100 text-blue-700' :
stats.crawlStatus === 'error' ? 'bg-red-100 text-red-700' :
'bg-green-100 text-green-700'
}`}>
{stats.crawlStatus === 'running' ? '🔄 Läuft...' :
stats.crawlStatus === 'error' ? '❌ Fehler' :
'✅ Bereit'}
</div>
</div>
<button
onClick={onStartCrawl}
disabled={loading || stats.crawlStatus === 'running'}
className="px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{loading ? (
<>
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Crawl läuft...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Crawl starten
</>
)}
</button>
</div>
{/* Crawl Settings */}
<div className="grid md:grid-cols-2 gap-6">
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h4 className="font-semibold text-slate-900 mb-4">Crawl-Einstellungen</h4>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Max. Seiten pro Crawl</label>
<input type="number" defaultValue={500} className="w-full px-3 py-2 border border-slate-300 rounded-lg" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rate-Limit (Requests/Sek)</label>
<input type="number" defaultValue={0.2} step={0.1} className="w-full px-3 py-2 border border-slate-300 rounded-lg" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Max. Crawl-Tiefe</label>
<input type="number" defaultValue={4} className="w-full px-3 py-2 border border-slate-300 rounded-lg" />
</div>
</div>
</div>
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h4 className="font-semibold text-slate-900 mb-4">Scheduler</h4>
<div className="space-y-4">
<div className="flex items-center gap-2">
<input type="checkbox" id="autoSchedule" className="rounded border-slate-300" />
<label htmlFor="autoSchedule" className="text-sm text-slate-700">Automatischer Crawl aktiviert</label>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Intervall</label>
<select className="w-full px-3 py-2 border border-slate-300 rounded-lg">
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
<option value="monthly">Monatlich</option>
</select>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,37 @@
'use client'
export default function RulesTab() {
return (
<div className="space-y-6">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<span className="text-2xl"></span>
<div>
<h4 className="font-semibold text-amber-800">Tagging-Regeln Editor</h4>
<p className="text-sm text-amber-700">
Die Tagging-Regeln werden aktuell über YAML-Dateien verwaltet.
Ein visueller Editor ist in Entwicklung.
</p>
</div>
</div>
</div>
<div className="grid md:grid-cols-2 gap-6">
{[
{ name: 'Doc-Type Regeln', file: 'doc_type_rules.yaml', desc: 'Klassifiziert Dokumente (Lehrplan, Arbeitsblatt, etc.)' },
{ name: 'Fach-Regeln', file: 'subject_rules.yaml', desc: 'Erkennt Unterrichtsfächer' },
{ name: 'Schulstufen-Regeln', file: 'level_rules.yaml', desc: 'Erkennt Primar, SekI, SekII, etc.' },
{ name: 'Trust-Score Regeln', file: 'trust_rules.yaml', desc: 'Domain-basierte Vertrauensbewertung' },
].map(rule => (
<div key={rule.file} className="bg-white border border-slate-200 rounded-lg p-6">
<h4 className="font-semibold text-slate-900 mb-2">{rule.name}</h4>
<p className="text-sm text-slate-500 mb-4">{rule.desc}</p>
<code className="text-xs bg-slate-100 px-2 py-1 rounded text-slate-600">
/rules/{rule.file}
</code>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,190 @@
'use client'
import { useState } from 'react'
import type { SeedURL, Category } from '../types'
export default function SeedModal({
seed,
categories,
onClose,
onSaved,
}: {
seed?: SeedURL | null
categories: Category[]
onClose: () => void
onSaved: () => void
}) {
const [formData, setFormData] = useState<Partial<SeedURL>>(seed || {
url: '',
category: 'federal',
name: '',
description: '',
trustBoost: 0.5,
enabled: true,
})
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setSaveError(null)
try {
const category = categories.find(c => c.name === formData.category || c.id === formData.category)
const payload = {
url: formData.url,
name: formData.name,
description: formData.description || '',
category_id: category?.id || null,
trust_boost: formData.trustBoost,
enabled: formData.enabled,
source_type: 'GOV',
scope: 'FEDERAL',
}
if (seed) {
const res = await fetch(`/api/admin/edu-search?id=${seed.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const errData = await res.json()
throw new Error(errData.detail || errData.error || `HTTP ${res.status}`)
}
} else {
const res = await fetch(`/api/admin/edu-search?action=seed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const errData = await res.json()
throw new Error(errData.detail || errData.error || `HTTP ${res.status}`)
}
}
onSaved()
onClose()
} catch (err) {
console.error('Failed to save seed:', err)
setSaveError(err instanceof Error ? err.message : 'Fehler beim Speichern')
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg mx-4">
<div className="px-6 py-4 border-b border-slate-200">
<h3 className="text-lg font-semibold">{seed ? 'Seed bearbeiten' : 'Neue Seed-URL hinzufügen'}</h3>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{saveError && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded-lg text-sm">
{saveError}
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">URL *</label>
<input
type="url"
required
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="https://www.example.de"
value={formData.url}
onChange={e => setFormData({ ...formData, url: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
required
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="Name der Quelle"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Kategorie *</label>
<select
required
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
value={formData.category}
onChange={e => setFormData({ ...formData, category: e.target.value })}
>
{categories.map(cat => (
<option key={cat.id} value={cat.name}>{cat.icon} {cat.display_name || cat.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
rows={2}
placeholder="Kurze Beschreibung der Quelle"
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Trust-Boost: {formData.trustBoost?.toFixed(2)}
</label>
<input
type="range"
min="0"
max="1"
step="0.05"
className="w-full"
value={formData.trustBoost}
onChange={e => setFormData({ ...formData, trustBoost: parseFloat(e.target.value) })}
/>
<p className="text-xs text-slate-500 mt-1">
Höhere Werte für vertrauenswürdigere Quellen (1.0 = max für offizielle Regierungsquellen)
</p>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="enabled"
className="rounded border-slate-300 text-primary-600 focus:ring-primary-500"
checked={formData.enabled}
onChange={e => setFormData({ ...formData, enabled: e.target.checked })}
/>
<label htmlFor="enabled" className="text-sm text-slate-700">Aktiv (wird beim nächsten Crawl berücksichtigt)</label>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
disabled={saving}
className="px-4 py-2 text-slate-700 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-50"
>
Abbrechen
</button>
<button
type="submit"
disabled={saving}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{saving && (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{seed ? 'Speichern' : 'Hinzufügen'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,200 @@
'use client'
import { useState } from 'react'
import type { SeedURL, Category } from '../types'
import SeedModal from './SeedModal'
export default function SeedsTab({
seeds,
allSeeds,
categories,
searchQuery,
setSearchQuery,
selectedCategory,
setSelectedCategory,
onToggleEnabled,
onDelete,
onSaved,
}: {
seeds: SeedURL[]
allSeeds: SeedURL[]
categories: Category[]
searchQuery: string
setSearchQuery: (q: string) => void
selectedCategory: string
setSelectedCategory: (cat: string) => void
onToggleEnabled: (id: string) => void
onDelete: (id: string) => void
onSaved: () => void
}) {
const [showAddModal, setShowAddModal] = useState(false)
const [editingSeed, setEditingSeed] = useState<SeedURL | null>(null)
const filteredSeeds = seeds.filter(seed => {
const matchesCategory = selectedCategory === 'all' || seed.category === selectedCategory
const matchesSearch = seed.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
seed.url.toLowerCase().includes(searchQuery.toLowerCase())
return matchesCategory && matchesSearch
})
return (
<div>
{/* Header with filters */}
<div className="flex flex-wrap items-center gap-4 mb-6">
<div className="flex-1 min-w-[200px]">
<input
type="text"
placeholder="Suche nach Name oder URL..."
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</div>
<select
className="px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
value={selectedCategory}
onChange={e => setSelectedCategory(e.target.value)}
>
<option value="all">Alle Kategorien</option>
{categories.map(cat => (
<option key={cat.id} value={cat.name}>{cat.icon} {cat.display_name || cat.name}</option>
))}
</select>
<button
onClick={() => setShowAddModal(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors flex items-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="M12 4v16m8-8H4" />
</svg>
Neue Seed-URL
</button>
</div>
{/* Category Quick Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4 mb-6">
{categories.map(cat => {
const count = allSeeds.filter(s => s.category === cat.name).length
return (
<button
key={cat.id}
onClick={() => setSelectedCategory(selectedCategory === cat.name ? 'all' : cat.name)}
className={`p-4 rounded-lg border transition-colors text-left ${
selectedCategory === cat.name
? 'border-primary-500 bg-primary-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="text-2xl mb-1">{cat.icon}</div>
<div className="font-medium text-slate-900 text-sm">{cat.display_name || cat.name}</div>
<div className="text-sm text-slate-500">{count} Seeds</div>
</button>
)
})}
</div>
{/* Seeds Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 font-medium text-slate-700">Status</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">Name</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">URL</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">Kategorie</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">Trust</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">Dokumente</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">Aktionen</th>
</tr>
</thead>
<tbody>
{filteredSeeds.map(seed => (
<tr key={seed.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4">
<button
onClick={() => onToggleEnabled(seed.id)}
className={`w-10 h-6 rounded-full transition-colors ${
seed.enabled ? 'bg-green-500' : 'bg-slate-300'
}`}
>
<span className={`block w-4 h-4 bg-white rounded-full transform transition-transform ${
seed.enabled ? 'translate-x-5' : 'translate-x-1'
}`} />
</button>
</td>
<td className="py-3 px-4">
<div className="font-medium text-slate-900">{seed.name}</div>
<div className="text-sm text-slate-500">{seed.description}</div>
</td>
<td className="py-3 px-4">
<a href={seed.url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline text-sm">
{seed.url.replace(/^https?:\/\/(www\.)?/, '').slice(0, 30)}...
</a>
</td>
<td className="py-3 px-4">
<span className="inline-flex items-center gap-1 px-2 py-1 bg-slate-100 rounded text-sm">
{categories.find(c => c.name === seed.category)?.icon || '📁'}
{categories.find(c => c.name === seed.category)?.display_name || seed.category}
</span>
</td>
<td className="py-3 px-4">
<span className={`inline-flex px-2 py-1 rounded text-sm font-medium ${
seed.trustBoost >= 0.4 ? 'bg-green-100 text-green-700' :
seed.trustBoost >= 0.2 ? 'bg-yellow-100 text-yellow-700' :
'bg-slate-100 text-slate-700'
}`}>
+{seed.trustBoost.toFixed(2)}
</span>
</td>
<td className="py-3 px-4 text-slate-600">
{seed.documentCount?.toLocaleString() || '-'}
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<button
onClick={() => setEditingSeed(seed)}
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded"
title="Bearbeiten"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => onDelete(seed.id)}
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded"
title="Löschen"
>
<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>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredSeeds.length === 0 && (
<div className="text-center py-12 text-slate-500">
Keine Seed-URLs gefunden
</div>
)}
{/* Modals */}
{(showAddModal || editingSeed) && (
<SeedModal
seed={editingSeed}
categories={categories}
onClose={() => {
setShowAddModal(false)
setEditingSeed(null)
}}
onSaved={onSaved}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,88 @@
'use client'
import type { CrawlStats, Category } from '../types'
export default function StatsTab({
stats,
categories,
}: {
stats: CrawlStats
categories: Category[]
}) {
return (
<div className="space-y-6">
{/* Overview Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl p-5 text-white">
<div className="text-3xl font-bold">{stats.totalDocuments.toLocaleString()}</div>
<div className="text-blue-100">Dokumente indexiert</div>
</div>
<div className="bg-gradient-to-br from-green-500 to-green-600 rounded-xl p-5 text-white">
<div className="text-3xl font-bold">{stats.totalSeeds}</div>
<div className="text-green-100">Seed-URLs aktiv</div>
</div>
<div className="bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl p-5 text-white">
<div className="text-3xl font-bold">{(stats.avgTrustScore * 100).toFixed(0)}%</div>
<div className="text-purple-100">Ø Trust-Score</div>
</div>
<div className="bg-gradient-to-br from-orange-500 to-orange-600 rounded-xl p-5 text-white">
<div className="text-3xl font-bold">{Object.keys(stats.documentsPerDocType).length}</div>
<div className="text-orange-100">Dokumenttypen</div>
</div>
</div>
{/* Charts */}
<div className="grid md:grid-cols-2 gap-6">
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h4 className="font-semibold text-slate-900 mb-4">Dokumente nach Kategorie</h4>
<div className="space-y-3">
{Object.entries(stats.documentsPerCategory).map(([cat, count]) => {
const category = categories.find(c => c.name === cat)
const percentage = stats.totalDocuments > 0 ? (count / stats.totalDocuments) * 100 : 0
return (
<div key={cat}>
<div className="flex justify-between text-sm mb-1">
<span>{category?.icon || '📁'} {category?.display_name || cat}</span>
<span className="text-slate-500">{count.toLocaleString()} ({percentage.toFixed(1)}%)</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 rounded-full"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
)
})}
</div>
</div>
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h4 className="font-semibold text-slate-900 mb-4">Dokumente nach Typ</h4>
<div className="space-y-3">
{Object.entries(stats.documentsPerDocType)
.sort(([,a], [,b]) => b - a)
.slice(0, 6)
.map(([docType, count]) => {
const percentage = (count / stats.totalDocuments) * 100
return (
<div key={docType}>
<div className="flex justify-between text-sm mb-1">
<span>{docType.replace(/_/g, ' ')}</span>
<span className="text-slate-500">{count.toLocaleString()} ({percentage.toFixed(1)}%)</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
)
}

View File

@@ -7,531 +7,34 @@
* edu-search-service (Tavily alternative for German education content)
*/
import { useState, useEffect, useCallback } from 'react'
import { useState } from 'react'
import AdminLayout from '@/components/admin/AdminLayout'
import { useEduSearchData } from './useEduSearchData'
import SeedsTab from './_components/SeedsTab'
import CrawlTab from './_components/CrawlTab'
import StatsTab from './_components/StatsTab'
import RulesTab from './_components/RulesTab'
// All API calls go through Next.js API proxy at /api/admin/edu-search
// This avoids CORS issues since browser calls same-origin API routes
// Types
interface SeedURL {
id: string
url: string
category: string
category_id?: string
name: string
description: string
trustBoost: number
enabled: boolean
lastCrawled?: string
documentCount?: number
source_type?: string
scope?: string
state?: string
crawl_depth?: number
crawl_frequency?: string
}
interface CrawlStats {
totalDocuments: number
totalSeeds: number
lastCrawlTime?: string
crawlStatus: 'idle' | 'running' | 'error'
documentsPerCategory: Record<string, number>
documentsPerDocType: Record<string, number>
avgTrustScore: number
}
interface Category {
id: string
name: string
display_name?: string
description: string
icon: string
sort_order?: number
is_active?: boolean
}
interface ApiSeed {
id: string
url: string
name: string
description: string | null
category: string | null // Backend returns 'category' not 'category_name'
category_display_name: string | null
source_type: string
scope: string
state: string | null
trust_boost: number
enabled: boolean
crawl_depth: number
crawl_frequency: string
last_crawled_at: string | null
last_crawl_status: string | null
last_crawl_docs: number
total_documents: number
created_at: string
updated_at: string
}
// Default categories (fallback if API fails)
const DEFAULT_CATEGORIES: Category[] = [
{ id: 'federal', name: 'federal', display_name: 'Bundesebene', description: 'KMK, BMBF, Bildungsserver', icon: '🏛️' },
{ id: 'states', name: 'states', display_name: 'Bundesländer', description: 'Ministerien, Landesbildungsserver', icon: '🗺️' },
{ id: 'science', name: 'science', display_name: 'Wissenschaft', description: 'Bertelsmann, PISA, IGLU, TIMSS', icon: '🔬' },
{ id: 'universities', name: 'universities', display_name: 'Hochschulen', description: 'Universitäten, Fachhochschulen, Pädagogische Hochschulen', icon: '🎓' },
{ id: 'legal', name: 'legal', display_name: 'Recht & Schulgesetze', description: 'Schulgesetze, Erlasse, Verordnungen, Datenschutzrecht', icon: '⚖️' },
{ id: 'portals', name: 'portals', display_name: 'Bildungsportale', description: 'Lehrer-Online, 4teachers, ZUM', icon: '📚' },
{ id: 'authorities', name: 'authorities', display_name: 'Schulbehörden', description: 'Regierungspräsidien, Schulämter', icon: '📋' },
const tabDefs = [
{ id: 'seeds' as const, name: 'Seed-URLs', icon: '🌱' },
{ id: 'crawl' as const, name: 'Crawl-Steuerung', icon: '🕷️' },
{ id: 'stats' as const, name: 'Statistiken', icon: '📊' },
{ id: 'rules' as const, name: 'Tagging-Regeln', icon: '🏷️' },
]
// Convert API seed to frontend format
function apiSeedToFrontend(seed: ApiSeed): SeedURL {
return {
id: seed.id,
url: seed.url,
name: seed.name,
description: seed.description || '',
category: seed.category || 'federal', // Backend uses 'category' field
category_id: undefined,
trustBoost: seed.trust_boost,
enabled: seed.enabled,
lastCrawled: seed.last_crawled_at || undefined,
documentCount: seed.total_documents,
source_type: seed.source_type,
scope: seed.scope,
state: seed.state || undefined,
crawl_depth: seed.crawl_depth,
crawl_frequency: seed.crawl_frequency,
}
}
// Default empty stats (loaded from API)
const DEFAULT_STATS: CrawlStats = {
totalDocuments: 0,
totalSeeds: 0,
lastCrawlTime: undefined,
crawlStatus: 'idle',
documentsPerCategory: {},
documentsPerDocType: {},
avgTrustScore: 0,
}
export default function EduSearchAdminPage() {
const [activeTab, setActiveTab] = useState<'seeds' | 'crawl' | 'stats' | 'rules'>('seeds')
const [seeds, setSeeds] = useState<SeedURL[]>([])
const [allSeeds, setAllSeeds] = useState<SeedURL[]>([]) // All seeds for category counts
const [categories, setCategories] = useState<Category[]>(DEFAULT_CATEGORIES)
const [stats, setStats] = useState<CrawlStats>(DEFAULT_STATS)
const [selectedCategory, setSelectedCategory] = useState<string>('all')
const [searchQuery, setSearchQuery] = useState('')
const [showAddModal, setShowAddModal] = useState(false)
const [editingSeed, setEditingSeed] = useState<SeedURL | null>(null)
const [loading, setLoading] = useState(false)
const [initialLoading, setInitialLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Fetch categories from API (via proxy)
const fetchCategories = useCallback(async () => {
try {
const res = await fetch(`/api/admin/edu-search?action=categories`)
if (res.ok) {
const data = await res.json()
if (data.categories && data.categories.length > 0) {
setCategories(data.categories.map((cat: { id: string; name: string; display_name: string; description: string; icon: string; sort_order: number; is_active: boolean }) => ({
id: cat.id,
name: cat.name,
display_name: cat.display_name,
description: cat.description || '',
icon: cat.icon || '📁',
sort_order: cat.sort_order,
is_active: cat.is_active,
})))
}
}
} catch (err) {
console.error('Failed to fetch categories:', err)
}
}, [])
// Fetch all seeds from API (for category counts)
const fetchAllSeeds = useCallback(async () => {
try {
const res = await fetch(`/api/admin/edu-search?action=seeds`)
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const data = await res.json()
setAllSeeds((data.seeds || []).map(apiSeedToFrontend))
} catch (err) {
console.error('Failed to fetch all seeds:', err)
}
}, [])
// Fetch seeds from API (via proxy) - filtered by category
const fetchSeeds = useCallback(async () => {
try {
const params = new URLSearchParams()
params.append('action', 'seeds')
if (selectedCategory !== 'all') {
params.append('category', selectedCategory)
}
const res = await fetch(`/api/admin/edu-search?${params}`)
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const data = await res.json()
const fetchedSeeds = (data.seeds || []).map(apiSeedToFrontend)
setSeeds(fetchedSeeds)
// If fetching all, also update allSeeds for counts
if (selectedCategory === 'all') {
setAllSeeds(fetchedSeeds)
}
setError(null)
} catch (err) {
console.error('Failed to fetch seeds:', err)
setError('Seeds konnten nicht geladen werden. API nicht erreichbar.')
}
}, [selectedCategory])
// Fetch stats from API (via proxy)
const fetchStats = useCallback(async (preserveCrawlStatus = false) => {
try {
const res = await fetch(`/api/admin/edu-search?action=stats`)
if (res.ok) {
const data = await res.json()
setStats(prev => ({
totalDocuments: data.total_documents || 0,
totalSeeds: data.total_seeds || 0,
lastCrawlTime: data.last_crawl_time || prev.lastCrawlTime,
crawlStatus: preserveCrawlStatus ? prev.crawlStatus : (data.crawl_status || 'idle'),
documentsPerCategory: data.seeds_per_category || {},
documentsPerDocType: {},
avgTrustScore: data.avg_trust_boost || 0,
}))
}
} catch (err) {
console.error('Failed to fetch stats:', err)
}
}, [])
// Initial load
useEffect(() => {
const loadData = async () => {
setInitialLoading(true)
await Promise.all([fetchCategories(), fetchSeeds(), fetchAllSeeds(), fetchStats()])
setInitialLoading(false)
}
loadData()
}, [fetchCategories, fetchSeeds, fetchAllSeeds, fetchStats])
// Reload seeds when category filter changes
useEffect(() => {
if (!initialLoading) {
fetchSeeds()
}
}, [selectedCategory, initialLoading, fetchSeeds])
// Filter seeds
const filteredSeeds = seeds.filter(seed => {
const matchesCategory = selectedCategory === 'all' || seed.category === selectedCategory
const matchesSearch = seed.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
seed.url.toLowerCase().includes(searchQuery.toLowerCase())
return matchesCategory && matchesSearch
})
// Add/Edit Seed Modal
const SeedModal = ({ seed, onClose }: { seed?: SeedURL | null, onClose: () => void }) => {
const [formData, setFormData] = useState<Partial<SeedURL>>(seed || {
url: '',
category: 'federal',
name: '',
description: '',
trustBoost: 0.5,
enabled: true,
})
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setSaveError(null)
try {
// Find category ID by name
const category = categories.find(c => c.name === formData.category || c.id === formData.category)
const payload = {
url: formData.url,
name: formData.name,
description: formData.description || '',
category_id: category?.id || null,
trust_boost: formData.trustBoost,
enabled: formData.enabled,
source_type: 'GOV',
scope: 'FEDERAL',
}
if (seed) {
// Update existing seed (via proxy)
const res = await fetch(`/api/admin/edu-search?id=${seed.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const errData = await res.json()
throw new Error(errData.detail || errData.error || `HTTP ${res.status}`)
}
} else {
// Create new seed (via proxy)
const res = await fetch(`/api/admin/edu-search?action=seed`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const errData = await res.json()
throw new Error(errData.detail || errData.error || `HTTP ${res.status}`)
}
}
// Reload seeds and close modal
await fetchSeeds()
await fetchStats()
onClose()
} catch (err) {
console.error('Failed to save seed:', err)
setSaveError(err instanceof Error ? err.message : 'Fehler beim Speichern')
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg mx-4">
<div className="px-6 py-4 border-b border-slate-200">
<h3 className="text-lg font-semibold">{seed ? 'Seed bearbeiten' : 'Neue Seed-URL hinzufügen'}</h3>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{saveError && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded-lg text-sm">
{saveError}
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">URL *</label>
<input
type="url"
required
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="https://www.example.de"
value={formData.url}
onChange={e => setFormData({ ...formData, url: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Name *</label>
<input
type="text"
required
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="Name der Quelle"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Kategorie *</label>
<select
required
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
value={formData.category}
onChange={e => setFormData({ ...formData, category: e.target.value })}
>
{categories.map(cat => (
<option key={cat.id} value={cat.name}>{cat.icon} {cat.display_name || cat.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Beschreibung</label>
<textarea
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
rows={2}
placeholder="Kurze Beschreibung der Quelle"
value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Trust-Boost: {formData.trustBoost?.toFixed(2)}
</label>
<input
type="range"
min="0"
max="1"
step="0.05"
className="w-full"
value={formData.trustBoost}
onChange={e => setFormData({ ...formData, trustBoost: parseFloat(e.target.value) })}
/>
<p className="text-xs text-slate-500 mt-1">
Höhere Werte für vertrauenswürdigere Quellen (1.0 = max für offizielle Regierungsquellen)
</p>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="enabled"
className="rounded border-slate-300 text-primary-600 focus:ring-primary-500"
checked={formData.enabled}
onChange={e => setFormData({ ...formData, enabled: e.target.checked })}
/>
<label htmlFor="enabled" className="text-sm text-slate-700">Aktiv (wird beim nächsten Crawl berücksichtigt)</label>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
disabled={saving}
className="px-4 py-2 text-slate-700 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-50"
>
Abbrechen
</button>
<button
type="submit"
disabled={saving}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 flex items-center gap-2"
>
{saving && (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{seed ? 'Speichern' : 'Hinzufügen'}
</button>
</div>
</form>
</div>
</div>
)
}
const handleDelete = async (id: string) => {
if (!confirm('Seed-URL wirklich löschen?')) return
try {
const res = await fetch(`/api/admin/edu-search?id=${id}`, {
method: 'DELETE',
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
await fetchSeeds()
await fetchStats()
} catch (err) {
console.error('Failed to delete seed:', err)
alert('Fehler beim Löschen')
}
}
const handleToggleEnabled = async (id: string) => {
const seed = seeds.find(s => s.id === id)
if (!seed) return
try {
const res = await fetch(`/api/admin/edu-search?id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !seed.enabled }),
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
// Optimistic update
setSeeds(seeds.map(s => s.id === id ? { ...s, enabled: !s.enabled } : s))
} catch (err) {
console.error('Failed to toggle seed:', err)
// Reload on error
await fetchSeeds()
}
}
// Poll for crawl status from backend
const pollCrawlStatus = useCallback(async () => {
try {
const res = await fetch('/api/admin/edu-search?action=legal-crawler-status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
if (res.ok) {
const data = await res.json()
return data.status // 'running', 'idle', 'completed', 'error'
}
} catch {
// Ignore errors
}
return 'idle'
}, [])
const handleStartCrawl = async () => {
setLoading(true)
setError(null)
setStats(prev => ({ ...prev, crawlStatus: 'running', lastCrawlTime: new Date().toISOString() }))
try {
const response = await fetch('/api/admin/edu-search?action=crawl', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const data = await response.json()
if (response.ok) {
// Crawl gestartet - kontinuierlich Status prüfen
const checkStatus = async () => {
const status = await pollCrawlStatus()
if (status === 'running') {
// Noch am Laufen - weiter pollen
setStats(prev => ({ ...prev, crawlStatus: 'running' }))
setTimeout(checkStatus, 3000)
} else if (status === 'completed' || status === 'idle') {
// Fertig
setStats(prev => ({ ...prev, crawlStatus: 'idle' }))
setLoading(false)
await fetchStats(false) // Refresh stats
} else {
// Fehler oder unbekannter Status
setStats(prev => ({ ...prev, crawlStatus: 'error' }))
setLoading(false)
}
}
// Start polling nach kurzer Verzögerung
setTimeout(checkStatus, 2000)
} else {
setError(data.error || 'Fehler beim Starten des Crawls')
setLoading(false)
setStats(prev => ({ ...prev, crawlStatus: 'idle' }))
}
} catch (err) {
setError('Netzwerkfehler beim Starten des Crawls')
setLoading(false)
setStats(prev => ({ ...prev, crawlStatus: 'idle' }))
}
}
const {
seeds, allSeeds, categories, stats, selectedCategory, setSelectedCategory,
loading, initialLoading, error,
handleStartCrawl, handleDelete, handleToggleEnabled, handleSaved,
fetchSeeds, fetchStats,
} = useEduSearchData()
return (
<AdminLayout title="Education Search" description="Bildungsquellen & Crawler verwalten">
{/* Loading State */}
{initialLoading && (
<div className="flex items-center justify-center py-12">
<svg className="w-8 h-8 animate-spin text-primary-600" fill="none" viewBox="0 0 24 24">
@@ -542,7 +45,6 @@ export default function EduSearchAdminPage() {
</div>
)}
{/* Error State */}
{error && !initialLoading && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center gap-3">
@@ -563,395 +65,45 @@ export default function EduSearchAdminPage() {
</div>
)}
{/* Tabs */}
{!initialLoading && (
<div className="bg-white rounded-xl shadow-sm border border-slate-200 mb-6">
<div className="border-b border-slate-200">
<nav className="flex -mb-px">
{[
{ id: 'seeds', name: 'Seed-URLs', icon: '🌱' },
{ id: 'crawl', name: 'Crawl-Steuerung', icon: '🕷️' },
{ id: 'stats', name: 'Statistiken', icon: '📊' },
{ id: 'rules', name: 'Tagging-Regeln', icon: '🏷️' },
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary-600 text-primary-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
}`}
>
<span className="mr-2">{tab.icon}</span>
{tab.name}
</button>
))}
</nav>
</div>
<div className="p-6">
{/* Seeds Tab */}
{activeTab === 'seeds' && (
<div>
{/* Header with filters */}
<div className="flex flex-wrap items-center gap-4 mb-6">
<div className="flex-1 min-w-[200px]">
<input
type="text"
placeholder="Suche nach Name oder URL..."
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</div>
<select
className="px-4 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
value={selectedCategory}
onChange={e => setSelectedCategory(e.target.value)}
>
<option value="all">Alle Kategorien</option>
{categories.map(cat => (
<option key={cat.id} value={cat.name}>{cat.icon} {cat.display_name || cat.name}</option>
))}
</select>
<div className="bg-white rounded-xl shadow-sm border border-slate-200 mb-6">
<div className="border-b border-slate-200">
<nav className="flex -mb-px">
{tabDefs.map(tab => (
<button
onClick={() => setShowAddModal(true)}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors flex items-center gap-2"
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-6 py-4 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary-600 text-primary-600'
: 'border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300'
}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neue Seed-URL
<span className="mr-2">{tab.icon}</span>
{tab.name}
</button>
</div>
))}
</nav>
</div>
{/* Category Quick Stats - show all categories, use allSeeds for counts */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4 mb-6">
{categories.map(cat => {
const count = allSeeds.filter(s => s.category === cat.name).length
return (
<button
key={cat.id}
onClick={() => setSelectedCategory(selectedCategory === cat.name ? 'all' : cat.name)}
className={`p-4 rounded-lg border transition-colors text-left ${
selectedCategory === cat.name
? 'border-primary-500 bg-primary-50'
: 'border-slate-200 hover:border-slate-300'
}`}
>
<div className="text-2xl mb-1">{cat.icon}</div>
<div className="font-medium text-slate-900 text-sm">{cat.display_name || cat.name}</div>
<div className="text-sm text-slate-500">{count} Seeds</div>
</button>
)
})}
</div>
{/* Seeds Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200">
<th className="text-left py-3 px-4 font-medium text-slate-700">Status</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">Name</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">URL</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">Kategorie</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">Trust</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">Dokumente</th>
<th className="text-left py-3 px-4 font-medium text-slate-700">Aktionen</th>
</tr>
</thead>
<tbody>
{filteredSeeds.map(seed => (
<tr key={seed.id} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-3 px-4">
<button
onClick={() => handleToggleEnabled(seed.id)}
className={`w-10 h-6 rounded-full transition-colors ${
seed.enabled ? 'bg-green-500' : 'bg-slate-300'
}`}
>
<span className={`block w-4 h-4 bg-white rounded-full transform transition-transform ${
seed.enabled ? 'translate-x-5' : 'translate-x-1'
}`} />
</button>
</td>
<td className="py-3 px-4">
<div className="font-medium text-slate-900">{seed.name}</div>
<div className="text-sm text-slate-500">{seed.description}</div>
</td>
<td className="py-3 px-4">
<a href={seed.url} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline text-sm">
{seed.url.replace(/^https?:\/\/(www\.)?/, '').slice(0, 30)}...
</a>
</td>
<td className="py-3 px-4">
<span className="inline-flex items-center gap-1 px-2 py-1 bg-slate-100 rounded text-sm">
{categories.find(c => c.name === seed.category)?.icon || '📁'}
{categories.find(c => c.name === seed.category)?.display_name || seed.category}
</span>
</td>
<td className="py-3 px-4">
<span className={`inline-flex px-2 py-1 rounded text-sm font-medium ${
seed.trustBoost >= 0.4 ? 'bg-green-100 text-green-700' :
seed.trustBoost >= 0.2 ? 'bg-yellow-100 text-yellow-700' :
'bg-slate-100 text-slate-700'
}`}>
+{seed.trustBoost.toFixed(2)}
</span>
</td>
<td className="py-3 px-4 text-slate-600">
{seed.documentCount?.toLocaleString() || '-'}
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<button
onClick={() => setEditingSeed(seed)}
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded"
title="Bearbeiten"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
onClick={() => handleDelete(seed.id)}
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded"
title="Löschen"
>
<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>
</td>
</tr>
))}
</tbody>
</table>
</div>
{filteredSeeds.length === 0 && (
<div className="text-center py-12 text-slate-500">
Keine Seed-URLs gefunden
</div>
)}
</div>
)}
{/* Crawl Tab */}
{activeTab === 'crawl' && (
<div className="space-y-6">
{/* Crawl Status */}
<div className="bg-slate-50 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-slate-900">Crawl-Status</h3>
<p className="text-sm text-slate-500">
Letzter Crawl: {stats.lastCrawlTime ? new Date(stats.lastCrawlTime).toLocaleString('de-DE') : 'Noch nie'}
</p>
</div>
<div className={`px-3 py-1.5 rounded-full text-sm font-medium ${
stats.crawlStatus === 'running' ? 'bg-blue-100 text-blue-700' :
stats.crawlStatus === 'error' ? 'bg-red-100 text-red-700' :
'bg-green-100 text-green-700'
}`}>
{stats.crawlStatus === 'running' ? '🔄 Läuft...' :
stats.crawlStatus === 'error' ? '❌ Fehler' :
'✅ Bereit'}
</div>
</div>
<button
onClick={handleStartCrawl}
disabled={loading || stats.crawlStatus === 'running'}
className="px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{loading ? (
<>
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Crawl läuft...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Crawl starten
</>
)}
</button>
</div>
{/* Crawl Settings */}
<div className="grid md:grid-cols-2 gap-6">
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h4 className="font-semibold text-slate-900 mb-4">Crawl-Einstellungen</h4>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Max. Seiten pro Crawl</label>
<input type="number" defaultValue={500} className="w-full px-3 py-2 border border-slate-300 rounded-lg" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Rate-Limit (Requests/Sek)</label>
<input type="number" defaultValue={0.2} step={0.1} className="w-full px-3 py-2 border border-slate-300 rounded-lg" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Max. Crawl-Tiefe</label>
<input type="number" defaultValue={4} className="w-full px-3 py-2 border border-slate-300 rounded-lg" />
</div>
</div>
</div>
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h4 className="font-semibold text-slate-900 mb-4">Scheduler</h4>
<div className="space-y-4">
<div className="flex items-center gap-2">
<input type="checkbox" id="autoSchedule" className="rounded border-slate-300" />
<label htmlFor="autoSchedule" className="text-sm text-slate-700">Automatischer Crawl aktiviert</label>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Intervall</label>
<select className="w-full px-3 py-2 border border-slate-300 rounded-lg">
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
<option value="monthly">Monatlich</option>
</select>
</div>
</div>
</div>
</div>
</div>
)}
{/* Stats Tab */}
{activeTab === 'stats' && (
<div className="space-y-6">
{/* Overview Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl p-5 text-white">
<div className="text-3xl font-bold">{stats.totalDocuments.toLocaleString()}</div>
<div className="text-blue-100">Dokumente indexiert</div>
</div>
<div className="bg-gradient-to-br from-green-500 to-green-600 rounded-xl p-5 text-white">
<div className="text-3xl font-bold">{stats.totalSeeds}</div>
<div className="text-green-100">Seed-URLs aktiv</div>
</div>
<div className="bg-gradient-to-br from-purple-500 to-purple-600 rounded-xl p-5 text-white">
<div className="text-3xl font-bold">{(stats.avgTrustScore * 100).toFixed(0)}%</div>
<div className="text-purple-100">Ø Trust-Score</div>
</div>
<div className="bg-gradient-to-br from-orange-500 to-orange-600 rounded-xl p-5 text-white">
<div className="text-3xl font-bold">{Object.keys(stats.documentsPerDocType).length}</div>
<div className="text-orange-100">Dokumenttypen</div>
</div>
</div>
{/* Charts */}
<div className="grid md:grid-cols-2 gap-6">
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h4 className="font-semibold text-slate-900 mb-4">Dokumente nach Kategorie</h4>
<div className="space-y-3">
{Object.entries(stats.documentsPerCategory).map(([cat, count]) => {
const category = categories.find(c => c.name === cat)
const percentage = stats.totalDocuments > 0 ? (count / stats.totalDocuments) * 100 : 0
return (
<div key={cat}>
<div className="flex justify-between text-sm mb-1">
<span>{category?.icon || '📁'} {category?.display_name || cat}</span>
<span className="text-slate-500">{count.toLocaleString()} ({percentage.toFixed(1)}%)</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 rounded-full"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
)
})}
</div>
</div>
<div className="bg-white border border-slate-200 rounded-lg p-6">
<h4 className="font-semibold text-slate-900 mb-4">Dokumente nach Typ</h4>
<div className="space-y-3">
{Object.entries(stats.documentsPerDocType)
.sort(([,a], [,b]) => b - a)
.slice(0, 6)
.map(([docType, count]) => {
const percentage = (count / stats.totalDocuments) * 100
return (
<div key={docType}>
<div className="flex justify-between text-sm mb-1">
<span>{docType.replace(/_/g, ' ')}</span>
<span className="text-slate-500">{count.toLocaleString()} ({percentage.toFixed(1)}%)</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
)}
{/* Rules Tab */}
{activeTab === 'rules' && (
<div className="space-y-6">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<span className="text-2xl"></span>
<div>
<h4 className="font-semibold text-amber-800">Tagging-Regeln Editor</h4>
<p className="text-sm text-amber-700">
Die Tagging-Regeln werden aktuell über YAML-Dateien verwaltet.
Ein visueller Editor ist in Entwicklung.
</p>
</div>
</div>
</div>
<div className="grid md:grid-cols-2 gap-6">
{[
{ name: 'Doc-Type Regeln', file: 'doc_type_rules.yaml', desc: 'Klassifiziert Dokumente (Lehrplan, Arbeitsblatt, etc.)' },
{ name: 'Fach-Regeln', file: 'subject_rules.yaml', desc: 'Erkennt Unterrichtsfächer' },
{ name: 'Schulstufen-Regeln', file: 'level_rules.yaml', desc: 'Erkennt Primar, SekI, SekII, etc.' },
{ name: 'Trust-Score Regeln', file: 'trust_rules.yaml', desc: 'Domain-basierte Vertrauensbewertung' },
].map(rule => (
<div key={rule.file} className="bg-white border border-slate-200 rounded-lg p-6">
<h4 className="font-semibold text-slate-900 mb-2">{rule.name}</h4>
<p className="text-sm text-slate-500 mb-4">{rule.desc}</p>
<code className="text-xs bg-slate-100 px-2 py-1 rounded text-slate-600">
/rules/{rule.file}
</code>
</div>
))}
</div>
</div>
)}
<div className="p-6">
{activeTab === 'seeds' && (
<SeedsTab
seeds={seeds} allSeeds={allSeeds} categories={categories}
searchQuery={searchQuery} setSearchQuery={setSearchQuery}
selectedCategory={selectedCategory} setSelectedCategory={setSelectedCategory}
onToggleEnabled={handleToggleEnabled} onDelete={handleDelete} onSaved={handleSaved}
/>
)}
{activeTab === 'crawl' && (
<CrawlTab stats={stats} loading={loading} onStartCrawl={handleStartCrawl} />
)}
{activeTab === 'stats' && (
<StatsTab stats={stats} categories={categories} />
)}
{activeTab === 'rules' && <RulesTab />}
</div>
</div>
</div>
)}
{/* Modals */}
{(showAddModal || editingSeed) && (
<SeedModal
seed={editingSeed}
onClose={() => {
setShowAddModal(false)
setEditingSeed(null)
}}
/>
)}
</AdminLayout>
)

View File

@@ -0,0 +1,102 @@
export interface SeedURL {
id: string
url: string
category: string
category_id?: string
name: string
description: string
trustBoost: number
enabled: boolean
lastCrawled?: string
documentCount?: number
source_type?: string
scope?: string
state?: string
crawl_depth?: number
crawl_frequency?: string
}
export interface CrawlStats {
totalDocuments: number
totalSeeds: number
lastCrawlTime?: string
crawlStatus: 'idle' | 'running' | 'error'
documentsPerCategory: Record<string, number>
documentsPerDocType: Record<string, number>
avgTrustScore: number
}
export interface Category {
id: string
name: string
display_name?: string
description: string
icon: string
sort_order?: number
is_active?: boolean
}
export interface ApiSeed {
id: string
url: string
name: string
description: string | null
category: string | null
category_display_name: string | null
source_type: string
scope: string
state: string | null
trust_boost: number
enabled: boolean
crawl_depth: number
crawl_frequency: string
last_crawled_at: string | null
last_crawl_status: string | null
last_crawl_docs: number
total_documents: number
created_at: string
updated_at: string
}
// Default categories (fallback if API fails)
export const DEFAULT_CATEGORIES: Category[] = [
{ id: 'federal', name: 'federal', display_name: 'Bundesebene', description: 'KMK, BMBF, Bildungsserver', icon: '🏛️' },
{ id: 'states', name: 'states', display_name: 'Bundesländer', description: 'Ministerien, Landesbildungsserver', icon: '🗺️' },
{ id: 'science', name: 'science', display_name: 'Wissenschaft', description: 'Bertelsmann, PISA, IGLU, TIMSS', icon: '🔬' },
{ id: 'universities', name: 'universities', display_name: 'Hochschulen', description: 'Universitäten, Fachhochschulen, Pädagogische Hochschulen', icon: '🎓' },
{ id: 'legal', name: 'legal', display_name: 'Recht & Schulgesetze', description: 'Schulgesetze, Erlasse, Verordnungen, Datenschutzrecht', icon: '⚖️' },
{ id: 'portals', name: 'portals', display_name: 'Bildungsportale', description: 'Lehrer-Online, 4teachers, ZUM', icon: '📚' },
{ id: 'authorities', name: 'authorities', display_name: 'Schulbehörden', description: 'Regierungspräsidien, Schulämter', icon: '📋' },
]
// Default empty stats (loaded from API)
export const DEFAULT_STATS: CrawlStats = {
totalDocuments: 0,
totalSeeds: 0,
lastCrawlTime: undefined,
crawlStatus: 'idle',
documentsPerCategory: {},
documentsPerDocType: {},
avgTrustScore: 0,
}
// Convert API seed to frontend format
export function apiSeedToFrontend(seed: ApiSeed): SeedURL {
return {
id: seed.id,
url: seed.url,
name: seed.name,
description: seed.description || '',
category: seed.category || 'federal',
category_id: undefined,
trustBoost: seed.trust_boost,
enabled: seed.enabled,
lastCrawled: seed.last_crawled_at || undefined,
documentCount: seed.total_documents,
source_type: seed.source_type,
scope: seed.scope,
state: seed.state || undefined,
crawl_depth: seed.crawl_depth,
crawl_frequency: seed.crawl_frequency,
}
}

View File

@@ -0,0 +1,171 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import type { SeedURL, Category, CrawlStats } from './types'
import { DEFAULT_CATEGORIES, DEFAULT_STATS, apiSeedToFrontend } from './types'
export function useEduSearchData() {
const [seeds, setSeeds] = useState<SeedURL[]>([])
const [allSeeds, setAllSeeds] = useState<SeedURL[]>([])
const [categories, setCategories] = useState<Category[]>(DEFAULT_CATEGORIES)
const [stats, setStats] = useState<CrawlStats>(DEFAULT_STATS)
const [selectedCategory, setSelectedCategory] = useState<string>('all')
const [loading, setLoading] = useState(false)
const [initialLoading, setInitialLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchCategories = useCallback(async () => {
try {
const res = await fetch(`/api/admin/edu-search?action=categories`)
if (res.ok) {
const data = await res.json()
if (data.categories && data.categories.length > 0) {
setCategories(data.categories.map((cat: { id: string; name: string; display_name: string; description: string; icon: string; sort_order: number; is_active: boolean }) => ({
id: cat.id, name: cat.name, display_name: cat.display_name,
description: cat.description || '', icon: cat.icon || '📁',
sort_order: cat.sort_order, is_active: cat.is_active,
})))
}
}
} catch (err) {
console.error('Failed to fetch categories:', err)
}
}, [])
const fetchAllSeeds = useCallback(async () => {
try {
const res = await fetch(`/api/admin/edu-search?action=seeds`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setAllSeeds((data.seeds || []).map(apiSeedToFrontend))
} catch (err) {
console.error('Failed to fetch all seeds:', err)
}
}, [])
const fetchSeeds = useCallback(async () => {
try {
const params = new URLSearchParams()
params.append('action', 'seeds')
if (selectedCategory !== 'all') params.append('category', selectedCategory)
const res = await fetch(`/api/admin/edu-search?${params}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
const fetchedSeeds = (data.seeds || []).map(apiSeedToFrontend)
setSeeds(fetchedSeeds)
if (selectedCategory === 'all') setAllSeeds(fetchedSeeds)
setError(null)
} catch (err) {
console.error('Failed to fetch seeds:', err)
setError('Seeds konnten nicht geladen werden. API nicht erreichbar.')
}
}, [selectedCategory])
const fetchStats = useCallback(async (preserveCrawlStatus = false) => {
try {
const res = await fetch(`/api/admin/edu-search?action=stats`)
if (res.ok) {
const data = await res.json()
setStats(prev => ({
totalDocuments: data.total_documents || 0,
totalSeeds: data.total_seeds || 0,
lastCrawlTime: data.last_crawl_time || prev.lastCrawlTime,
crawlStatus: preserveCrawlStatus ? prev.crawlStatus : (data.crawl_status || 'idle'),
documentsPerCategory: data.seeds_per_category || {},
documentsPerDocType: {},
avgTrustScore: data.avg_trust_boost || 0,
}))
}
} catch (err) {
console.error('Failed to fetch stats:', err)
}
}, [])
useEffect(() => {
const loadData = async () => {
setInitialLoading(true)
await Promise.all([fetchCategories(), fetchSeeds(), fetchAllSeeds(), fetchStats()])
setInitialLoading(false)
}
loadData()
}, [fetchCategories, fetchSeeds, fetchAllSeeds, fetchStats])
useEffect(() => {
if (!initialLoading) fetchSeeds()
}, [selectedCategory, initialLoading, fetchSeeds])
const pollCrawlStatus = useCallback(async () => {
try {
const res = await fetch('/api/admin/edu-search?action=legal-crawler-status', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}),
})
if (res.ok) return (await res.json()).status
} catch { /* Ignore */ }
return 'idle'
}, [])
const handleStartCrawl = async () => {
setLoading(true)
setError(null)
setStats(prev => ({ ...prev, crawlStatus: 'running', lastCrawlTime: new Date().toISOString() }))
try {
const response = await fetch('/api/admin/edu-search?action=crawl', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}),
})
const data = await response.json()
if (response.ok) {
const checkStatus = async () => {
const status = await pollCrawlStatus()
if (status === 'running') { setStats(prev => ({ ...prev, crawlStatus: 'running' })); setTimeout(checkStatus, 3000) }
else if (status === 'completed' || status === 'idle') { setStats(prev => ({ ...prev, crawlStatus: 'idle' })); setLoading(false); await fetchStats(false) }
else { setStats(prev => ({ ...prev, crawlStatus: 'error' })); setLoading(false) }
}
setTimeout(checkStatus, 2000)
} else {
setError(data.error || 'Fehler beim Starten des Crawls')
setLoading(false)
setStats(prev => ({ ...prev, crawlStatus: 'idle' }))
}
} catch (err) {
setError('Netzwerkfehler beim Starten des Crawls')
setLoading(false)
setStats(prev => ({ ...prev, crawlStatus: 'idle' }))
}
}
const handleDelete = async (id: string) => {
if (!confirm('Seed-URL wirklich löschen?')) return
try {
const res = await fetch(`/api/admin/edu-search?id=${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
await fetchSeeds(); await fetchStats()
} catch (err) {
console.error('Failed to delete seed:', err)
alert('Fehler beim Löschen')
}
}
const handleToggleEnabled = async (id: string) => {
const seed = seeds.find(s => s.id === id)
if (!seed) return
try {
const res = await fetch(`/api/admin/edu-search?id=${id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !seed.enabled }),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setSeeds(seeds.map(s => s.id === id ? { ...s, enabled: !s.enabled } : s))
} catch (err) {
console.error('Failed to toggle seed:', err); await fetchSeeds()
}
}
const handleSaved = async () => { await fetchSeeds(); await fetchStats() }
return {
seeds, allSeeds, categories, stats, selectedCategory, setSelectedCategory,
loading, initialLoading, error, setError,
handleStartCrawl, handleDelete, handleToggleEnabled, handleSaved,
fetchSeeds, fetchStats,
}
}

View File

@@ -0,0 +1,70 @@
'use client'
import type { MacMiniStatus } from '../types'
export default function DockerSection({
status,
actionLoading,
onDockerUp,
onDockerDown,
}: {
status: MacMiniStatus | null
actionLoading: string | null
onDockerUp: () => void
onDockerDown: () => void
}) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<span className="text-2xl">🐳</span> Docker Container
</h3>
<div className="flex gap-2">
<button
onClick={onDockerUp}
disabled={actionLoading !== null || !status?.online}
className="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{actionLoading === 'docker-up' ? '...' : '▶ Start'}
</button>
<button
onClick={onDockerDown}
disabled={actionLoading !== null || !status?.online}
className="px-3 py-1.5 bg-red-600 text-white text-sm rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{actionLoading === 'docker-down' ? '...' : '⏹ Stop'}
</button>
</div>
</div>
{status?.containers && status.containers.length > 0 ? (
<div className="space-y-2">
{status.containers.map((container, idx) => (
<div key={idx} className="flex items-center justify-between bg-slate-50 rounded-lg p-3">
<div className="flex items-center gap-3">
<span className={`w-2 h-2 rounded-full ${
container.status.includes('Up') ? 'bg-green-500' : 'bg-red-500'
}`}></span>
<span className="font-medium text-slate-700">{container.name}</span>
</div>
<div className="flex items-center gap-4">
{container.ports && (
<span className="text-sm text-slate-500 font-mono">{container.ports}</span>
)}
<span className={`text-sm ${
container.status.includes('Up') ? 'text-green-600' : 'text-red-500'
}`}>
{container.status}
</span>
</div>
</div>
))}
</div>
) : (
<p className="text-slate-500 text-center py-4">
{status?.online ? 'Keine Container gefunden' : 'Server nicht erreichbar'}
</p>
)}
</div>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
import { INTERNET_REQUIRED_ACTIONS } from '../constants'
export default function InternetStatus({ internet }: { internet?: boolean }) {
return (
<div className={`rounded-xl border p-4 mb-6 ${
internet
? 'bg-green-50 border-green-200'
: 'bg-amber-50 border-amber-200'
}`}>
<div className="flex items-start justify-between">
<div className="flex gap-3">
<span className="text-2xl">{internet ? '🌐' : '📴'}</span>
<div>
<h3 className={`font-semibold ${internet ? 'text-green-900' : 'text-amber-900'}`}>
Internet: {internet ? 'Verbunden' : 'Offline (Normalbetrieb)'}
</h3>
<p className={`text-sm mt-1 ${internet ? 'text-green-700' : 'text-amber-700'}`}>
{internet
? 'Mac Mini hat Internet-Zugang. LLM-Downloads und Updates möglich.'
: 'Mac Mini arbeitet offline. Für bestimmte Aktionen muss Internet aktiviert werden.'}
</p>
</div>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${
internet
? 'bg-green-100 text-green-800'
: 'bg-amber-100 text-amber-800'
}`}>
{internet ? 'Online' : 'Offline'}
</span>
</div>
{!internet && (
<div className="mt-4 pt-4 border-t border-amber-200">
<h4 className="font-medium text-amber-900 mb-2"> Diese Aktionen benötigen Internet:</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{INTERNET_REQUIRED_ACTIONS.map((item, idx) => (
<div key={idx} className="flex items-start gap-2 text-sm">
<span className="text-amber-600 mt-0.5"></span>
<div>
<span className="font-medium text-amber-800">{item.action}</span>
<span className="text-amber-600 ml-1"> {item.description}</span>
</div>
</div>
))}
</div>
<p className="text-xs text-amber-600 mt-3 italic">
💡 Tipp: Internet am Router/Switch nur bei Bedarf für den Mac Mini aktivieren.
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,289 @@
'use client'
import { useState } from 'react'
import type { MacMiniStatus, DownloadProgress, ModelDescription } from '../types'
import { MODEL_DATABASE, RECOMMENDED_MODELS } from '../constants'
function getModelInfo(modelName: string): ModelDescription | null {
if (MODEL_DATABASE[modelName]) return MODEL_DATABASE[modelName]
const baseName = modelName.split(':')[0]
const matchingKey = Object.keys(MODEL_DATABASE).find(key =>
key.startsWith(baseName) || key === baseName
)
return matchingKey ? MODEL_DATABASE[matchingKey] : null
}
function formatBytes(bytes: number) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
export default function OllamaSection({
status,
actionLoading,
downloadProgress,
modelInput,
setModelInput,
onPullModel,
}: {
status: MacMiniStatus | null
actionLoading: string | null
downloadProgress: DownloadProgress | null
modelInput: string
setModelInput: (v: string) => void
onPullModel: () => void
}) {
const [selectedModel, setSelectedModel] = useState<string | null>(null)
const [showRecommendations, setShowRecommendations] = useState(false)
const isModelInstalled = (modelName: string): boolean => {
if (!status?.models) return false
return status.models.some(m =>
m.name === modelName || m.name.startsWith(modelName.split(':')[0])
)
}
return (
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 flex items-center gap-2 mb-4">
<span className="text-2xl">🤖</span> Ollama LLM Modelle
</h3>
{/* Installed Models */}
{status?.models && status.models.length > 0 ? (
<div className="space-y-2 mb-6">
{status.models.map((model, idx) => {
const modelInfo = getModelInfo(model.name)
return (
<div key={idx} className="flex items-center justify-between bg-slate-50 rounded-lg p-3 hover:bg-slate-100 transition-colors">
<div className="flex items-center gap-3">
<span className="w-2 h-2 rounded-full bg-green-500"></span>
<span className="font-medium text-slate-700">{model.name}</span>
{modelInfo && (
<button
onClick={() => setSelectedModel(model.name)}
className="text-blue-500 hover:text-blue-700 transition-colors"
title="Modell-Info anzeigen"
>
<svg className="w-5 h-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>
</button>
)}
{modelInfo?.category === 'vision' && (
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full">Vision</span>
)}
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-slate-500">{model.size}</span>
<span className="text-sm text-slate-400">{model.modified}</span>
</div>
</div>
)
})}
</div>
) : (
<p className="text-slate-500 text-center py-4 mb-6">
{status?.ollama ? 'Keine Modelle installiert' : 'Ollama nicht erreichbar'}
</p>
)}
{/* Model Info Modal */}
{selectedModel && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setSelectedModel(null)}>
<div className="bg-white rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl" onClick={e => e.stopPropagation()}>
{(() => {
const info = getModelInfo(selectedModel)
if (!info) return <p>Keine Informationen verfügbar</p>
return (
<>
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-xl font-bold text-slate-900">{info.name}</h3>
<div className="flex items-center gap-2 mt-1">
<span className={`px-2 py-0.5 text-xs rounded-full ${
info.category === 'vision' ? 'bg-purple-100 text-purple-700' :
info.category === 'text' ? 'bg-blue-100 text-blue-700' :
'bg-slate-100 text-slate-700'
}`}>
{info.category === 'vision' ? '👁️ Vision' : info.category === 'text' ? '📝 Text' : info.category}
</span>
<span className="text-sm text-slate-500">{info.size}</span>
</div>
</div>
<button onClick={() => setSelectedModel(null)} className="text-slate-400 hover:text-slate-600">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-slate-600 mb-4">{info.description}</p>
<div>
<h4 className="font-medium text-slate-700 mb-2">Geeignet für:</h4>
<div className="flex flex-wrap gap-2">
{info.useCases.map((useCase, i) => (
<span key={i} className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm">
{useCase}
</span>
))}
</div>
</div>
</>
)
})()}
</div>
</div>
)}
{/* Download New Model */}
<div className="border-t border-slate-200 pt-6">
<h4 className="font-medium text-slate-700 mb-3">Neues Modell herunterladen</h4>
<div className="flex gap-3 mb-4">
<input
type="text"
value={modelInput}
onChange={(e) => setModelInput(e.target.value)}
placeholder="z.B. llama3.2, mistral, qwen2.5:14b"
className="flex-1 px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
disabled={actionLoading === 'pull'}
/>
<button
onClick={onPullModel}
disabled={actionLoading !== null || !status?.ollama || !modelInput.trim()}
className="px-6 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{actionLoading === 'pull' ? 'Lädt...' : 'Herunterladen'}
</button>
</div>
{/* Download Progress */}
{downloadProgress && (
<div className="bg-slate-50 rounded-lg p-4">
<div className="flex justify-between mb-2">
<span className="font-medium text-slate-700">{downloadProgress.model}</span>
<span className="text-sm text-slate-500">
{formatBytes(downloadProgress.completed)} / {formatBytes(downloadProgress.total)}
</span>
</div>
<div className="h-3 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary-500 to-primary-600 transition-all duration-300"
style={{ width: `${downloadProgress.percent}%` }}
></div>
</div>
<div className="text-center mt-2 text-sm font-medium text-slate-600">
{downloadProgress.percent}%
</div>
</div>
)}
{/* Toggle Recommendations */}
<button
onClick={() => setShowRecommendations(!showRecommendations)}
className="mt-4 text-primary-600 hover:text-primary-700 font-medium text-sm flex items-center gap-2"
>
<svg className={`w-4 h-4 transition-transform ${showRecommendations ? '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>
{showRecommendations ? 'Empfehlungen ausblenden' : 'Modell-Empfehlungen für Klausurkorrektur & Handschrift anzeigen'}
</button>
</div>
{/* Recommendations Section */}
{showRecommendations && (
<div className="border-t border-slate-200 pt-6 mt-6">
<h4 className="font-semibold text-slate-900 mb-4">📚 Empfohlene Modelle</h4>
{/* Handwriting Recognition */}
<div className="mb-6">
<h5 className="font-medium text-slate-700 flex items-center gap-2 mb-3">
<span className="text-lg"></span> Handschrifterkennung (Vision-Modelle)
</h5>
<div className="space-y-2">
{RECOMMENDED_MODELS.handwriting.map((rec, idx) => {
const info = MODEL_DATABASE[rec.model]
const installed = isModelInstalled(rec.model)
return (
<div key={idx} className={`flex items-center justify-between rounded-lg p-3 ${installed ? 'bg-green-50 border border-green-200' : 'bg-slate-50'}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-700">{info?.name || rec.model}</span>
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full">Vision</span>
{info?.recommended && <span className="px-2 py-0.5 text-xs bg-yellow-100 text-yellow-700 rounded-full"> Empfohlen</span>}
{installed && <span className="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full"> Installiert</span>}
</div>
<p className="text-sm text-slate-500 mt-1">{rec.reason}</p>
<p className="text-xs text-slate-400 mt-0.5">Größe: {info?.size || 'unbekannt'}</p>
</div>
{!installed && (
<button
onClick={() => { setModelInput(rec.model); onPullModel() }}
disabled={actionLoading !== null || !status?.ollama}
className="ml-4 px-4 py-2 bg-primary-600 text-white text-sm rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Installieren
</button>
)}
</div>
)
})}
</div>
</div>
{/* Grading / Text Analysis */}
<div>
<h5 className="font-medium text-slate-700 flex items-center gap-2 mb-3">
<span className="text-lg">📝</span> Klausurkorrektur (Text-Modelle)
</h5>
<div className="space-y-2">
{RECOMMENDED_MODELS.grading.map((rec, idx) => {
const info = MODEL_DATABASE[rec.model]
const installed = isModelInstalled(rec.model)
return (
<div key={idx} className={`flex items-center justify-between rounded-lg p-3 ${installed ? 'bg-green-50 border border-green-200' : 'bg-slate-50'}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-700">{info?.name || rec.model}</span>
<span className="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded-full">Text</span>
{info?.recommended && <span className="px-2 py-0.5 text-xs bg-yellow-100 text-yellow-700 rounded-full"> Empfohlen</span>}
{installed && <span className="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full"> Installiert</span>}
</div>
<p className="text-sm text-slate-500 mt-1">{rec.reason}</p>
<p className="text-xs text-slate-400 mt-0.5">Größe: {info?.size || 'unbekannt'}</p>
</div>
{!installed && (
<button
onClick={() => { setModelInput(rec.model); onPullModel() }}
disabled={actionLoading !== null || !status?.ollama}
className="ml-4 px-4 py-2 bg-primary-600 text-white text-sm rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Installieren
</button>
)}
</div>
)
})}
</div>
</div>
{/* Info Box */}
<div className="mt-6 bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex gap-3">
<span className="text-xl">💡</span>
<div>
<h5 className="font-medium text-amber-900">Tipp: Modell-Kombinationen</h5>
<p className="text-sm text-amber-800 mt-1">
Für beste Ergebnisse bei Klausuren mit Handschrift kombiniere ein <strong>Vision-Modell</strong> (für OCR/Handschrifterkennung)
mit einem <strong>Text-Modell</strong> (für Bewertung und Feedback). Beispiel: <code className="bg-amber-100 px-1 rounded">llama3.2-vision:11b</code> + <code className="bg-amber-100 px-1 rounded">qwen2.5:14b</code>
</p>
</div>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,127 @@
'use client'
import type { MacMiniStatus } from '../types'
export default function PowerControls({
status,
loading,
actionLoading,
message,
error,
onWake,
onRestart,
onShutdown,
onRefresh,
}: {
status: MacMiniStatus | null
loading: boolean
actionLoading: string | null
message: string | null
error: string | null
onWake: () => void
onRestart: () => void
onShutdown: () => void
onRefresh: () => void
}) {
const getStatusBadge = (online: boolean) => {
return online
? 'px-3 py-1 rounded-full text-sm font-semibold bg-green-100 text-green-800'
: 'px-3 py-1 rounded-full text-sm font-semibold bg-red-100 text-red-800'
}
const getServiceStatus = (ok: boolean) => {
return ok
? 'flex items-center gap-2 text-green-600'
: 'flex items-center gap-2 text-red-500'
}
return (
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<div className="text-4xl">🖥</div>
<div>
<h2 className="text-xl font-bold text-slate-900">Mac Mini Headless</h2>
<p className="text-slate-500 text-sm">IP: {status?.ip || '192.168.178.100'}</p>
</div>
</div>
<span className={getStatusBadge(status?.online || false)}>
{loading ? 'Laden...' : status?.online ? 'Online' : 'Offline'}
</span>
</div>
{/* Power Buttons */}
<div className="flex items-center gap-4 mb-6">
<button
onClick={onWake}
disabled={actionLoading !== null}
className="px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{actionLoading === 'wake' ? '...' : '⚡ Wake on LAN'}
</button>
<button
onClick={onRestart}
disabled={actionLoading !== null || !status?.online}
className="px-4 py-2 bg-yellow-600 text-white rounded-lg font-medium hover:bg-yellow-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{actionLoading === 'restart' ? '...' : '🔄 Neustart'}
</button>
<button
onClick={onShutdown}
disabled={actionLoading !== null || !status?.online}
className="px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{actionLoading === 'shutdown' ? '...' : '⏻ Herunterfahren'}
</button>
<button
onClick={onRefresh}
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 ? '...' : '🔍 Status 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>
{/* Service Status Grid */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-sm text-slate-500 mb-1">Ping</div>
<div className={getServiceStatus(status?.ping || false)}>
<span className={`w-2 h-2 rounded-full ${status?.ping ? 'bg-green-500' : 'bg-red-500'}`}></span>
{status?.ping ? 'Erreichbar' : 'Nicht erreichbar'}
</div>
</div>
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-sm text-slate-500 mb-1">SSH</div>
<div className={getServiceStatus(status?.ssh || false)}>
<span className={`w-2 h-2 rounded-full ${status?.ssh ? 'bg-green-500' : 'bg-red-500'}`}></span>
{status?.ssh ? 'Verbunden' : 'Getrennt'}
</div>
</div>
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-sm text-slate-500 mb-1">Docker</div>
<div className={getServiceStatus(status?.docker || false)}>
<span className={`w-2 h-2 rounded-full ${status?.docker ? 'bg-green-500' : 'bg-red-500'}`}></span>
{status?.docker ? 'Aktiv' : 'Inaktiv'}
</div>
</div>
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-sm text-slate-500 mb-1">Ollama</div>
<div className={getServiceStatus(status?.ollama || false)}>
<span className={`w-2 h-2 rounded-full ${status?.ollama ? 'bg-green-500' : 'bg-red-500'}`}></span>
{status?.ollama ? 'Bereit' : 'Nicht bereit'}
</div>
</div>
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-sm text-slate-500 mb-1">Uptime</div>
<div className="font-semibold text-slate-700">
{status?.uptime || '-'}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,105 @@
import type { ModelDescription } from './types'
export const API_BASE = 'http://192.168.178.100:8000/api/mac-mini'
// Aktionen die Internet benötigen
export const INTERNET_REQUIRED_ACTIONS = [
{ action: 'LLM Modelle herunterladen', description: 'Ollama pull benötigt Verbindung zu ollama.com' },
{ action: 'Docker Base Images pullen', description: 'Neue Images von Docker Hub/GHCR' },
{ action: 'npm/pip/go Packages', description: 'Beim ersten Build oder neuen Dependencies' },
{ action: 'Git Pull/Push', description: 'Code-Synchronisation mit Remote-Repository' },
]
export const MODEL_DATABASE: Record<string, ModelDescription> = {
'llama3.2-vision:11b': {
name: 'Llama 3.2 Vision 11B', category: 'vision', size: '7.8 GB',
description: 'Metas multimodales Vision-Modell. Kann Bilder und PDFs analysieren, Text aus Handschrift extrahieren.',
useCases: ['Handschrifterkennung', 'Bild-Analyse', 'Dokumentenverarbeitung', 'OCR-Aufgaben'],
recommended: true
},
'llama3.2-vision:90b': {
name: 'Llama 3.2 Vision 90B', category: 'vision', size: '55 GB',
description: 'Größte Version von Llama Vision. Beste Qualität für komplexe Bildanalyse.',
useCases: ['Komplexe Handschrift', 'Detaillierte Bild-Analyse', 'Mathematische Formeln'],
},
'minicpm-v': {
name: 'MiniCPM-V', category: 'vision', size: '5.5 GB',
description: 'Kompaktes Vision-Modell mit gutem Preis-Leistungs-Verhältnis für OCR.',
useCases: ['Schnelle OCR', 'Einfache Handschrift', 'Tabellen-Erkennung'],
recommended: true
},
'llava:13b': {
name: 'LLaVA 13B', category: 'vision', size: '8 GB',
description: 'Large Language-and-Vision Assistant. Gut für Bild-zu-Text Aufgaben.',
useCases: ['Bildbeschreibung', 'Handschrift', 'Diagramm-Analyse'],
},
'llava:34b': {
name: 'LLaVA 34B', category: 'vision', size: '20 GB',
description: 'Größere LLaVA-Version mit besserer Genauigkeit.',
useCases: ['Komplexe Dokumente', 'Wissenschaftliche Notation', 'Detailanalyse'],
},
'bakllava': {
name: 'BakLLaVA', category: 'vision', size: '4.7 GB',
description: 'Verbesserte LLaVA-Variante mit Mistral-Basis.',
useCases: ['Schnelle Bildanalyse', 'Handschrift', 'Formular-Verarbeitung'],
},
'qwen2.5:14b': {
name: 'Qwen 2.5 14B', category: 'text', size: '9 GB',
description: 'Alibabas neuestes Sprachmodell. Exzellent für deutsche Texte und Bewertungsaufgaben.',
useCases: ['Klausurkorrektur', 'Aufsatzbewertung', 'Feedback-Generierung', 'Grammatikprüfung'],
recommended: true
},
'qwen2.5:7b': {
name: 'Qwen 2.5 7B', category: 'text', size: '4.7 GB',
description: 'Kleinere Qwen-Version, schneller bei ähnlicher Qualität.',
useCases: ['Schnelle Korrektur', 'Einfache Bewertungen', 'Rechtschreibprüfung'],
},
'qwen2.5:32b': {
name: 'Qwen 2.5 32B', category: 'text', size: '19 GB',
description: 'Große Qwen-Version für komplexe Bewertungsaufgaben.',
useCases: ['Detaillierte Analyse', 'Abitur-Klausuren', 'Komplexe Argumentation'],
},
'llama3.1:8b': {
name: 'Llama 3.1 8B', category: 'text', size: '4.7 GB',
description: 'Metas schnelles Textmodell. Gute Balance aus Geschwindigkeit und Qualität.',
useCases: ['Allgemeine Korrektur', 'Schnelles Feedback', 'Zusammenfassungen'],
},
'llama3.1:70b': {
name: 'Llama 3.1 70B', category: 'text', size: '40 GB',
description: 'Großes Llama-Modell für anspruchsvolle Aufgaben.',
useCases: ['Komplexe Klausuren', 'Tiefgehende Analyse', 'Wissenschaftliche Texte'],
},
'mistral': {
name: 'Mistral 7B', category: 'text', size: '4.1 GB',
description: 'Effizientes europäisches Modell mit guter deutscher Sprachunterstützung.',
useCases: ['Deutsche Texte', 'Schnelle Verarbeitung', 'Allgemeine Korrektur'],
},
'mixtral:8x7b': {
name: 'Mixtral 8x7B', category: 'text', size: '26 GB',
description: 'Mixture-of-Experts Modell. Kombiniert Geschwindigkeit mit hoher Qualität.',
useCases: ['Komplexe Korrektur', 'Multi-Aspekt-Bewertung', 'Wissenschaftliche Arbeiten'],
},
'gemma2:9b': {
name: 'Gemma 2 9B', category: 'text', size: '5.5 GB',
description: 'Googles kompaktes Modell. Gut für Instruktionen und Bewertungen.',
useCases: ['Strukturierte Bewertung', 'Feedback', 'Zusammenfassungen'],
},
'phi3': {
name: 'Phi-3', category: 'text', size: '2.3 GB',
description: 'Microsofts kleines aber leistungsfähiges Modell.',
useCases: ['Schnelle Checks', 'Einfache Korrektur', 'Ressourcenschonend'],
},
}
export const RECOMMENDED_MODELS = {
handwriting: [
{ model: 'llama3.2-vision:11b', reason: 'Beste Balance aus Qualität und Geschwindigkeit für Handschrift' },
{ model: 'minicpm-v', reason: 'Schnell und ressourcenschonend für einfache Handschrift' },
{ model: 'llava:13b', reason: 'Gute Alternative mit bewährter Vision-Architektur' },
],
grading: [
{ model: 'qwen2.5:14b', reason: 'Beste Qualität für deutsche Klausurkorrektur' },
{ model: 'llama3.1:8b', reason: 'Schnell für einfache Bewertungen' },
{ model: 'mistral', reason: 'Europäisches Modell mit guter Sprachqualität' },
]
}

View File

@@ -12,188 +12,12 @@
import AdminLayout from '@/components/admin/AdminLayout'
import { useEffect, useState, useCallback, useRef } from 'react'
interface MacMiniStatus {
online: boolean
ping: boolean
ssh: boolean
docker: boolean
ollama: boolean
internet: boolean // Neuer Status: Hat Mac Mini Internet-Zugang?
ip: string
uptime?: string
cpu_load?: string
memory?: string
containers?: ContainerInfo[]
models?: ModelInfo[]
error?: string
}
// Aktionen die Internet benötigen
const INTERNET_REQUIRED_ACTIONS = [
{ action: 'LLM Modelle herunterladen', description: 'Ollama pull benötigt Verbindung zu ollama.com' },
{ action: 'Docker Base Images pullen', description: 'Neue Images von Docker Hub/GHCR' },
{ action: 'npm/pip/go Packages', description: 'Beim ersten Build oder neuen Dependencies' },
{ action: 'Git Pull/Push', description: 'Code-Synchronisation mit Remote-Repository' },
]
interface ContainerInfo {
name: string
status: string
ports?: string
}
interface ModelInfo {
name: string
size: string
modified: string
}
interface DownloadProgress {
model: string
status: string
completed: number
total: number
percent: number
}
// Modell-Informationen für Beschreibungen und Empfehlungen
interface ModelDescription {
name: string
category: 'vision' | 'text' | 'code' | 'embedding'
size: string
description: string
useCases: string[]
recommended?: boolean
}
const MODEL_DATABASE: Record<string, ModelDescription> = {
// Vision-Modelle (Handschrifterkennung)
'llama3.2-vision:11b': {
name: 'Llama 3.2 Vision 11B',
category: 'vision',
size: '7.8 GB',
description: 'Metas multimodales Vision-Modell. Kann Bilder und PDFs analysieren, Text aus Handschrift extrahieren.',
useCases: ['Handschrifterkennung', 'Bild-Analyse', 'Dokumentenverarbeitung', 'OCR-Aufgaben'],
recommended: true
},
'llama3.2-vision:90b': {
name: 'Llama 3.2 Vision 90B',
category: 'vision',
size: '55 GB',
description: 'Größte Version von Llama Vision. Beste Qualität für komplexe Bildanalyse.',
useCases: ['Komplexe Handschrift', 'Detaillierte Bild-Analyse', 'Mathematische Formeln'],
},
'minicpm-v': {
name: 'MiniCPM-V',
category: 'vision',
size: '5.5 GB',
description: 'Kompaktes Vision-Modell mit gutem Preis-Leistungs-Verhältnis für OCR.',
useCases: ['Schnelle OCR', 'Einfache Handschrift', 'Tabellen-Erkennung'],
recommended: true
},
'llava:13b': {
name: 'LLaVA 13B',
category: 'vision',
size: '8 GB',
description: 'Large Language-and-Vision Assistant. Gut für Bild-zu-Text Aufgaben.',
useCases: ['Bildbeschreibung', 'Handschrift', 'Diagramm-Analyse'],
},
'llava:34b': {
name: 'LLaVA 34B',
category: 'vision',
size: '20 GB',
description: 'Größere LLaVA-Version mit besserer Genauigkeit.',
useCases: ['Komplexe Dokumente', 'Wissenschaftliche Notation', 'Detailanalyse'],
},
'bakllava': {
name: 'BakLLaVA',
category: 'vision',
size: '4.7 GB',
description: 'Verbesserte LLaVA-Variante mit Mistral-Basis.',
useCases: ['Schnelle Bildanalyse', 'Handschrift', 'Formular-Verarbeitung'],
},
// Text-Modelle (Klausurkorrektur)
'qwen2.5:14b': {
name: 'Qwen 2.5 14B',
category: 'text',
size: '9 GB',
description: 'Alibabas neuestes Sprachmodell. Exzellent für deutsche Texte und Bewertungsaufgaben.',
useCases: ['Klausurkorrektur', 'Aufsatzbewertung', 'Feedback-Generierung', 'Grammatikprüfung'],
recommended: true
},
'qwen2.5:7b': {
name: 'Qwen 2.5 7B',
category: 'text',
size: '4.7 GB',
description: 'Kleinere Qwen-Version, schneller bei ähnlicher Qualität.',
useCases: ['Schnelle Korrektur', 'Einfache Bewertungen', 'Rechtschreibprüfung'],
},
'qwen2.5:32b': {
name: 'Qwen 2.5 32B',
category: 'text',
size: '19 GB',
description: 'Große Qwen-Version für komplexe Bewertungsaufgaben.',
useCases: ['Detaillierte Analyse', 'Abitur-Klausuren', 'Komplexe Argumentation'],
},
'llama3.1:8b': {
name: 'Llama 3.1 8B',
category: 'text',
size: '4.7 GB',
description: 'Metas schnelles Textmodell. Gute Balance aus Geschwindigkeit und Qualität.',
useCases: ['Allgemeine Korrektur', 'Schnelles Feedback', 'Zusammenfassungen'],
},
'llama3.1:70b': {
name: 'Llama 3.1 70B',
category: 'text',
size: '40 GB',
description: 'Großes Llama-Modell für anspruchsvolle Aufgaben.',
useCases: ['Komplexe Klausuren', 'Tiefgehende Analyse', 'Wissenschaftliche Texte'],
},
'mistral': {
name: 'Mistral 7B',
category: 'text',
size: '4.1 GB',
description: 'Effizientes europäisches Modell mit guter deutscher Sprachunterstützung.',
useCases: ['Deutsche Texte', 'Schnelle Verarbeitung', 'Allgemeine Korrektur'],
},
'mixtral:8x7b': {
name: 'Mixtral 8x7B',
category: 'text',
size: '26 GB',
description: 'Mixture-of-Experts Modell. Kombiniert Geschwindigkeit mit hoher Qualität.',
useCases: ['Komplexe Korrektur', 'Multi-Aspekt-Bewertung', 'Wissenschaftliche Arbeiten'],
},
'gemma2:9b': {
name: 'Gemma 2 9B',
category: 'text',
size: '5.5 GB',
description: 'Googles kompaktes Modell. Gut für Instruktionen und Bewertungen.',
useCases: ['Strukturierte Bewertung', 'Feedback', 'Zusammenfassungen'],
},
'phi3': {
name: 'Phi-3',
category: 'text',
size: '2.3 GB',
description: 'Microsofts kleines aber leistungsfähiges Modell.',
useCases: ['Schnelle Checks', 'Einfache Korrektur', 'Ressourcenschonend'],
},
}
// Empfohlene Modelle für spezifische Anwendungsfälle
const RECOMMENDED_MODELS = {
handwriting: [
{ model: 'llama3.2-vision:11b', reason: 'Beste Balance aus Qualität und Geschwindigkeit für Handschrift' },
{ model: 'minicpm-v', reason: 'Schnell und ressourcenschonend für einfache Handschrift' },
{ model: 'llava:13b', reason: 'Gute Alternative mit bewährter Vision-Architektur' },
],
grading: [
{ model: 'qwen2.5:14b', reason: 'Beste Qualität für deutsche Klausurkorrektur' },
{ model: 'llama3.1:8b', reason: 'Schnell für einfache Bewertungen' },
{ model: 'mistral', reason: 'Europäisches Modell mit guter Sprachqualität' },
]
}
import type { MacMiniStatus, DownloadProgress } from './types'
import { API_BASE } from './constants'
import PowerControls from './_components/PowerControls'
import InternetStatus from './_components/InternetStatus'
import DockerSection from './_components/DockerSection'
import OllamaSection from './_components/OllamaSection'
export default function MacMiniControlPage() {
const [status, setStatus] = useState<MacMiniStatus | null>(null)
@@ -203,57 +27,21 @@ export default function MacMiniControlPage() {
const [message, setMessage] = useState<string | null>(null)
const [downloadProgress, setDownloadProgress] = useState<DownloadProgress | null>(null)
const [modelInput, setModelInput] = useState('')
const [selectedModel, setSelectedModel] = useState<string | null>(null)
const [showRecommendations, setShowRecommendations] = useState(false)
const eventSourceRef = useRef<EventSource | null>(null)
// Get model info from database
const getModelInfo = (modelName: string): ModelDescription | null => {
// Try exact match first
if (MODEL_DATABASE[modelName]) return MODEL_DATABASE[modelName]
// Try base name (without tag)
const baseName = modelName.split(':')[0]
const matchingKey = Object.keys(MODEL_DATABASE).find(key =>
key.startsWith(baseName) || key === baseName
)
return matchingKey ? MODEL_DATABASE[matchingKey] : null
}
// Check if model is installed
const isModelInstalled = (modelName: string): boolean => {
if (!status?.models) return false
return status.models.some(m =>
m.name === modelName || m.name.startsWith(modelName.split(':')[0])
)
}
// API Endpoint (Mac Mini Backend or local proxy)
const API_BASE = 'http://192.168.178.100:8000/api/mac-mini'
// Fetch status
const fetchStatus = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await fetch(`${API_BASE}/status`)
const data = await response.json()
if (!response.ok) {
throw new Error(data.detail || `HTTP ${response.status}`)
}
if (!response.ok) throw new Error(data.detail || `HTTP ${response.status}`)
setStatus(data)
} catch (err) {
setError(err instanceof Error ? err.message : 'Verbindungsfehler')
setStatus({
online: false,
ping: false,
ssh: false,
docker: false,
ollama: false,
internet: false,
ip: '192.168.178.100',
online: false, ping: false, ssh: false, docker: false,
ollama: false, internet: false, ip: '192.168.178.100',
error: 'Verbindung fehlgeschlagen'
})
} finally {
@@ -261,161 +49,81 @@ export default function MacMiniControlPage() {
}
}, [])
// Initial load
useEffect(() => {
fetchStatus()
}, [fetchStatus])
// Auto-refresh every 30 seconds
useEffect(() => { fetchStatus() }, [fetchStatus])
useEffect(() => {
const interval = setInterval(fetchStatus, 30000)
return () => clearInterval(interval)
}, [fetchStatus])
// Wake on LAN
const wakeOnLan = async () => {
setActionLoading('wake')
const performAction = async (action: string, endpoint: string, confirmMsg?: string) => {
if (confirmMsg && !confirm(confirmMsg)) return
setActionLoading(action)
setError(null)
setMessage(null)
try {
const response = await fetch(`${API_BASE}/wake`, { method: 'POST' })
const response = await fetch(`${API_BASE}/${endpoint}`, { method: 'POST' })
const data = await response.json()
if (!response.ok) throw new Error(data.detail || `${action} fehlgeschlagen`)
return data
} catch (err) {
setError(err instanceof Error ? err.message : `Fehler bei ${action}`)
return null
} finally {
setActionLoading(null)
}
}
if (!response.ok) {
throw new Error(data.detail || 'Wake-on-LAN fehlgeschlagen')
}
const wakeOnLan = async () => {
const result = await performAction('wake', 'wake')
if (result) {
setMessage('Wake-on-LAN Paket gesendet')
setTimeout(fetchStatus, 5000)
setTimeout(fetchStatus, 15000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Aufwecken')
} finally {
setActionLoading(null)
}
}
// Restart
const restart = async () => {
if (!confirm('Mac Mini wirklich neu starten?')) return
setActionLoading('restart')
setError(null)
setMessage(null)
try {
const response = await fetch(`${API_BASE}/restart`, { method: 'POST' })
const data = await response.json()
if (!response.ok) {
throw new Error(data.detail || 'Neustart fehlgeschlagen')
}
const result = await performAction('restart', 'restart', 'Mac Mini wirklich neu starten?')
if (result) {
setMessage('Neustart eingeleitet')
setTimeout(fetchStatus, 30000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Neustart')
} finally {
setActionLoading(null)
}
}
// Shutdown
const shutdown = async () => {
if (!confirm('Mac Mini wirklich herunterfahren?')) return
setActionLoading('shutdown')
setError(null)
setMessage(null)
try {
const response = await fetch(`${API_BASE}/shutdown`, { method: 'POST' })
const data = await response.json()
if (!response.ok) {
throw new Error(data.detail || 'Shutdown fehlgeschlagen')
}
const result = await performAction('shutdown', 'shutdown', 'Mac Mini wirklich herunterfahren?')
if (result) {
setMessage('Shutdown eingeleitet')
setTimeout(fetchStatus, 10000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Herunterfahren')
} finally {
setActionLoading(null)
}
}
// Docker Up
const dockerUp = async () => {
setActionLoading('docker-up')
setError(null)
setMessage(null)
try {
const response = await fetch(`${API_BASE}/docker/up`, { method: 'POST' })
const data = await response.json()
if (!response.ok) {
throw new Error(data.detail || 'Docker Start fehlgeschlagen')
}
const result = await performAction('docker-up', 'docker/up')
if (result) {
setMessage('Docker Container werden gestartet...')
setTimeout(fetchStatus, 5000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Docker Start')
} finally {
setActionLoading(null)
}
}
// Docker Down
const dockerDown = async () => {
if (!confirm('Docker Container wirklich stoppen?')) return
setActionLoading('docker-down')
setError(null)
setMessage(null)
try {
const response = await fetch(`${API_BASE}/docker/down`, { method: 'POST' })
const data = await response.json()
if (!response.ok) {
throw new Error(data.detail || 'Docker Stop fehlgeschlagen')
}
const result = await performAction('docker-down', 'docker/down', 'Docker Container wirklich stoppen?')
if (result) {
setMessage('Docker Container werden gestoppt...')
setTimeout(fetchStatus, 5000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Docker Stop')
} finally {
setActionLoading(null)
}
}
// Pull Model with SSE Progress
const pullModel = async () => {
if (!modelInput.trim()) return
setActionLoading('pull')
setError(null)
setMessage(null)
setDownloadProgress({
model: modelInput,
status: 'starting',
completed: 0,
total: 0,
percent: 0
})
setDownloadProgress({ model: modelInput, status: 'starting', completed: 0, total: 0, percent: 0 })
try {
// Close any existing EventSource
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
if (eventSourceRef.current) eventSourceRef.current.close()
// Use fetch with streaming for progress
const response = await fetch(`${API_BASE}/ollama/pull`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -434,19 +142,15 @@ export default function MacMiniControlPage() {
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value)
const lines = text.split('\n').filter(line => line.trim())
for (const line of lines) {
try {
const data = JSON.parse(line)
if (data.status === 'downloading' && data.total) {
setDownloadProgress({
model: modelInput,
status: data.status,
completed: data.completed || 0,
total: data.total,
model: modelInput, status: data.status,
completed: data.completed || 0, total: data.total,
percent: Math.round((data.completed || 0) / data.total * 100)
})
} else if (data.status === 'success') {
@@ -457,9 +161,7 @@ export default function MacMiniControlPage() {
} else if (data.error) {
throw new Error(data.error)
}
} catch (e) {
// Skip parsing errors for incomplete chunks
}
} catch (e) { /* Skip parsing errors for incomplete chunks */ }
}
}
}
@@ -471,463 +173,37 @@ export default function MacMiniControlPage() {
}
}
// Format bytes
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
// Status badge styling
const getStatusBadge = (online: boolean) => {
return online
? 'px-3 py-1 rounded-full text-sm font-semibold bg-green-100 text-green-800'
: 'px-3 py-1 rounded-full text-sm font-semibold bg-red-100 text-red-800'
}
const getServiceStatus = (ok: boolean) => {
return ok
? 'flex items-center gap-2 text-green-600'
: 'flex items-center gap-2 text-red-500'
}
return (
<AdminLayout title="Mac Mini Control" description="Headless Server Management">
{/* Power Controls */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<div className="text-4xl">🖥</div>
<div>
<h2 className="text-xl font-bold text-slate-900">Mac Mini Headless</h2>
<p className="text-slate-500 text-sm">IP: {status?.ip || '192.168.178.100'}</p>
</div>
</div>
<span className={getStatusBadge(status?.online || false)}>
{loading ? 'Laden...' : status?.online ? 'Online' : 'Offline'}
</span>
</div>
<PowerControls
status={status}
loading={loading}
actionLoading={actionLoading}
message={message}
error={error}
onWake={wakeOnLan}
onRestart={restart}
onShutdown={shutdown}
onRefresh={fetchStatus}
/>
{/* Power Buttons */}
<div className="flex items-center gap-4 mb-6">
<button
onClick={wakeOnLan}
disabled={actionLoading !== null}
className="px-4 py-2 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{actionLoading === 'wake' ? '...' : '⚡ Wake on LAN'}
</button>
<button
onClick={restart}
disabled={actionLoading !== null || !status?.online}
className="px-4 py-2 bg-yellow-600 text-white rounded-lg font-medium hover:bg-yellow-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{actionLoading === 'restart' ? '...' : '🔄 Neustart'}
</button>
<button
onClick={shutdown}
disabled={actionLoading !== null || !status?.online}
className="px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{actionLoading === 'shutdown' ? '...' : '⏻ Herunterfahren'}
</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 ? '...' : '🔍 Status aktualisieren'}
</button>
<InternetStatus internet={status?.internet} />
{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>
<DockerSection
status={status}
actionLoading={actionLoading}
onDockerUp={dockerUp}
onDockerDown={dockerDown}
/>
{/* Service Status Grid */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-sm text-slate-500 mb-1">Ping</div>
<div className={getServiceStatus(status?.ping || false)}>
<span className={`w-2 h-2 rounded-full ${status?.ping ? 'bg-green-500' : 'bg-red-500'}`}></span>
{status?.ping ? 'Erreichbar' : 'Nicht erreichbar'}
</div>
</div>
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-sm text-slate-500 mb-1">SSH</div>
<div className={getServiceStatus(status?.ssh || false)}>
<span className={`w-2 h-2 rounded-full ${status?.ssh ? 'bg-green-500' : 'bg-red-500'}`}></span>
{status?.ssh ? 'Verbunden' : 'Getrennt'}
</div>
</div>
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-sm text-slate-500 mb-1">Docker</div>
<div className={getServiceStatus(status?.docker || false)}>
<span className={`w-2 h-2 rounded-full ${status?.docker ? 'bg-green-500' : 'bg-red-500'}`}></span>
{status?.docker ? 'Aktiv' : 'Inaktiv'}
</div>
</div>
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-sm text-slate-500 mb-1">Ollama</div>
<div className={getServiceStatus(status?.ollama || false)}>
<span className={`w-2 h-2 rounded-full ${status?.ollama ? 'bg-green-500' : 'bg-red-500'}`}></span>
{status?.ollama ? 'Bereit' : 'Nicht bereit'}
</div>
</div>
<div className="bg-slate-50 rounded-lg p-4">
<div className="text-sm text-slate-500 mb-1">Uptime</div>
<div className="font-semibold text-slate-700">
{status?.uptime || '-'}
</div>
</div>
</div>
</div>
{/* Internet Status Banner */}
<div className={`rounded-xl border p-4 mb-6 ${
status?.internet
? 'bg-green-50 border-green-200'
: 'bg-amber-50 border-amber-200'
}`}>
<div className="flex items-start justify-between">
<div className="flex gap-3">
<span className="text-2xl">{status?.internet ? '🌐' : '📴'}</span>
<div>
<h3 className={`font-semibold ${status?.internet ? 'text-green-900' : 'text-amber-900'}`}>
Internet: {status?.internet ? 'Verbunden' : 'Offline (Normalbetrieb)'}
</h3>
<p className={`text-sm mt-1 ${status?.internet ? 'text-green-700' : 'text-amber-700'}`}>
{status?.internet
? 'Mac Mini hat Internet-Zugang. LLM-Downloads und Updates möglich.'
: 'Mac Mini arbeitet offline. Für bestimmte Aktionen muss Internet aktiviert werden.'}
</p>
</div>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-semibold ${
status?.internet
? 'bg-green-100 text-green-800'
: 'bg-amber-100 text-amber-800'
}`}>
{status?.internet ? 'Online' : 'Offline'}
</span>
</div>
{/* Internet Required Actions - nur anzeigen wenn offline */}
{!status?.internet && (
<div className="mt-4 pt-4 border-t border-amber-200">
<h4 className="font-medium text-amber-900 mb-2"> Diese Aktionen benötigen Internet:</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{INTERNET_REQUIRED_ACTIONS.map((item, idx) => (
<div key={idx} className="flex items-start gap-2 text-sm">
<span className="text-amber-600 mt-0.5"></span>
<div>
<span className="font-medium text-amber-800">{item.action}</span>
<span className="text-amber-600 ml-1"> {item.description}</span>
</div>
</div>
))}
</div>
<p className="text-xs text-amber-600 mt-3 italic">
💡 Tipp: Internet am Router/Switch nur bei Bedarf für den Mac Mini aktivieren.
</p>
</div>
)}
</div>
{/* Docker Section */}
<div className="bg-white rounded-xl border border-slate-200 p-6 mb-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<span className="text-2xl">🐳</span> Docker Container
</h3>
<div className="flex gap-2">
<button
onClick={dockerUp}
disabled={actionLoading !== null || !status?.online}
className="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg font-medium hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{actionLoading === 'docker-up' ? '...' : '▶ Start'}
</button>
<button
onClick={dockerDown}
disabled={actionLoading !== null || !status?.online}
className="px-3 py-1.5 bg-red-600 text-white text-sm rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{actionLoading === 'docker-down' ? '...' : '⏹ Stop'}
</button>
</div>
</div>
{status?.containers && status.containers.length > 0 ? (
<div className="space-y-2">
{status.containers.map((container, idx) => (
<div key={idx} className="flex items-center justify-between bg-slate-50 rounded-lg p-3">
<div className="flex items-center gap-3">
<span className={`w-2 h-2 rounded-full ${
container.status.includes('Up') ? 'bg-green-500' : 'bg-red-500'
}`}></span>
<span className="font-medium text-slate-700">{container.name}</span>
</div>
<div className="flex items-center gap-4">
{container.ports && (
<span className="text-sm text-slate-500 font-mono">{container.ports}</span>
)}
<span className={`text-sm ${
container.status.includes('Up') ? 'text-green-600' : 'text-red-500'
}`}>
{container.status}
</span>
</div>
</div>
))}
</div>
) : (
<p className="text-slate-500 text-center py-4">
{status?.online ? 'Keine Container gefunden' : 'Server nicht erreichbar'}
</p>
)}
</div>
{/* Ollama Section */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<h3 className="font-semibold text-slate-900 flex items-center gap-2 mb-4">
<span className="text-2xl">🤖</span> Ollama LLM Modelle
</h3>
{/* Installed Models */}
{status?.models && status.models.length > 0 ? (
<div className="space-y-2 mb-6">
{status.models.map((model, idx) => {
const modelInfo = getModelInfo(model.name)
return (
<div key={idx} className="flex items-center justify-between bg-slate-50 rounded-lg p-3 hover:bg-slate-100 transition-colors">
<div className="flex items-center gap-3">
<span className="w-2 h-2 rounded-full bg-green-500"></span>
<span className="font-medium text-slate-700">{model.name}</span>
{modelInfo && (
<button
onClick={() => setSelectedModel(model.name)}
className="text-blue-500 hover:text-blue-700 transition-colors"
title="Modell-Info anzeigen"
>
<svg className="w-5 h-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>
</button>
)}
{modelInfo?.category === 'vision' && (
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full">Vision</span>
)}
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-slate-500">{model.size}</span>
<span className="text-sm text-slate-400">{model.modified}</span>
</div>
</div>
)
})}
</div>
) : (
<p className="text-slate-500 text-center py-4 mb-6">
{status?.ollama ? 'Keine Modelle installiert' : 'Ollama nicht erreichbar'}
</p>
)}
{/* Model Info Modal */}
{selectedModel && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setSelectedModel(null)}>
<div className="bg-white rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl" onClick={e => e.stopPropagation()}>
{(() => {
const info = getModelInfo(selectedModel)
if (!info) return <p>Keine Informationen verfügbar</p>
return (
<>
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-xl font-bold text-slate-900">{info.name}</h3>
<div className="flex items-center gap-2 mt-1">
<span className={`px-2 py-0.5 text-xs rounded-full ${
info.category === 'vision' ? 'bg-purple-100 text-purple-700' :
info.category === 'text' ? 'bg-blue-100 text-blue-700' :
'bg-slate-100 text-slate-700'
}`}>
{info.category === 'vision' ? '👁️ Vision' : info.category === 'text' ? '📝 Text' : info.category}
</span>
<span className="text-sm text-slate-500">{info.size}</span>
</div>
</div>
<button onClick={() => setSelectedModel(null)} className="text-slate-400 hover:text-slate-600">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-slate-600 mb-4">{info.description}</p>
<div>
<h4 className="font-medium text-slate-700 mb-2">Geeignet für:</h4>
<div className="flex flex-wrap gap-2">
{info.useCases.map((useCase, i) => (
<span key={i} className="px-3 py-1 bg-slate-100 text-slate-700 rounded-full text-sm">
{useCase}
</span>
))}
</div>
</div>
</>
)
})()}
</div>
</div>
)}
{/* Download New Model */}
<div className="border-t border-slate-200 pt-6">
<h4 className="font-medium text-slate-700 mb-3">Neues Modell herunterladen</h4>
<div className="flex gap-3 mb-4">
<input
type="text"
value={modelInput}
onChange={(e) => setModelInput(e.target.value)}
placeholder="z.B. llama3.2, mistral, qwen2.5:14b"
className="flex-1 px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
disabled={actionLoading === 'pull'}
/>
<button
onClick={pullModel}
disabled={actionLoading !== null || !status?.ollama || !modelInput.trim()}
className="px-6 py-2 bg-primary-600 text-white rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{actionLoading === 'pull' ? 'Lädt...' : 'Herunterladen'}
</button>
</div>
{/* Download Progress */}
{downloadProgress && (
<div className="bg-slate-50 rounded-lg p-4">
<div className="flex justify-between mb-2">
<span className="font-medium text-slate-700">{downloadProgress.model}</span>
<span className="text-sm text-slate-500">
{formatBytes(downloadProgress.completed)} / {formatBytes(downloadProgress.total)}
</span>
</div>
<div className="h-3 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary-500 to-primary-600 transition-all duration-300"
style={{ width: `${downloadProgress.percent}%` }}
></div>
</div>
<div className="text-center mt-2 text-sm font-medium text-slate-600">
{downloadProgress.percent}%
</div>
</div>
)}
{/* Toggle Recommendations */}
<button
onClick={() => setShowRecommendations(!showRecommendations)}
className="mt-4 text-primary-600 hover:text-primary-700 font-medium text-sm flex items-center gap-2"
>
<svg className={`w-4 h-4 transition-transform ${showRecommendations ? '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>
{showRecommendations ? 'Empfehlungen ausblenden' : 'Modell-Empfehlungen für Klausurkorrektur & Handschrift anzeigen'}
</button>
</div>
{/* Recommendations Section */}
{showRecommendations && (
<div className="border-t border-slate-200 pt-6 mt-6">
<h4 className="font-semibold text-slate-900 mb-4">📚 Empfohlene Modelle</h4>
{/* Handwriting Recognition */}
<div className="mb-6">
<h5 className="font-medium text-slate-700 flex items-center gap-2 mb-3">
<span className="text-lg"></span> Handschrifterkennung (Vision-Modelle)
</h5>
<div className="space-y-2">
{RECOMMENDED_MODELS.handwriting.map((rec, idx) => {
const info = MODEL_DATABASE[rec.model]
const installed = isModelInstalled(rec.model)
return (
<div key={idx} className={`flex items-center justify-between rounded-lg p-3 ${installed ? 'bg-green-50 border border-green-200' : 'bg-slate-50'}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-700">{info?.name || rec.model}</span>
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full">Vision</span>
{info?.recommended && <span className="px-2 py-0.5 text-xs bg-yellow-100 text-yellow-700 rounded-full"> Empfohlen</span>}
{installed && <span className="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full"> Installiert</span>}
</div>
<p className="text-sm text-slate-500 mt-1">{rec.reason}</p>
<p className="text-xs text-slate-400 mt-0.5">Größe: {info?.size || 'unbekannt'}</p>
</div>
{!installed && (
<button
onClick={() => { setModelInput(rec.model); pullModel() }}
disabled={actionLoading !== null || !status?.ollama}
className="ml-4 px-4 py-2 bg-primary-600 text-white text-sm rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Installieren
</button>
)}
</div>
)
})}
</div>
</div>
{/* Grading / Text Analysis */}
<div>
<h5 className="font-medium text-slate-700 flex items-center gap-2 mb-3">
<span className="text-lg">📝</span> Klausurkorrektur (Text-Modelle)
</h5>
<div className="space-y-2">
{RECOMMENDED_MODELS.grading.map((rec, idx) => {
const info = MODEL_DATABASE[rec.model]
const installed = isModelInstalled(rec.model)
return (
<div key={idx} className={`flex items-center justify-between rounded-lg p-3 ${installed ? 'bg-green-50 border border-green-200' : 'bg-slate-50'}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-700">{info?.name || rec.model}</span>
<span className="px-2 py-0.5 text-xs bg-blue-100 text-blue-700 rounded-full">Text</span>
{info?.recommended && <span className="px-2 py-0.5 text-xs bg-yellow-100 text-yellow-700 rounded-full"> Empfohlen</span>}
{installed && <span className="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full"> Installiert</span>}
</div>
<p className="text-sm text-slate-500 mt-1">{rec.reason}</p>
<p className="text-xs text-slate-400 mt-0.5">Größe: {info?.size || 'unbekannt'}</p>
</div>
{!installed && (
<button
onClick={() => { setModelInput(rec.model); pullModel() }}
disabled={actionLoading !== null || !status?.ollama}
className="ml-4 px-4 py-2 bg-primary-600 text-white text-sm rounded-lg font-medium hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Installieren
</button>
)}
</div>
)
})}
</div>
</div>
{/* Info Box */}
<div className="mt-6 bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex gap-3">
<span className="text-xl">💡</span>
<div>
<h5 className="font-medium text-amber-900">Tipp: Modell-Kombinationen</h5>
<p className="text-sm text-amber-800 mt-1">
Für beste Ergebnisse bei Klausuren mit Handschrift kombiniere ein <strong>Vision-Modell</strong> (für OCR/Handschrifterkennung)
mit einem <strong>Text-Modell</strong> (für Bewertung und Feedback). Beispiel: <code className="bg-amber-100 px-1 rounded">llama3.2-vision:11b</code> + <code className="bg-amber-100 px-1 rounded">qwen2.5:14b</code>
</p>
</div>
</div>
</div>
</div>
)}
</div>
<OllamaSection
status={status}
actionLoading={actionLoading}
downloadProgress={downloadProgress}
modelInput={modelInput}
setModelInput={setModelInput}
onPullModel={pullModel}
/>
{/* Info */}
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-xl p-4">

View File

@@ -0,0 +1,44 @@
export interface MacMiniStatus {
online: boolean
ping: boolean
ssh: boolean
docker: boolean
ollama: boolean
internet: boolean
ip: string
uptime?: string
cpu_load?: string
memory?: string
containers?: ContainerInfo[]
models?: ModelInfo[]
error?: string
}
export interface ContainerInfo {
name: string
status: string
ports?: string
}
export interface ModelInfo {
name: string
size: string
modified: string
}
export interface DownloadProgress {
model: string
status: string
completed: number
total: number
percent: number
}
export interface ModelDescription {
name: string
category: 'vision' | 'text' | 'code' | 'embedding'
size: string
description: string
useCases: string[]
recommended?: boolean
}

View File

@@ -0,0 +1,120 @@
'use client'
import { useState } from 'react'
export default function AISettingsTab() {
const [settings, setSettings] = useState({
autoAnalyze: true,
autoCreateTasks: true,
analysisModel: 'breakpilot-teacher-8b',
confidenceThreshold: 0.7,
})
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">KI-Einstellungen</h2>
<p className="text-sm text-slate-500">Konfigurieren Sie die automatische E-Mail-Analyse</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-6 space-y-6">
{/* Auto-Analyze */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-slate-900">Automatische Analyse</h3>
<p className="text-sm text-slate-500">E-Mails automatisch beim Empfang analysieren</p>
</div>
<button
onClick={() => setSettings({ ...settings, autoAnalyze: !settings.autoAnalyze })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
settings.autoAnalyze ? 'bg-primary-600' : 'bg-slate-200'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
settings.autoAnalyze ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Auto-Create Tasks */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-slate-900">Aufgaben automatisch erstellen</h3>
<p className="text-sm text-slate-500">Erkannte Fristen als Aufgaben anlegen</p>
</div>
<button
onClick={() => setSettings({ ...settings, autoCreateTasks: !settings.autoCreateTasks })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
settings.autoCreateTasks ? 'bg-primary-600' : 'bg-slate-200'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
settings.autoCreateTasks ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Model Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Analyse-Modell</label>
<select
value={settings.analysisModel}
onChange={(e) => setSettings({ ...settings, analysisModel: e.target.value })}
className="w-full md:w-64 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="breakpilot-teacher-8b">BreakPilot Teacher 8B (schnell)</option>
<option value="breakpilot-teacher-70b">BreakPilot Teacher 70B (genau)</option>
<option value="llama-3.1-8b-instruct">Llama 3.1 8B Instruct</option>
</select>
</div>
{/* Confidence Threshold */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Konfidenz-Schwelle: {Math.round(settings.confidenceThreshold * 100)}%
</label>
<input
type="range"
min="0.5"
max="0.95"
step="0.05"
value={settings.confidenceThreshold}
onChange={(e) => setSettings({ ...settings, confidenceThreshold: parseFloat(e.target.value) })}
className="w-full md:w-64"
/>
<p className="text-xs text-slate-500 mt-1">
Mindest-Konfidenz für automatische Aufgabenerstellung
</p>
</div>
</div>
{/* Sender Classification */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-700 mb-4">Bekannte Absender (Niedersachsen)</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{[
{ domain: '@mk.niedersachsen.de', type: 'Kultusministerium', priority: 'Hoch' },
{ domain: '@rlsb.de', type: 'RLSB', priority: 'Hoch' },
{ domain: '@landesschulbehoerde-nds.de', type: 'Landesschulbehörde', priority: 'Hoch' },
{ domain: '@nibis.de', type: 'NiBiS', priority: 'Mittel' },
{ domain: '@schultraeger.de', type: 'Schulträger', priority: 'Mittel' },
].map((sender) => (
<div key={sender.domain} className="p-3 bg-slate-50 rounded-lg">
<p className="text-sm font-mono text-slate-700">{sender.domain}</p>
<p className="text-xs text-slate-500">{sender.type}</p>
<span className={`text-xs px-2 py-0.5 rounded ${
sender.priority === 'Hoch' ? 'bg-red-100 text-red-700' : 'bg-yellow-100 text-yellow-700'
}`}>
{sender.priority}
</span>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,155 @@
'use client'
import { useState } from 'react'
import type { EmailAccount } from '../types'
import { API_BASE } from '../constants'
import AddAccountModal from './AddAccountModal'
export default function AccountsTab({
accounts,
loading,
onRefresh
}: {
accounts: EmailAccount[]
loading: boolean
onRefresh: () => void
}) {
const [showAddModal, setShowAddModal] = useState(false)
const testConnection = async (accountId: string) => {
try {
const res = await fetch(`${API_BASE}/api/v1/mail/accounts/${accountId}/test`, {
method: 'POST',
})
if (res.ok) {
alert('Verbindung erfolgreich!')
} else {
alert('Verbindungsfehler')
}
} catch (err) {
alert('Verbindungsfehler')
}
}
const statusColors = {
active: 'bg-green-100 text-green-800',
inactive: 'bg-gray-100 text-gray-800',
error: 'bg-red-100 text-red-800',
syncing: 'bg-yellow-100 text-yellow-800',
}
const statusLabels = {
active: 'Aktiv',
inactive: 'Inaktiv',
error: 'Fehler',
syncing: 'Synchronisiert...',
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Konten</h2>
<p className="text-sm text-slate-500">Verwalten Sie die verbundenen E-Mail-Konten</p>
</div>
<button
onClick={() => setShowAddModal(true)}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 flex items-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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Konto hinzufügen
</button>
</div>
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
)}
{/* Accounts Grid */}
{!loading && (
<div className="grid gap-4">
{accounts.length === 0 ? (
<div className="bg-slate-50 rounded-lg p-8 text-center">
<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="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<h3 className="text-lg font-medium text-slate-900 mb-2">Keine E-Mail-Konten</h3>
<p className="text-slate-500 mb-4">Fügen Sie Ihr erstes E-Mail-Konto hinzu.</p>
</div>
) : (
accounts.map((account) => (
<div
key={account.id}
className="bg-white rounded-lg border border-slate-200 p-6 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900">
{account.displayName || account.email}
</h3>
<p className="text-sm text-slate-500">{account.email}</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusColors[account.status]}`}>
{statusLabels[account.status]}
</span>
<button
onClick={() => testConnection(account.id)}
className="p-2 text-slate-400 hover:text-slate-600"
title="Verbindung testen"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">E-Mails</p>
<p className="text-lg font-semibold text-slate-900">{account.emailCount}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Ungelesen</p>
<p className="text-lg font-semibold text-slate-900">{account.unreadCount}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">IMAP</p>
<p className="text-sm font-mono text-slate-700">{account.imapHost}:{account.imapPort}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Letzte Sync</p>
<p className="text-sm text-slate-700">
{account.lastSync
? new Date(account.lastSync).toLocaleString('de-DE')
: 'Nie'}
</p>
</div>
</div>
</div>
))
)}
</div>
)}
{/* Add Account Modal */}
{showAddModal && (
<AddAccountModal onClose={() => setShowAddModal(false)} onSuccess={() => { setShowAddModal(false); onRefresh(); }} />
)}
</div>
)
}

View File

@@ -0,0 +1,185 @@
'use client'
import { useState } from 'react'
import { API_BASE } from '../constants'
export default function AddAccountModal({
onClose,
onSuccess
}: {
onClose: () => void
onSuccess: () => void
}) {
const [formData, setFormData] = useState({
email: '',
displayName: '',
imapHost: '',
imapPort: 993,
smtpHost: '',
smtpPort: 587,
username: '',
password: '',
})
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/api/v1/mail/accounts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.email,
display_name: formData.displayName,
imap_host: formData.imapHost,
imap_port: formData.imapPort,
smtp_host: formData.smtpHost,
smtp_port: formData.smtpPort,
username: formData.username,
password: formData.password,
}),
})
if (res.ok) {
onSuccess()
} else {
const data = await res.json()
setError(data.detail || 'Fehler beim Hinzufügen des Kontos')
}
} catch (err) {
setError('Netzwerkfehler')
} finally {
setSubmitting(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-slate-200">
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Konto hinzufügen</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">E-Mail-Adresse</label>
<input
type="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
placeholder="schulleitung@grundschule-xy.de"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Anzeigename</label>
<input
type="text"
value={formData.displayName}
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
placeholder="Schulleitung"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">IMAP Server</label>
<input
type="text"
required
value={formData.imapHost}
onChange={(e) => setFormData({ ...formData, imapHost: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
placeholder="imap.example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">IMAP Port</label>
<input
type="number"
required
value={formData.imapPort}
onChange={(e) => setFormData({ ...formData, imapPort: parseInt(e.target.value) })}
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">SMTP Server</label>
<input
type="text"
required
value={formData.smtpHost}
onChange={(e) => setFormData({ ...formData, smtpHost: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
placeholder="smtp.example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">SMTP Port</label>
<input
type="number"
required
value={formData.smtpPort}
onChange={(e) => setFormData({ ...formData, smtpPort: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Benutzername</label>
<input
type="text"
required
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Passwort</label>
<input
type="password"
required
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
<p className="text-xs text-slate-500 mt-1">
Das Passwort wird verschlüsselt in Vault gespeichert.
</p>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg"
>
Abbrechen
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{submitting ? 'Speichern...' : 'Konto hinzufügen'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,56 @@
'use client'
import { useState } from 'react'
export default function AuditLogTab() {
const [logs] = useState([
{ id: '1', action: 'account_created', user: 'admin@breakpilot.de', timestamp: new Date().toISOString(), details: 'Konto schulleitung@example.de hinzugefügt' },
{ id: '2', action: 'email_analyzed', user: 'system', timestamp: new Date(Date.now() - 3600000).toISOString(), details: '5 E-Mails analysiert' },
{ id: '3', action: 'task_created', user: 'system', timestamp: new Date(Date.now() - 7200000).toISOString(), details: 'Aufgabe aus Fristenerkennung erstellt' },
])
const actionLabels: Record<string, string> = {
account_created: 'Konto erstellt',
email_analyzed: 'E-Mail analysiert',
task_created: 'Aufgabe erstellt',
sync_completed: 'Sync abgeschlossen',
}
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">Audit-Log</h2>
<p className="text-sm text-slate-500">Alle Aktionen im Mail-System</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Zeit</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Aktion</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Benutzer</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{logs.map((log) => (
<tr key={log.id} className="hover:bg-slate-50">
<td className="px-6 py-4 text-sm text-slate-500">
{new Date(log.timestamp).toLocaleString('de-DE')}
</td>
<td className="px-6 py-4">
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded font-medium">
{actionLabels[log.action] || log.action}
</span>
</td>
<td className="px-6 py-4 text-sm text-slate-700">{log.user}</td>
<td className="px-6 py-4 text-sm text-slate-500">{log.details}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,174 @@
'use client'
import type { MailStats, SyncStatus } from '../types'
import { API_BASE } from '../constants'
function StatCard({
title,
value,
subtitle,
color = 'blue'
}: {
title: string
value: number
subtitle?: string
color?: 'blue' | 'green' | 'yellow' | 'red'
}) {
const colorClasses = {
blue: 'text-blue-600',
green: 'text-green-600',
yellow: 'text-yellow-600',
red: 'text-red-600',
}
return (
<div className="bg-white rounded-lg border border-slate-200 p-6">
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">{title}</p>
<p className={`text-3xl font-bold ${colorClasses[color]}`}>{value.toLocaleString()}</p>
{subtitle && <p className="text-sm text-slate-500 mt-1">{subtitle}</p>}
</div>
)
}
export default function OverviewTab({
stats,
syncStatus,
loading,
onRefresh
}: {
stats: MailStats | null
syncStatus: SyncStatus | null
loading: boolean
onRefresh: () => void
}) {
const triggerSync = async () => {
try {
await fetch(`${API_BASE}/api/v1/mail/sync/all`, {
method: 'POST',
})
onRefresh()
} catch (err) {
console.error('Failed to trigger sync:', err)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">System-Übersicht</h2>
<p className="text-sm text-slate-500">Status aller E-Mail-Konten und Aufgaben</p>
</div>
<div className="flex gap-3">
<button
onClick={onRefresh}
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
>
Aktualisieren
</button>
<button
onClick={triggerSync}
disabled={syncStatus?.running}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{syncStatus?.running ? 'Synchronisiert...' : 'Alle synchronisieren'}
</button>
</div>
</div>
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
)}
{/* Stats Grid */}
{!loading && stats && (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
title="E-Mail-Konten"
value={stats.totalAccounts}
subtitle={`${stats.activeAccounts} aktiv`}
color="blue"
/>
<StatCard
title="E-Mails gesamt"
value={stats.totalEmails}
subtitle={`${stats.unreadEmails} ungelesen`}
color="green"
/>
<StatCard
title="Aufgaben"
value={stats.totalTasks}
subtitle={`${stats.pendingTasks} offen`}
color="yellow"
/>
<StatCard
title="Überfällig"
value={stats.overdueTasks}
color={stats.overdueTasks > 0 ? 'red' : 'green'}
/>
</div>
{/* Sync Status */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-700 mb-4">Synchronisierung</h3>
<div className="flex items-center gap-4">
{syncStatus?.running ? (
<>
<div className="w-3 h-3 bg-yellow-500 rounded-full animate-pulse"></div>
<span className="text-slate-600">
Synchronisiere {syncStatus.accountsInProgress.length} Konto(en)...
</span>
</>
) : (
<>
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
<span className="text-slate-600">Bereit</span>
</>
)}
{stats.lastSyncTime && (
<span className="text-sm text-slate-500 ml-auto">
Letzte Sync: {new Date(stats.lastSyncTime).toLocaleString('de-DE')}
</span>
)}
</div>
{syncStatus?.errors && syncStatus.errors.length > 0 && (
<div className="mt-4 p-4 bg-red-50 rounded-lg">
<h4 className="text-sm font-medium text-red-800 mb-2">Fehler</h4>
<ul className="text-sm text-red-700 space-y-1">
{syncStatus.errors.slice(0, 3).map((error, i) => (
<li key={i}>{error}</li>
))}
</ul>
</div>
)}
</div>
{/* AI Stats */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-700 mb-4">KI-Analyse</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Analysiert</p>
<p className="text-2xl font-bold text-slate-900">{stats.aiAnalyzedCount}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Analyse-Rate</p>
<p className="text-2xl font-bold text-slate-900">
{stats.totalEmails > 0
? `${Math.round((stats.aiAnalyzedCount / stats.totalEmails) * 100)}%`
: '0%'}
</p>
</div>
</div>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,55 @@
'use client'
import { useState } from 'react'
export default function TemplatesTab() {
const [templates] = useState([
{ id: '1', name: 'Eingangsbestätigung', category: 'Standard', usageCount: 45 },
{ id: '2', name: 'Terminbestätigung', category: 'Termine', usageCount: 23 },
{ id: '3', name: 'Elternbrief-Vorlage', category: 'Eltern', usageCount: 67 },
])
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Vorlagen</h2>
<p className="text-sm text-slate-500">Verwalten Sie Antwort-Templates</p>
</div>
<button className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 flex items-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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Vorlage erstellen
</button>
</div>
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Kategorie</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Verwendet</th>
<th className="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{templates.map((template) => (
<tr key={template.id} className="hover:bg-slate-50">
<td className="px-6 py-4 text-sm font-medium text-slate-900">{template.name}</td>
<td className="px-6 py-4">
<span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded">{template.category}</span>
</td>
<td className="px-6 py-4 text-sm text-slate-500">{template.usageCount}x</td>
<td className="px-6 py-4 text-right">
<button className="text-primary-600 hover:text-primary-800 text-sm font-medium">Bearbeiten</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,53 @@
import type { TabId } from './types'
// API Base URL for klausur-service
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
// Tab definitions
export const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
{
id: 'overview',
name: 'Übersicht',
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: 'accounts',
name: 'Konten',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
),
},
{
id: 'ai-settings',
name: 'KI-Einstellungen',
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.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>
),
},
{
id: 'templates',
name: 'Vorlagen',
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 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>
),
},
{
id: 'logs',
name: 'Audit-Log',
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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
),
},
]

View File

@@ -11,97 +11,14 @@
import { useState, useEffect, useCallback } from 'react'
import AdminLayout from '@/components/admin/AdminLayout'
import type { MailStats, SyncStatus, EmailAccount, TabId } from './types'
import { API_BASE, tabs } from './constants'
import OverviewTab from './_components/OverviewTab'
import AccountsTab from './_components/AccountsTab'
import AISettingsTab from './_components/AISettingsTab'
import TemplatesTab from './_components/TemplatesTab'
import AuditLogTab from './_components/AuditLogTab'
// API Base URL for klausur-service
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
// Types
interface EmailAccount {
id: string
email: string
displayName: string
imapHost: string
imapPort: number
smtpHost: string
smtpPort: number
status: 'active' | 'inactive' | 'error' | 'syncing'
lastSync: string | null
emailCount: number
unreadCount: number
createdAt: string
}
interface MailStats {
totalAccounts: number
activeAccounts: number
totalEmails: number
unreadEmails: number
totalTasks: number
pendingTasks: number
overdueTasks: number
aiAnalyzedCount: number
lastSyncTime: string | null
}
interface SyncStatus {
running: boolean
accountsInProgress: string[]
lastCompleted: string | null
errors: string[]
}
// Tab definitions
type TabId = 'overview' | 'accounts' | 'ai-settings' | 'templates' | 'logs'
const tabs: { id: TabId; name: string; icon: JSX.Element }[] = [
{
id: 'overview',
name: 'Übersicht',
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: 'accounts',
name: 'Konten',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
),
},
{
id: 'ai-settings',
name: 'KI-Einstellungen',
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.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>
),
},
{
id: 'templates',
name: 'Vorlagen',
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 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>
),
},
{
id: 'logs',
name: 'Audit-Log',
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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
),
},
]
// Main Component
export default function MailAdminPage() {
const [activeTab, setActiveTab] = useState<TabId>('overview')
const [stats, setStats] = useState<MailStats | null>(null)
@@ -241,745 +158,3 @@ export default function MailAdminPage() {
</AdminLayout>
)
}
// ============================================================================
// Overview Tab
// ============================================================================
function OverviewTab({
stats,
syncStatus,
loading,
onRefresh
}: {
stats: MailStats | null
syncStatus: SyncStatus | null
loading: boolean
onRefresh: () => void
}) {
const triggerSync = async () => {
try {
await fetch(`${API_BASE}/api/v1/mail/sync/all`, {
method: 'POST',
})
onRefresh()
} catch (err) {
console.error('Failed to trigger sync:', err)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">System-Übersicht</h2>
<p className="text-sm text-slate-500">Status aller E-Mail-Konten und Aufgaben</p>
</div>
<div className="flex gap-3">
<button
onClick={onRefresh}
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
>
Aktualisieren
</button>
<button
onClick={triggerSync}
disabled={syncStatus?.running}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{syncStatus?.running ? 'Synchronisiert...' : 'Alle synchronisieren'}
</button>
</div>
</div>
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
)}
{/* Stats Grid */}
{!loading && stats && (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
title="E-Mail-Konten"
value={stats.totalAccounts}
subtitle={`${stats.activeAccounts} aktiv`}
color="blue"
/>
<StatCard
title="E-Mails gesamt"
value={stats.totalEmails}
subtitle={`${stats.unreadEmails} ungelesen`}
color="green"
/>
<StatCard
title="Aufgaben"
value={stats.totalTasks}
subtitle={`${stats.pendingTasks} offen`}
color="yellow"
/>
<StatCard
title="Überfällig"
value={stats.overdueTasks}
color={stats.overdueTasks > 0 ? 'red' : 'green'}
/>
</div>
{/* Sync Status */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-700 mb-4">Synchronisierung</h3>
<div className="flex items-center gap-4">
{syncStatus?.running ? (
<>
<div className="w-3 h-3 bg-yellow-500 rounded-full animate-pulse"></div>
<span className="text-slate-600">
Synchronisiere {syncStatus.accountsInProgress.length} Konto(en)...
</span>
</>
) : (
<>
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
<span className="text-slate-600">Bereit</span>
</>
)}
{stats.lastSyncTime && (
<span className="text-sm text-slate-500 ml-auto">
Letzte Sync: {new Date(stats.lastSyncTime).toLocaleString('de-DE')}
</span>
)}
</div>
{syncStatus?.errors && syncStatus.errors.length > 0 && (
<div className="mt-4 p-4 bg-red-50 rounded-lg">
<h4 className="text-sm font-medium text-red-800 mb-2">Fehler</h4>
<ul className="text-sm text-red-700 space-y-1">
{syncStatus.errors.slice(0, 3).map((error, i) => (
<li key={i}>{error}</li>
))}
</ul>
</div>
)}
</div>
{/* AI Stats */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-700 mb-4">KI-Analyse</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Analysiert</p>
<p className="text-2xl font-bold text-slate-900">{stats.aiAnalyzedCount}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Analyse-Rate</p>
<p className="text-2xl font-bold text-slate-900">
{stats.totalEmails > 0
? `${Math.round((stats.aiAnalyzedCount / stats.totalEmails) * 100)}%`
: '0%'}
</p>
</div>
</div>
</div>
</>
)}
</div>
)
}
function StatCard({
title,
value,
subtitle,
color = 'blue'
}: {
title: string
value: number
subtitle?: string
color?: 'blue' | 'green' | 'yellow' | 'red'
}) {
const colorClasses = {
blue: 'text-blue-600',
green: 'text-green-600',
yellow: 'text-yellow-600',
red: 'text-red-600',
}
return (
<div className="bg-white rounded-lg border border-slate-200 p-6">
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">{title}</p>
<p className={`text-3xl font-bold ${colorClasses[color]}`}>{value.toLocaleString()}</p>
{subtitle && <p className="text-sm text-slate-500 mt-1">{subtitle}</p>}
</div>
)
}
// ============================================================================
// Accounts Tab
// ============================================================================
function AccountsTab({
accounts,
loading,
onRefresh
}: {
accounts: EmailAccount[]
loading: boolean
onRefresh: () => void
}) {
const [showAddModal, setShowAddModal] = useState(false)
const testConnection = async (accountId: string) => {
try {
const res = await fetch(`${API_BASE}/api/v1/mail/accounts/${accountId}/test`, {
method: 'POST',
})
if (res.ok) {
alert('Verbindung erfolgreich!')
} else {
alert('Verbindungsfehler')
}
} catch (err) {
alert('Verbindungsfehler')
}
}
const statusColors = {
active: 'bg-green-100 text-green-800',
inactive: 'bg-gray-100 text-gray-800',
error: 'bg-red-100 text-red-800',
syncing: 'bg-yellow-100 text-yellow-800',
}
const statusLabels = {
active: 'Aktiv',
inactive: 'Inaktiv',
error: 'Fehler',
syncing: 'Synchronisiert...',
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Konten</h2>
<p className="text-sm text-slate-500">Verwalten Sie die verbundenen E-Mail-Konten</p>
</div>
<button
onClick={() => setShowAddModal(true)}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 flex items-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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Konto hinzufügen
</button>
</div>
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
)}
{/* Accounts Grid */}
{!loading && (
<div className="grid gap-4">
{accounts.length === 0 ? (
<div className="bg-slate-50 rounded-lg p-8 text-center">
<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="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<h3 className="text-lg font-medium text-slate-900 mb-2">Keine E-Mail-Konten</h3>
<p className="text-slate-500 mb-4">Fügen Sie Ihr erstes E-Mail-Konto hinzu.</p>
</div>
) : (
accounts.map((account) => (
<div
key={account.id}
className="bg-white rounded-lg border border-slate-200 p-6 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-primary-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<h3 className="text-lg font-semibold text-slate-900">
{account.displayName || account.email}
</h3>
<p className="text-sm text-slate-500">{account.email}</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className={`px-3 py-1 rounded-full text-xs font-medium ${statusColors[account.status]}`}>
{statusLabels[account.status]}
</span>
<button
onClick={() => testConnection(account.id)}
className="p-2 text-slate-400 hover:text-slate-600"
title="Verbindung testen"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">E-Mails</p>
<p className="text-lg font-semibold text-slate-900">{account.emailCount}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Ungelesen</p>
<p className="text-lg font-semibold text-slate-900">{account.unreadCount}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">IMAP</p>
<p className="text-sm font-mono text-slate-700">{account.imapHost}:{account.imapPort}</p>
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider">Letzte Sync</p>
<p className="text-sm text-slate-700">
{account.lastSync
? new Date(account.lastSync).toLocaleString('de-DE')
: 'Nie'}
</p>
</div>
</div>
</div>
))
)}
</div>
)}
{/* Add Account Modal */}
{showAddModal && (
<AddAccountModal onClose={() => setShowAddModal(false)} onSuccess={() => { setShowAddModal(false); onRefresh(); }} />
)}
</div>
)
}
function AddAccountModal({
onClose,
onSuccess
}: {
onClose: () => void
onSuccess: () => void
}) {
const [formData, setFormData] = useState({
email: '',
displayName: '',
imapHost: '',
imapPort: 993,
smtpHost: '',
smtpPort: 587,
username: '',
password: '',
})
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
setError(null)
try {
const res = await fetch(`${API_BASE}/api/v1/mail/accounts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: formData.email,
display_name: formData.displayName,
imap_host: formData.imapHost,
imap_port: formData.imapPort,
smtp_host: formData.smtpHost,
smtp_port: formData.smtpPort,
username: formData.username,
password: formData.password,
}),
})
if (res.ok) {
onSuccess()
} else {
const data = await res.json()
setError(data.detail || 'Fehler beim Hinzufügen des Kontos')
}
} catch (err) {
setError('Netzwerkfehler')
} finally {
setSubmitting(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-slate-200">
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Konto hinzufügen</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">E-Mail-Adresse</label>
<input
type="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
placeholder="schulleitung@grundschule-xy.de"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Anzeigename</label>
<input
type="text"
value={formData.displayName}
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
placeholder="Schulleitung"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">IMAP Server</label>
<input
type="text"
required
value={formData.imapHost}
onChange={(e) => setFormData({ ...formData, imapHost: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
placeholder="imap.example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">IMAP Port</label>
<input
type="number"
required
value={formData.imapPort}
onChange={(e) => setFormData({ ...formData, imapPort: parseInt(e.target.value) })}
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">SMTP Server</label>
<input
type="text"
required
value={formData.smtpHost}
onChange={(e) => setFormData({ ...formData, smtpHost: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
placeholder="smtp.example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">SMTP Port</label>
<input
type="number"
required
value={formData.smtpPort}
onChange={(e) => setFormData({ ...formData, smtpPort: parseInt(e.target.value) })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Benutzername</label>
<input
type="text"
required
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-slate-700 mb-1">Passwort</label>
<input
type="password"
required
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
/>
<p className="text-xs text-slate-500 mt-1">
Das Passwort wird verschlüsselt in Vault gespeichert.
</p>
</div>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-slate-200">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 rounded-lg"
>
Abbrechen
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 disabled:opacity-50"
>
{submitting ? 'Speichern...' : 'Konto hinzufügen'}
</button>
</div>
</form>
</div>
</div>
)
}
// ============================================================================
// AI Settings Tab
// ============================================================================
function AISettingsTab() {
const [settings, setSettings] = useState({
autoAnalyze: true,
autoCreateTasks: true,
analysisModel: 'breakpilot-teacher-8b',
confidenceThreshold: 0.7,
})
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">KI-Einstellungen</h2>
<p className="text-sm text-slate-500">Konfigurieren Sie die automatische E-Mail-Analyse</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 p-6 space-y-6">
{/* Auto-Analyze */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-slate-900">Automatische Analyse</h3>
<p className="text-sm text-slate-500">E-Mails automatisch beim Empfang analysieren</p>
</div>
<button
onClick={() => setSettings({ ...settings, autoAnalyze: !settings.autoAnalyze })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
settings.autoAnalyze ? 'bg-primary-600' : 'bg-slate-200'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
settings.autoAnalyze ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Auto-Create Tasks */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-medium text-slate-900">Aufgaben automatisch erstellen</h3>
<p className="text-sm text-slate-500">Erkannte Fristen als Aufgaben anlegen</p>
</div>
<button
onClick={() => setSettings({ ...settings, autoCreateTasks: !settings.autoCreateTasks })}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
settings.autoCreateTasks ? 'bg-primary-600' : 'bg-slate-200'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
settings.autoCreateTasks ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Model Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">Analyse-Modell</label>
<select
value={settings.analysisModel}
onChange={(e) => setSettings({ ...settings, analysisModel: e.target.value })}
className="w-full md:w-64 px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="breakpilot-teacher-8b">BreakPilot Teacher 8B (schnell)</option>
<option value="breakpilot-teacher-70b">BreakPilot Teacher 70B (genau)</option>
<option value="llama-3.1-8b-instruct">Llama 3.1 8B Instruct</option>
</select>
</div>
{/* Confidence Threshold */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Konfidenz-Schwelle: {Math.round(settings.confidenceThreshold * 100)}%
</label>
<input
type="range"
min="0.5"
max="0.95"
step="0.05"
value={settings.confidenceThreshold}
onChange={(e) => setSettings({ ...settings, confidenceThreshold: parseFloat(e.target.value) })}
className="w-full md:w-64"
/>
<p className="text-xs text-slate-500 mt-1">
Mindest-Konfidenz für automatische Aufgabenerstellung
</p>
</div>
</div>
{/* Sender Classification */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
<h3 className="text-sm font-medium text-slate-700 mb-4">Bekannte Absender (Niedersachsen)</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{[
{ domain: '@mk.niedersachsen.de', type: 'Kultusministerium', priority: 'Hoch' },
{ domain: '@rlsb.de', type: 'RLSB', priority: 'Hoch' },
{ domain: '@landesschulbehoerde-nds.de', type: 'Landesschulbehörde', priority: 'Hoch' },
{ domain: '@nibis.de', type: 'NiBiS', priority: 'Mittel' },
{ domain: '@schultraeger.de', type: 'Schulträger', priority: 'Mittel' },
].map((sender) => (
<div key={sender.domain} className="p-3 bg-slate-50 rounded-lg">
<p className="text-sm font-mono text-slate-700">{sender.domain}</p>
<p className="text-xs text-slate-500">{sender.type}</p>
<span className={`text-xs px-2 py-0.5 rounded ${
sender.priority === 'Hoch' ? 'bg-red-100 text-red-700' : 'bg-yellow-100 text-yellow-700'
}`}>
{sender.priority}
</span>
</div>
))}
</div>
</div>
</div>
)
}
// ============================================================================
// Templates Tab
// ============================================================================
function TemplatesTab() {
const [templates] = useState([
{ id: '1', name: 'Eingangsbestätigung', category: 'Standard', usageCount: 45 },
{ id: '2', name: 'Terminbestätigung', category: 'Termine', usageCount: 23 },
{ id: '3', name: 'Elternbrief-Vorlage', category: 'Eltern', usageCount: 67 },
])
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">E-Mail-Vorlagen</h2>
<p className="text-sm text-slate-500">Verwalten Sie Antwort-Templates</p>
</div>
<button className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-lg hover:bg-primary-700 flex items-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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Vorlage erstellen
</button>
</div>
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Kategorie</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Verwendet</th>
<th className="px-6 py-3 text-right text-xs font-medium text-slate-500 uppercase">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{templates.map((template) => (
<tr key={template.id} className="hover:bg-slate-50">
<td className="px-6 py-4 text-sm font-medium text-slate-900">{template.name}</td>
<td className="px-6 py-4">
<span className="px-2 py-1 bg-slate-100 text-slate-700 text-xs rounded">{template.category}</span>
</td>
<td className="px-6 py-4 text-sm text-slate-500">{template.usageCount}x</td>
<td className="px-6 py-4 text-right">
<button className="text-primary-600 hover:text-primary-800 text-sm font-medium">Bearbeiten</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
// ============================================================================
// Audit Log Tab
// ============================================================================
function AuditLogTab() {
const [logs] = useState([
{ id: '1', action: 'account_created', user: 'admin@breakpilot.de', timestamp: new Date().toISOString(), details: 'Konto schulleitung@example.de hinzugefügt' },
{ id: '2', action: 'email_analyzed', user: 'system', timestamp: new Date(Date.now() - 3600000).toISOString(), details: '5 E-Mails analysiert' },
{ id: '3', action: 'task_created', user: 'system', timestamp: new Date(Date.now() - 7200000).toISOString(), details: 'Aufgabe aus Fristenerkennung erstellt' },
])
const actionLabels: Record<string, string> = {
account_created: 'Konto erstellt',
email_analyzed: 'E-Mail analysiert',
task_created: 'Aufgabe erstellt',
sync_completed: 'Sync abgeschlossen',
}
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-slate-900">Audit-Log</h2>
<p className="text-sm text-slate-500">Alle Aktionen im Mail-System</p>
</div>
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden">
<table className="min-w-full divide-y divide-slate-200">
<thead className="bg-slate-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Zeit</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Aktion</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Benutzer</th>
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase">Details</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{logs.map((log) => (
<tr key={log.id} className="hover:bg-slate-50">
<td className="px-6 py-4 text-sm text-slate-500">
{new Date(log.timestamp).toLocaleString('de-DE')}
</td>
<td className="px-6 py-4">
<span className="px-2 py-1 bg-blue-100 text-blue-700 text-xs rounded font-medium">
{actionLabels[log.action] || log.action}
</span>
</td>
<td className="px-6 py-4 text-sm text-slate-700">{log.user}</td>
<td className="px-6 py-4 text-sm text-slate-500">{log.details}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,35 @@
export interface EmailAccount {
id: string
email: string
displayName: string
imapHost: string
imapPort: number
smtpHost: string
smtpPort: number
status: 'active' | 'inactive' | 'error' | 'syncing'
lastSync: string | null
emailCount: number
unreadCount: number
createdAt: string
}
export interface MailStats {
totalAccounts: number
activeAccounts: number
totalEmails: number
unreadEmails: number
totalTasks: number
pendingTasks: number
overdueTasks: number
aiAnalyzedCount: number
lastSyncTime: string | null
}
export interface SyncStatus {
running: boolean
accountsInProgress: string[]
lastCompleted: string | null
errors: string[]
}
export type TabId = 'overview' | 'accounts' | 'ai-settings' | 'templates' | 'logs'

View File

@@ -0,0 +1,113 @@
'use client'
import { useState } from 'react'
import type { OCRSession, OCRStats } from '../types'
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
export default function ExportTab({
sessions,
selectedSession,
setSelectedSession,
stats,
onError,
}: {
sessions: OCRSession[]
selectedSession: string | null
setSelectedSession: (id: string | null) => void
stats: OCRStats | null
onError: (msg: string) => void
}) {
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 {
onError('Export fehlgeschlagen')
}
} catch (err) {
onError('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>
</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>
)
}

View File

@@ -0,0 +1,192 @@
'use client'
import type { OCRItem } from '../types'
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
export default function LabelingTab({
queue,
currentItem,
currentIndex,
correctedText,
setCorrectedText,
onGoToPrev,
onGoToNext,
onConfirm,
onCorrect,
onSkip,
onSelectItem,
}: {
queue: OCRItem[]
currentItem: OCRItem | null
currentIndex: number
correctedText: string
setCorrectedText: (text: string) => void
onGoToPrev: () => void
onGoToNext: () => void
onConfirm: () => void
onCorrect: () => void
onSkip: () => void
onSelectItem: (item: OCRItem, index: number) => void
}) {
return (
<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={onGoToPrev}
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={onGoToNext}
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) => {
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={onConfirm}
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={onCorrect}
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={onSkip}
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={() => onSelectItem(item, idx)}
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>
)
}

View File

@@ -0,0 +1,160 @@
'use client'
import { useState } from 'react'
import type { OCRSession, CreateSessionRequest, OCRModel } from '../types'
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
export default function SessionsTab({
sessions,
selectedSession,
setSelectedSession,
onSessionCreated,
onError,
}: {
sessions: OCRSession[]
selectedSession: string | null
setSelectedSession: (id: string | null) => void
onSessionCreated: () => void
onError: (msg: string) => void
}) {
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' })
onSessionCreated()
} else {
onError('Session erstellen fehlgeschlagen')
}
} catch (err) {
onError('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>
)
}

View File

@@ -0,0 +1,68 @@
'use client'
import type { OCRStats } from '../types'
export default function StatsTab({ stats }: { stats: OCRStats | null }) {
return (
<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>
)
}

View File

@@ -0,0 +1,152 @@
'use client'
import { useState, useRef } from 'react'
import type { OCRSession } from '../types'
const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
export default function UploadTab({
sessions,
selectedSession,
setSelectedSession,
onUploadComplete,
onError,
}: {
sessions: OCRSession[]
selectedSession: string | null
setSelectedSession: (id: string | null) => void
onUploadComplete: () => void
onError: (msg: string) => void
}) {
const [uploading, setUploading] = useState(false)
const [uploadResults, setUploadResults] = useState<any[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const handleUpload = async (files: FileList) => {
if (!selectedSession) {
onError('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 || [])
onUploadComplete()
} else {
onError('Upload fehlgeschlagen')
}
} catch (err) {
onError('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>
)
}

View File

@@ -0,0 +1,11 @@
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
export type TabId = 'labeling' | 'sessions' | 'upload' | 'stats' | 'export'
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> },
]

View File

@@ -7,70 +7,16 @@
* DSGVO-konform: Alle Verarbeitung lokal auf Mac Mini (Ollama).
*/
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useCallback } from 'react'
import AdminLayout from '@/components/admin/AdminLayout'
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>
),
},
]
import type { OCRSession, OCRItem, OCRStats } from './types'
import { API_BASE, tabs } from './constants'
import type { TabId } from './constants'
import LabelingTab from './_components/LabelingTab'
import SessionsTab from './_components/SessionsTab'
import UploadTab from './_components/UploadTab'
import StatsTab from './_components/StatsTab'
import ExportTab from './_components/ExportTab'
export default function OCRLabelingPage() {
const [activeTab, setActiveTab] = useState<TabId>('labeling')
@@ -85,819 +31,65 @@ export default function OCRLabelingPage() {
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)
}
try { const r = await fetch(`${API_BASE}/api/v1/ocr-label/sessions`); if (r.ok) setSessions(await r.json()) }
catch (e) { console.error('Failed to fetch sessions:', e) }
}, [])
// 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())
}
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 r = await fetch(url)
if (r.ok) {
const data = await r.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)
}
} catch (e) { console.error('Failed to fetch queue:', e) }
}, [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)
}
const url = selectedSession ? `${API_BASE}/api/v1/ocr-label/stats?session_id=${selectedSession}` : `${API_BASE}/api/v1/ocr-label/stats`
const r = await fetch(url); if (r.ok) setStats(await r.json())
} catch (e) { console.error('Failed to fetch stats:', e) }
}, [selectedSession])
// Initial data load
useEffect(() => {
const loadData = async () => {
setLoading(true)
await Promise.all([fetchSessions(), fetchQueue(), fetchStats()])
setLoading(false)
}
loadData()
}, [fetchSessions, fetchQueue, fetchStats])
useEffect(() => { setLoading(true); Promise.all([fetchSessions(), fetchQueue(), fetchStats()]).then(() => setLoading(false)) }, [fetchSessions, fetchQueue, fetchStats])
useEffect(() => { setCurrentItem(null); setCurrentIndex(0); fetchQueue(); fetchStats() }, [selectedSession, fetchQueue, fetchStats])
// Refresh queue when session changes
useEffect(() => {
setCurrentItem(null)
setCurrentIndex(0)
fetchQueue()
fetchStats()
}, [selectedSession, fetchQueue, fetchStats])
const getLabelTime = () => labelStartTime ? Math.round((Date.now() - labelStartTime) / 1000) : undefined
const setItem = (item: OCRItem, idx: number) => { setCurrentIndex(idx); setCurrentItem(item); setCorrectedText(item.ocr_text || ''); setLabelStartTime(Date.now()) }
// 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()
}
if (currentIndex < queue.length - 1) setItem(queue[currentIndex + 1], currentIndex + 1)
else fetchQueue()
}
const goToPrev = () => { if (currentIndex > 0) setItem(queue[currentIndex - 1], currentIndex - 1) }
const postAction = async (endpoint: string, body: object) => {
const r = await fetch(`${API_BASE}/api/v1/ocr-label/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
if (r.ok) { setQueue(prev => prev.filter(i => i.id !== currentItem?.id)); goToNext(); fetchStats() }
else setError(`${endpoint} fehlgeschlagen`)
}
// 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())
}
}
const confirmItem = () => { if (currentItem) postAction('confirm', { item_id: currentItem.id, label_time_seconds: getLabelTime() }).catch(() => setError('Netzwerkfehler')) }
const correctItem = () => { if (currentItem && correctedText.trim()) postAction('correct', { item_id: currentItem.id, ground_truth: correctedText.trim(), label_time_seconds: getLabelTime() }).catch(() => setError('Netzwerkfehler')) }
const skipItem = () => { if (currentItem) postAction('skip', { item_id: currentItem.id }).catch(() => setError('Netzwerkfehler')) }
// 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
const h = (e: KeyboardEvent) => {
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()
}
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)
window.addEventListener('keydown', h); return () => window.removeEventListener('keydown', h)
}, [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>
</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 (
<AdminLayout
title="OCR-Labeling"
description="Handschrift-Training & Ground Truth Erfassung"
>
{/* Error Toast */}
<AdminLayout title="OCR-Labeling" description="Handschrift-Training & Ground Truth Erfassung">
{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>
@@ -905,40 +97,25 @@ export default function OCRLabelingPage() {
</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 className="mb-6 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>
{/* 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>
<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()}
{activeTab === 'labeling' && <LabelingTab queue={queue} currentItem={currentItem} currentIndex={currentIndex} correctedText={correctedText} setCorrectedText={setCorrectedText} onGoToPrev={goToPrev} onGoToNext={goToNext} onConfirm={confirmItem} onCorrect={correctItem} onSkip={skipItem} onSelectItem={setItem} />}
{activeTab === 'sessions' && <SessionsTab sessions={sessions} selectedSession={selectedSession} setSelectedSession={setSelectedSession} onSessionCreated={fetchSessions} onError={setError} />}
{activeTab === 'upload' && <UploadTab sessions={sessions} selectedSession={selectedSession} setSelectedSession={setSelectedSession} onUploadComplete={() => { fetchQueue(); fetchStats() }} onError={setError} />}
{activeTab === 'stats' && <StatsTab stats={stats} />}
{activeTab === 'export' && <ExportTab sessions={sessions} selectedSession={selectedSession} setSelectedSession={setSelectedSession} stats={stats} onError={setError} />}
</>
)}
</AdminLayout>