import { useCallback, useEffect, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { type Assignment, type Consultant, type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem, type AssignmentFeedback, type FeedbackCategoryRating, type FeedbackDimensionText, type FeedbackCategoryText, type FeedbackRating, type MeetingInstance, type Assessment, type ConversationEntry, getAssignment, getConsultant, loadFeedbackStructure, listDoneMeetingsChronological, listAssessmentsForMeetings, listConversationEntriesForMeetings, getRatingTrendWeightStep, getOrCreateAssignmentFeedback, updateAssignmentFeedback, listFeedbackCategoryRatings, addFeedbackCategoryRating, updateFeedbackCategoryRating, listFeedbackDimensionTexts, getFeedbackDimensionText, addFeedbackDimensionText, updateFeedbackDimensionText, listFeedbackCategoryTexts, getFeedbackCategoryText, addFeedbackCategoryText, updateFeedbackCategoryText, getFeedbackDimensionPrompt, } from '../db' import { RATING_OPTIONS, AI_CONFIG } from '../config/constants' import { buildGroupMeetingScores, computeTrend, numberToRating, type TrendResult } from '../utils/ratingTrend' import { buildDimensionPromptContext, renderDimensionPrompt, parseDimensionAiResponse } from '../utils/aiPrompt' const ratingColor = (r: FeedbackRating | undefined) => RATING_OPTIONS.find(x => x.value === r)?.color ?? 'bg-gray-50 text-gray-400 border-gray-200' const trendArrow = (trend: TrendResult['trend']) => { if (trend === 'up') return ↑ if (trend === 'down') return ↓ return null } function getAiSettings() { return { apiKey: localStorage.getItem(AI_CONFIG.localStorageKeyApiKey) ?? '', model: localStorage.getItem(AI_CONFIG.localStorageKeyModel) ?? AI_CONFIG.defaultModel, } } function saveAiSettings(apiKey: string, model: string) { localStorage.setItem(AI_CONFIG.localStorageKeyApiKey, apiKey) localStorage.setItem(AI_CONFIG.localStorageKeyModel, model) } /** Klein gehaltene KI-Vorschlag-Box mit Übernehmen/Verwerfen — gleiche Bedienlogik überall im Feedback. */ function AiSuggestionBox({ text, onAccept, onDiscard }: { text: string; onAccept: () => void; onDiscard: () => void }) { return (
✦ KI-Vorschlag
{text}
) } // ─── Komponente ─────────────────────────────────────────────────────────────── export default function AssignmentFeedbackPage() { const { id, instituteeId } = useParams() const navigate = useNavigate() const assignmentId = Number(id) const instId = Number(instituteeId) const [assignment, setAssignment] = useState(null) const [institutee, setInstitutee] = useState(null) const [dimensions, setDimensions] = useState([]) const [categories, setCategories] = useState([]) const [items, setItems] = useState([]) const [feedback, setFeedback] = useState(null) const [ratings, setRatings] = useState([]) const [dimTexts, setDimTexts] = useState([]) const [categoryTexts, setCategoryTexts] = useState([]) const [expanded, setExpanded] = useState>(new Set()) const [catTrends, setCatTrends] = useState>(new Map()) const [showAiSettings, setShowAiSettings] = useState(false) const [aiKey, setAiKey] = useState(getAiSettings().apiKey) const [aiModel, setAiModel] = useState(getAiSettings().model) const [generating, setGenerating] = useState(false) const [generationProgress, setGenerationProgress] = useState('') const [aiError, setAiError] = useState('') const [dimensionErrors, setDimensionErrors] = useState>({}) const saveTimer = useRef>>(new Map()) const load = useCallback(async () => { const [a, cons, structure] = await Promise.all([ getAssignment(assignmentId), getConsultant(instId), loadFeedbackStructure(), ]) setAssignment(a ?? null) setInstitutee(cons ?? null) setDimensions(structure.dimensions) setCategories(structure.categories) setItems(structure.items) const { categories: cats, items: itms } = structure const meetings = await listDoneMeetingsChronological(assignmentId) const meetingIds = meetings.map(m => m.id!) const [meetingAssessments, weightStep] = await Promise.all([ listAssessmentsForMeetings(meetingIds), getRatingTrendWeightStep(), ]) const instAssessments = meetingAssessments.filter(as => as.instituteeId === instId) const getScore = (meetingId: number, itemId: number): FeedbackRating | null => instAssessments.find(as => as.meetingInstanceId === meetingId && as.criteriaId === itemId)?.score ?? null const trends = new Map() for (const cat of cats) { const catItems = itms.filter(i => i.categoryId === cat.id) const history = buildGroupMeetingScores(catItems, meetings, getScore) trends.set(cat.id!, computeTrend(history, weightStep)) } setCatTrends(trends) const fb = await getOrCreateAssignmentFeedback(assignmentId, instId) setFeedback(fb ?? null) if (fb?.id) { const [rs, txts, catTxts] = await Promise.all([ listFeedbackCategoryRatings(fb.id), listFeedbackDimensionTexts(fb.id), listFeedbackCategoryTexts(fb.id), ]) setRatings(rs) setDimTexts(txts) setCategoryTexts(catTxts) } // Alle Dimensionen initial expandiert setExpanded(new Set(structure.dimensions.map(d => d.id!))) }, [assignmentId, instId]) useEffect(() => { load() }, [load]) // ── Rating setzen ───────────────────────────────────────────────────────── const setRating = async (categoryId: number, rating: FeedbackRating) => { if (!feedback?.id) return const existing = ratings.find(r => r.feedbackCategoryId === categoryId) if (existing?.id) { await updateFeedbackCategoryRating(existing.id, { rating }) setRatings(prev => prev.map(r => r.feedbackCategoryId === categoryId ? { ...r, rating } : r)) } else { const newId = await addFeedbackCategoryRating({ assignmentFeedbackId: feedback.id, feedbackCategoryId: categoryId, rating, }) setRatings(prev => [...prev, { id: newId, assignmentFeedbackId: feedback.id!, feedbackCategoryId: categoryId, rating }]) } } const getRating = (categoryId: number): FeedbackRating | undefined => ratings.find(r => r.feedbackCategoryId === categoryId)?.rating // ── Dimension-Text speichern (debounced) ────────────────────────────────── const saveDimText = (dimId: number, field: 'achievements' | 'developmentNeeds', value: string) => { setDimTexts(prev => { const existing = prev.find(t => t.feedbackDimensionId === dimId) if (existing) return prev.map(t => t.feedbackDimensionId === dimId ? { ...t, [field]: value } : t) return [...prev, { assignmentFeedbackId: feedback?.id ?? 0, feedbackDimensionId: dimId, achievements: '', developmentNeeds: '', [field]: value }] }) const key = `dim-${dimId}-${field}` const prev = saveTimer.current.get(key) if (prev) clearTimeout(prev) saveTimer.current.set(key, setTimeout(async () => { if (!feedback?.id) return const existing = await getFeedbackDimensionText(feedback.id, dimId) if (existing?.id) { await updateFeedbackDimensionText(existing.id, { [field]: value }) } else { await addFeedbackDimensionText({ assignmentFeedbackId: feedback.id, feedbackDimensionId: dimId, achievements: field === 'achievements' ? value : '', developmentNeeds: field === 'developmentNeeds' ? value : '', }) } }, 600)) } const getDimText = (dimId: number) => dimTexts.find(t => t.feedbackDimensionId === dimId) const appendTrendSentence = (dimId: number, cat: FeedbackCategory, trend: TrendResult) => { if (trend.trend !== 'up' && trend.trend !== 'down') return const field = trend.trend === 'up' ? 'achievements' : 'developmentNeeds' const firstLabel = RATING_OPTIONS.find(o => o.value === numberToRating(trend.history[0].score))?.short const lastLabel = RATING_OPTIONS.find(o => o.value === numberToRating(trend.history[trend.history.length - 1].score))?.short const verb = trend.trend === 'up' ? 'Verbesserung' : 'Verschlechterung' const sentence = `Zeigt im Verlauf des Assignments in ${cat.name} eine ${verb} von ${firstLabel} zu ${lastLabel}.` const existing = getDimText(dimId)?.[field] ?? '' saveDimText(dimId, field, existing ? `${existing}\n${sentence}` : sentence) } const acceptDimSuggestion = (dimId: number, field: 'achievements' | 'developmentNeeds') => { const t = getDimText(dimId) const sugField = field === 'achievements' ? 'suggestedAchievements' : 'suggestedDevelopmentNeeds' const value = t?.[sugField] if (!value) return saveDimText(dimId, field, value) discardDimSuggestion(dimId, field) } const discardDimSuggestion = async (dimId: number, field: 'achievements' | 'developmentNeeds') => { const sugField = field === 'achievements' ? 'suggestedAchievements' : 'suggestedDevelopmentNeeds' setDimTexts(prev => prev.map(t => t.feedbackDimensionId === dimId ? { ...t, [sugField]: '' } : t)) if (!feedback?.id) return const existing = await getFeedbackDimensionText(feedback.id, dimId) if (existing?.id) await updateFeedbackDimensionText(existing.id, { [sugField]: '' }) } // ── Kategorie-Text speichern (debounced) ────────────────────────────────── const getCategoryText = (catId: number) => categoryTexts.find(t => t.feedbackCategoryId === catId) const saveCategoryText = (catId: number, value: string) => { setCategoryTexts(prev => { const existing = prev.find(t => t.feedbackCategoryId === catId) if (existing) return prev.map(t => t.feedbackCategoryId === catId ? { ...t, text: value } : t) return [...prev, { assignmentFeedbackId: feedback?.id ?? 0, feedbackCategoryId: catId, text: value }] }) const key = `cat-${catId}` const prev = saveTimer.current.get(key) if (prev) clearTimeout(prev) saveTimer.current.set(key, setTimeout(async () => { if (!feedback?.id) return const existing = await getFeedbackCategoryText(feedback.id, catId) if (existing?.id) { await updateFeedbackCategoryText(existing.id, { text: value }) } else { await addFeedbackCategoryText({ assignmentFeedbackId: feedback.id, feedbackCategoryId: catId, text: value }) } }, 600)) } const acceptCategorySuggestion = (catId: number) => { const value = getCategoryText(catId)?.suggestedText if (!value) return saveCategoryText(catId, value) discardCategorySuggestion(catId) } const discardCategorySuggestion = async (catId: number) => { setCategoryTexts(prev => prev.map(t => t.feedbackCategoryId === catId ? { ...t, suggestedText: '' } : t)) if (!feedback?.id) return const existing = await getFeedbackCategoryText(feedback.id, catId) if (existing?.id) await updateFeedbackCategoryText(existing.id, { suggestedText: '' }) } // ── KI-Generierung: ein Call pro Dimension ──────────────────────────────── const generateOneDimension = async ( dim: FeedbackDimension, meetings: MeetingInstance[], instAssessments: Assessment[], instEntries: ConversationEntry[], settings: { apiKey: string; model: string }, ) => { if (!institutee || !assignment || !feedback?.id) return const dimCategories = categories.filter(c => c.dimensionId === dim.id) const dimItems = items.filter(i => dimCategories.some(c => c.id === i.categoryId)) const promptCfg = await getFeedbackDimensionPrompt(dim.id!) const template = promptCfg?.promptTemplate ?? AI_CONFIG.defaultDimensionPromptTemplate const context = buildDimensionPromptContext({ institutee, assignment, dimension: dim, dimCategories, dimItems, ratings, catTrends, instAssessments, instEntries, meetings, }) const prompt = renderDimensionPrompt(template, context) const res = await fetch('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { 'Authorization': `Bearer ${settings.apiKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': window.location.origin, 'X-Title': 'Assignment Monitor', }, body: JSON.stringify({ model: settings.model, messages: [{ role: 'user', content: prompt }], }), }) if (!res.ok) throw new Error(`API-Fehler: ${res.status} ${res.statusText}`) const data = await res.json() const text = data.choices?.[0]?.message?.content ?? '' const parsed = parseDimensionAiResponse(text) const existingDimText = await getFeedbackDimensionText(feedback.id, dim.id!) if (existingDimText?.id) { await updateFeedbackDimensionText(existingDimText.id, { suggestedAchievements: parsed.achievements, suggestedDevelopmentNeeds: parsed.developmentNeeds, }) } else { await addFeedbackDimensionText({ assignmentFeedbackId: feedback.id, feedbackDimensionId: dim.id!, achievements: '', developmentNeeds: '', suggestedAchievements: parsed.achievements, suggestedDevelopmentNeeds: parsed.developmentNeeds, }) } for (const ct of parsed.categoryTexts) { const cat = dimCategories.find(c => c.name === ct.name || ct.name.includes(c.name) || c.name.includes(ct.name)) if (!cat?.id) continue const existing = await getFeedbackCategoryText(feedback.id, cat.id) if (existing?.id) { await updateFeedbackCategoryText(existing.id, { suggestedText: ct.text }) } else { await addFeedbackCategoryText({ assignmentFeedbackId: feedback.id, feedbackCategoryId: cat.id, text: '', suggestedText: ct.text }) } } } /** Sequenziell mit Fortschrittsanzeige — Fehler einzelner Dimensionen stoppen den Lauf nicht. */ const runDimensionBatch = async (dimsToRun: FeedbackDimension[]) => { if (!institutee || !assignment || !feedback?.id || dimsToRun.length === 0) return const settings = getAiSettings() if (!settings.apiKey) { setShowAiSettings(true); return } setGenerating(true) setAiError('') setDimensionErrors(prev => { const next = { ...prev } for (const d of dimsToRun) delete next[d.id!] return next }) try { const meetings = await listDoneMeetingsChronological(assignmentId) const meetingIds = meetings.map(m => m.id!) const [allAssessments, allEntries] = await Promise.all([ listAssessmentsForMeetings(meetingIds), listConversationEntriesForMeetings(meetingIds), ]) const instAssessments = allAssessments.filter(a => a.instituteeId === institutee.id) const instEntries = allEntries.filter(e => e.instituteeId === institutee.id) for (let i = 0; i < dimsToRun.length; i++) { const dim = dimsToRun[i] setGenerationProgress(`Generiere Dimension ${i + 1}/${dimsToRun.length}: ${dim.name}…`) try { await generateOneDimension(dim, meetings, instAssessments, instEntries, settings) } catch (e) { setDimensionErrors(prev => ({ ...prev, [dim.id!]: e instanceof Error ? e.message : 'Unbekannter Fehler' })) } } await updateAssignmentFeedback(feedback.id, { aiModel: settings.model, updatedAt: new Date().toISOString() }) await load() } catch (e) { setAiError(e instanceof Error ? e.message : 'Unbekannter Fehler') } finally { setGenerating(false) setGenerationProgress('') } } const generateAllDimensions = () => runDimensionBatch(dimensions) const retryFailedDimensions = () => { const failedIds = new Set(Object.keys(dimensionErrors).map(Number)) runDimensionBatch(dimensions.filter(d => failedIds.has(d.id!))) } const toggleFinal = async () => { if (!feedback?.id) return const next = feedback.status === 'final' ? 'draft' : 'final' await updateAssignmentFeedback(feedback.id, { status: next, updatedAt: new Date().toISOString() }) setFeedback(prev => prev ? { ...prev, status: next } : null) } if (!assignment || !institutee) return
Lädt…
return (
{/* Header */}

Feedback: {institutee.firstName} {institutee.lastName}

{assignment.title} · {assignment.client}
{/* KI-Einstellungen */} {showAiSettings && (
KI-Einstellungen (OpenRouter)
API-Key von openrouter.ai — wird lokal gespeichert, nie übertragen.
setAiKey(e.target.value)} />
)} {/* KI-Generieren Button */}
{aiError && (
{aiError}
)} {!generating && Object.keys(dimensionErrors).length > 0 && (
Fehler bei {Object.keys(dimensionErrors).length} Dimension(en):
{Object.entries(dimensionErrors).map(([dimId, msg]) => (
• {dimensions.find(d => d.id === Number(dimId))?.name}: {msg}
))}
)} {/* Dimensionen */} {dimensions.map(dim => { const dimCats = categories.filter(c => c.dimensionId === dim.id) const isOpen = expanded.has(dim.id!) const text = getDimText(dim.id!) // Zusammenfassung der Bewertungen const catRatings = dimCats.map(c => getRating(c.id!)).filter(Boolean) as FeedbackRating[] const worstRating = catRatings.includes('not_client_ready') ? 'not_client_ready' : catRatings.includes('partially_client_ready') ? 'partially_client_ready' : catRatings.includes('nearly_client_ready') ? 'nearly_client_ready' : catRatings.includes('client_ready') ? 'client_ready' : catRatings.length > 0 ? 'na' : undefined const summaryColor = ratingColor(worstRating) return (
{/* Dimension-Header */} {isOpen && (
{/* Kategorien */} {dimCats.map(cat => { const catItems = items.filter(i => i.categoryId === cat.id) const currentRating = getRating(cat.id!) const catText = getCategoryText(cat.id!) return (
{cat.name}
{/* Rating-Buttons */}
{RATING_OPTIONS.map(r => ( ))}
{/* Bewertungs-Vorschlag aus Meeting-Assessments */} {(() => { const trend = catTrends.get(cat.id!) if (!trend || trend.suggestion === null) return null const suggestedRating = numberToRating(trend.suggestion) const shortLabel = RATING_OPTIONS.find(o => o.value === suggestedRating)?.short return (
Vorschlag: {shortLabel} ({trend.suggestion.toFixed(1)}) {trendArrow(trend.trend)} {(trend.trend === 'up' || trend.trend === 'down') && ( )}
) })()} {/* Kriterium-Items als Referenz */} {catItems.length > 0 && (
{catItems.map(item => ( {item.name} ))}
)} {/* Kategorie-Notiz */}