Files
breakpilot-lehrer/studio-v2/components/korrektur/EHSuggestionPanel.tsx
Benjamin Boenisch 5a31f52310 Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website,
Klausur-Service, School-Service, Voice-Service, Geo-Service,
BreakPilot Drive, Agent-Core

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:47:26 +01:00

294 lines
11 KiB
TypeScript

'use client'
import { useState } from 'react'
import type { EHSuggestion } from '@/app/korrektur/types'
import { DEFAULT_CRITERIA, ANNOTATION_COLORS, NIBIS_ATTRIBUTION } from '@/app/korrektur/types'
interface EHSuggestionPanelProps {
suggestions: EHSuggestion[]
isLoading: boolean
onLoadSuggestions: (criterion?: string) => void
onInsertSuggestion?: (text: string) => void
className?: string
}
export function EHSuggestionPanel({
suggestions,
isLoading,
onLoadSuggestions,
onInsertSuggestion,
className = '',
}: EHSuggestionPanelProps) {
const [selectedCriterion, setSelectedCriterion] = useState<string | undefined>(undefined)
const [expandedSuggestion, setExpandedSuggestion] = useState<string | null>(null)
const handleLoadSuggestions = () => {
onLoadSuggestions(selectedCriterion)
}
// Group suggestions by criterion
const groupedSuggestions = suggestions.reduce((acc, suggestion) => {
if (!acc[suggestion.criterion]) {
acc[suggestion.criterion] = []
}
acc[suggestion.criterion].push(suggestion)
return acc
}, {} as Record<string, EHSuggestion[]>)
return (
<div className={`space-y-4 ${className}`}>
{/* Header with Attribution (CTRL-SRC-002) */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-white font-semibold">EH-Vorschlaege</h3>
<p className="text-white/40 text-xs">Aus 500+ NiBiS Dokumenten</p>
</div>
</div>
{/* Attribution Notice */}
<div className="p-2 rounded-lg bg-white/5 border border-white/10">
<div className="flex items-center gap-2 text-xs text-white/50">
<svg className="w-3 h-3 flex-shrink-0" 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>
<span>
Quelle: {NIBIS_ATTRIBUTION.publisher} {' '}
<a
href={NIBIS_ATTRIBUTION.license_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
>
{NIBIS_ATTRIBUTION.license}
</a>
</span>
</div>
</div>
{/* Criterion Filter */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => setSelectedCriterion(undefined)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
selectedCriterion === undefined
? 'bg-purple-500 text-white'
: 'bg-white/10 text-white/60 hover:bg-white/20'
}`}
>
Alle
</button>
{Object.entries(DEFAULT_CRITERIA).map(([id, config]) => {
const color = ANNOTATION_COLORS[id as keyof typeof ANNOTATION_COLORS] || '#6b7280'
return (
<button
key={id}
onClick={() => setSelectedCriterion(id)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
selectedCriterion === id
? 'text-white'
: 'text-white/60 hover:bg-white/20'
}`}
style={{
backgroundColor: selectedCriterion === id ? color : 'rgba(255,255,255,0.1)',
}}
>
{config.name}
</button>
)
})}
</div>
{/* Load Button */}
<button
onClick={handleLoadSuggestions}
disabled={isLoading}
className="w-full px-4 py-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 text-white font-medium hover:shadow-lg hover:shadow-blue-500/30 transition-all disabled:opacity-50 flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
RAG-Suche laeuft...
</>
) : (
<>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
EH-Vorschlaege laden
</>
)}
</button>
{/* Suggestions List */}
{suggestions.length > 0 && (
<div className="space-y-3 max-h-96 overflow-y-auto">
{Object.entries(groupedSuggestions).map(([criterion, criterionSuggestions]) => {
const config = DEFAULT_CRITERIA[criterion]
const color = ANNOTATION_COLORS[criterion as keyof typeof ANNOTATION_COLORS] || '#6b7280'
return (
<div key={criterion} className="space-y-2">
{/* Criterion Header */}
<div className="flex items-center gap-2 sticky top-0 bg-slate-900/95 backdrop-blur-sm py-1">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="text-white/60 text-xs font-medium">
{config?.name || criterion}
</span>
<span className="text-white/30 text-xs">
({criterionSuggestions.length})
</span>
</div>
{/* Suggestions */}
{criterionSuggestions.map((suggestion, index) => (
<SuggestionCard
key={`${criterion}-${index}`}
suggestion={suggestion}
color={color}
isExpanded={expandedSuggestion === `${criterion}-${index}`}
onToggle={() =>
setExpandedSuggestion(
expandedSuggestion === `${criterion}-${index}`
? null
: `${criterion}-${index}`
)
}
onInsert={onInsertSuggestion}
/>
))}
</div>
)
})}
</div>
)}
{/* Empty State */}
{!isLoading && suggestions.length === 0 && (
<div className="p-6 rounded-xl bg-white/5 border border-white/10 text-center">
<div className="w-12 h-12 rounded-full bg-white/10 flex items-center justify-center mx-auto mb-3">
<svg className="w-6 h-6 text-white/30" 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>
</div>
<p className="text-white/60 text-sm">
Klicken Sie auf "EH-Vorschlaege laden" um<br />
relevante Bewertungskriterien zu finden.
</p>
</div>
)}
</div>
)
}
// =============================================================================
// SUGGESTION CARD
// =============================================================================
interface SuggestionCardProps {
suggestion: EHSuggestion
color: string
isExpanded: boolean
onToggle: () => void
onInsert?: (text: string) => void
}
function SuggestionCard({
suggestion,
color,
isExpanded,
onToggle,
onInsert,
}: SuggestionCardProps) {
const relevancePercent = Math.round(suggestion.relevance_score * 100)
return (
<div
className="rounded-xl bg-white/5 border border-white/10 overflow-hidden transition-all"
style={{ borderLeftColor: color, borderLeftWidth: '3px' }}
>
{/* Header */}
<button
onClick={onToggle}
className="w-full px-3 py-2 flex items-center justify-between hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div
className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center text-xs font-bold"
style={{
backgroundColor: `${color}20`,
color: color,
}}
>
{relevancePercent}%
</div>
<p className="text-white/70 text-sm truncate text-left">
{suggestion.excerpt.slice(0, 60)}...
</p>
</div>
<svg
className={`w-4 h-4 text-white/40 transition-transform flex-shrink-0 ml-2 ${
isExpanded ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="px-3 pb-3 space-y-2">
<p className="text-white/60 text-sm whitespace-pre-wrap">
{suggestion.excerpt}
</p>
{/* Source Attribution */}
<div className="flex items-center gap-2 text-xs text-white/40 pt-1 border-t border-white/10">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span>
{suggestion.source_document || 'NiBiS Kerncurriculum'} ({NIBIS_ATTRIBUTION.license})
</span>
</div>
{/* Actions */}
{onInsert && (
<div className="flex gap-2">
<button
onClick={() => onInsert(suggestion.excerpt)}
className="flex-1 px-3 py-2 rounded-lg bg-white/10 text-white/70 text-xs hover:bg-white/20 hover:text-white transition-colors flex items-center justify-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="M12 4v16m8-8H4" />
</svg>
Einfuegen
</button>
<button
onClick={() => {
// Insert with citation (CTRL-SRC-002)
const citation = `\n\n[Quelle: ${suggestion.source_document || 'NiBiS Kerncurriculum'}, ${NIBIS_ATTRIBUTION.publisher}, ${NIBIS_ATTRIBUTION.license}]`
onInsert(suggestion.excerpt + citation)
}}
className="px-3 py-2 rounded-lg bg-blue-500/20 text-blue-300 text-xs hover:bg-blue-500/30 transition-colors flex items-center justify-center gap-1"
title="Mit Quellenangabe einfuegen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.172 13.828a4 4 0 015.656 0l4-4a4 4 0 00-5.656-5.656l-1.102 1.101" />
</svg>
+ Zitat
</button>
</div>
)}
</div>
)}
</div>
)
}