[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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
75
website/app/admin/compliance/audit-workspace/types.ts
Normal file
75
website/app/admin/compliance/audit-workspace/types.ts
Normal 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' },
|
||||
}
|
||||
99
website/app/admin/edu-search/_components/CrawlTab.tsx
Normal file
99
website/app/admin/edu-search/_components/CrawlTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
website/app/admin/edu-search/_components/RulesTab.tsx
Normal file
37
website/app/admin/edu-search/_components/RulesTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
190
website/app/admin/edu-search/_components/SeedModal.tsx
Normal file
190
website/app/admin/edu-search/_components/SeedModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
200
website/app/admin/edu-search/_components/SeedsTab.tsx
Normal file
200
website/app/admin/edu-search/_components/SeedsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
website/app/admin/edu-search/_components/StatsTab.tsx
Normal file
88
website/app/admin/edu-search/_components/StatsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
102
website/app/admin/edu-search/types.ts
Normal file
102
website/app/admin/edu-search/types.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
171
website/app/admin/edu-search/useEduSearchData.ts
Normal file
171
website/app/admin/edu-search/useEduSearchData.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
70
website/app/admin/mac-mini/_components/DockerSection.tsx
Normal file
70
website/app/admin/mac-mini/_components/DockerSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
website/app/admin/mac-mini/_components/InternetStatus.tsx
Normal file
56
website/app/admin/mac-mini/_components/InternetStatus.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
289
website/app/admin/mac-mini/_components/OllamaSection.tsx
Normal file
289
website/app/admin/mac-mini/_components/OllamaSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
127
website/app/admin/mac-mini/_components/PowerControls.tsx
Normal file
127
website/app/admin/mac-mini/_components/PowerControls.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
105
website/app/admin/mac-mini/constants.ts
Normal file
105
website/app/admin/mac-mini/constants.ts
Normal 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' },
|
||||
]
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
44
website/app/admin/mac-mini/types.ts
Normal file
44
website/app/admin/mac-mini/types.ts
Normal 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
|
||||
}
|
||||
120
website/app/admin/mail/_components/AISettingsTab.tsx
Normal file
120
website/app/admin/mail/_components/AISettingsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
155
website/app/admin/mail/_components/AccountsTab.tsx
Normal file
155
website/app/admin/mail/_components/AccountsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
185
website/app/admin/mail/_components/AddAccountModal.tsx
Normal file
185
website/app/admin/mail/_components/AddAccountModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
website/app/admin/mail/_components/AuditLogTab.tsx
Normal file
56
website/app/admin/mail/_components/AuditLogTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
174
website/app/admin/mail/_components/OverviewTab.tsx
Normal file
174
website/app/admin/mail/_components/OverviewTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
55
website/app/admin/mail/_components/TemplatesTab.tsx
Normal file
55
website/app/admin/mail/_components/TemplatesTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
53
website/app/admin/mail/constants.ts
Normal file
53
website/app/admin/mail/constants.ts
Normal 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>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
35
website/app/admin/mail/types.ts
Normal file
35
website/app/admin/mail/types.ts
Normal 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'
|
||||
113
website/app/admin/ocr-labeling/_components/ExportTab.tsx
Normal file
113
website/app/admin/ocr-labeling/_components/ExportTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
192
website/app/admin/ocr-labeling/_components/LabelingTab.tsx
Normal file
192
website/app/admin/ocr-labeling/_components/LabelingTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
160
website/app/admin/ocr-labeling/_components/SessionsTab.tsx
Normal file
160
website/app/admin/ocr-labeling/_components/SessionsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
68
website/app/admin/ocr-labeling/_components/StatsTab.tsx
Normal file
68
website/app/admin/ocr-labeling/_components/StatsTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
152
website/app/admin/ocr-labeling/_components/UploadTab.tsx
Normal file
152
website/app/admin/ocr-labeling/_components/UploadTab.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
website/app/admin/ocr-labeling/constants.tsx
Normal file
11
website/app/admin/ocr-labeling/constants.tsx
Normal 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> },
|
||||
]
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user