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 RatingScore, type MeetingInstance, type Assessment, type ConversationEntry, getAssignment, getConsultant, getAssignmentType, loadFeedbackStructure, resolveVisibleStructure, listDoneMeetingsChronological, listAssessmentsForMeetings, listConversationEntriesForMeetings, listMeetingCategoryRatingsForMeetings, getRatingTrendWeightStep, getOrCreateAssignmentFeedback, updateAssignmentFeedback, listFeedbackCategoryRatings, addFeedbackCategoryRating, updateFeedbackCategoryRating, deleteFeedbackCategoryRating, listFeedbackDimensionTexts, getFeedbackDimensionText, addFeedbackDimensionText, updateFeedbackDimensionText, listFeedbackCategoryTexts, getFeedbackCategoryText, addFeedbackCategoryText, updateFeedbackCategoryText, getFeedbackDimensionPrompt, getOrSeedDefaultCategoryPromptTemplate, getOrSeedDefaultDimensionPromptTemplate, } from '../db' import { AI_CONFIG, isValidRatingScore, resolveScoreStyle, } from '../config/constants' import { ScorePicker } from '../components/ScorePicker' import { useRatingColorBands } from '../hooks/useRatingColorBands' import { resolveCategoryChipScore } from '../utils/meetingCategoryScore' import { computeTrend, numberToRating, type MeetingScorePoint, type TrendResult } from '../utils/ratingTrend' import { buildDimensionCondenseContext, buildCategoryPromptContext, renderPrompt, parseCategoryAiResponse, parseDimensionCondenseResponse, type CategoryCondenseEntry, } from '../utils/aiPrompt' import { getAiSettings, saveAiSettings, callOpenRouter } from '../utils/aiClient' import { exportInstituteeFeedbackPdf } from '../utils/pdfExport' const trendArrow = (trend: TrendResult['trend']) => { if (trend === 'up') return if (trend === 'down') return return null } /** 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 colorBands = useRatingColorBands() 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 [categoryErrors, setCategoryErrors] = useState>({}) const [exportingPdf, setExportingPdf] = useState(false) const [previewOpen, setPreviewOpen] = useState>(new Set()) const [previewText, setPreviewText] = useState>({}) const [previewLoading, setPreviewLoading] = useState>(new Set()) 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) const aType = a ? await getAssignmentType(a.assignmentTypeId) : undefined const visible = resolveVisibleStructure( structure.dimensions, structure.categories, structure.items, aType?.criteriaIds, ) setDimensions(visible.dimensions) setCategories(visible.categories) setItems(visible.items) const { categories: cats, items: itms } = visible const meetings = await listDoneMeetingsChronological(assignmentId) const meetingIds = meetings.map(m => m.id!) const [meetingAssessments, categoryRatings, weightStep] = await Promise.all([ listAssessmentsForMeetings(meetingIds), listMeetingCategoryRatingsForMeetings(meetingIds), getRatingTrendWeightStep(), ]) const instAssessments = meetingAssessments.filter(as => as.instituteeId === instId) const trends = new Map() for (const cat of cats) { const catItems = itms.filter(i => i.categoryId === cat.id) const history: MeetingScorePoint[] = [] for (const meeting of meetings) { const score = resolveCategoryChipScore({ meetingId: meeting.id!, instituteeId: instId, categoryId: cat.id!, categoryRatings, assessments: instAssessments, catItems, }) if (score === null) continue history.push({ meetingId: meeting.id!, date: meeting.date, score }) } 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) } setExpanded(new Set(visible.dimensions.map(d => d.id!))) }, [assignmentId, instId]) useEffect(() => { load() }, [load]) // ── Rating setzen ───────────────────────────────────────────────────────── const setRating = async (categoryId: number, rating: RatingScore | null) => { if (!feedback?.id) return const existing = ratings.find(r => r.feedbackCategoryId === categoryId) if (rating === null) { if (existing?.id) { await deleteFeedbackCategoryRating(existing.id) setRatings(prev => prev.filter(r => r.feedbackCategoryId !== categoryId)) } return } 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): RatingScore | null => ratings.find(r => r.feedbackCategoryId === categoryId)?.rating ?? null // ── 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 = (cat: FeedbackCategory, trend: TrendResult) => { if (trend.trend !== 'up' && trend.trend !== 'down') return const field = trend.trend === 'up' ? 'benefits' : 'concerns' const firstLabel = String(numberToRating(trend.history[0].score) ?? trend.history[0].score.toFixed(1)) const lastLabel = String(numberToRating(trend.history[trend.history.length - 1].score) ?? trend.history[trend.history.length - 1].score.toFixed(1)) 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 = getCategoryText(cat.id!)?.[field] ?? '' saveCategoryField(cat.id!, 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 Benefits/Concerns speichern (debounced) ───────────────────── const getCategoryText = (catId: number) => categoryTexts.find(t => t.feedbackCategoryId === catId) const saveCategoryField = (catId: number, field: 'benefits' | 'concerns', value: string) => { setCategoryTexts(prev => { const existing = prev.find(t => t.feedbackCategoryId === catId) if (existing) return prev.map(t => t.feedbackCategoryId === catId ? { ...t, [field]: value } : t) return [...prev, { assignmentFeedbackId: feedback?.id ?? 0, feedbackCategoryId: catId, benefits: '', concerns: '', [field]: value, }] }) const key = `cat-${catId}-${field}` 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, { [field]: value }) } else { await addFeedbackCategoryText({ assignmentFeedbackId: feedback.id, feedbackCategoryId: catId, benefits: field === 'benefits' ? value : '', concerns: field === 'concerns' ? value : '', }) } }, 600)) } const acceptCategorySuggestion = (catId: number, field: 'benefits' | 'concerns') => { const row = getCategoryText(catId) const value = field === 'benefits' ? row?.suggestedBenefits : row?.suggestedConcerns if (!value) return saveCategoryField(catId, field, value) discardCategorySuggestion(catId, field) } const discardCategorySuggestion = async (catId: number, field: 'benefits' | 'concerns') => { const sugField = field === 'benefits' ? 'suggestedBenefits' : 'suggestedConcerns' setCategoryTexts(prev => prev.map(t => t.feedbackCategoryId === catId ? { ...t, [sugField]: '' } : t)) if (!feedback?.id) return const existing = await getFeedbackCategoryText(feedback.id, catId) if (existing?.id) await updateFeedbackCategoryText(existing.id, { [sugField]: '' }) } // ── Prompt-Vorschau (ohne API-Call) ─────────────────────────────────────── const togglePreview = async (dim: FeedbackDimension) => { if (previewOpen.has(dim.id!)) { setPreviewOpen(prev => { const next = new Set(prev); next.delete(dim.id!); return next }) return } setPreviewOpen(prev => new Set(prev).add(dim.id!)) if (previewText[dim.id!] || !institutee || !assignment) return setPreviewLoading(prev => new Set(prev).add(dim.id!)) try { const [meetings, categoryTemplate, dimensionTemplate] = await Promise.all([ listDoneMeetingsChronological(assignmentId), getOrSeedDefaultCategoryPromptTemplate(), getOrSeedDefaultDimensionPromptTemplate(), ]) 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) const dimCategories = categories.filter(c => c.dimensionId === dim.id) const firstCat = dimCategories[0] const catPreview = firstCat ? renderPrompt(categoryTemplate, buildCategoryPromptContext({ institutee, assignment, dimension: dim, category: firstCat, catItems: items.filter(i => i.categoryId === firstCat.id), ratings, catTrend: catTrends.get(firstCat.id!), instAssessments, instEntries, meetings, })) : '(keine Kategorien)' const sampleResults: CategoryCondenseEntry[] = dimCategories.map(c => ({ name: c.name, score: catTrends.get(c.id!)?.suggestion ?? null, benefits: '(Beispiel — wird aus Stufe A befüllt)', concerns: '(Beispiel — wird aus Stufe A befüllt)', })) const dimPreview = renderPrompt(dimensionTemplate, buildDimensionCondenseContext({ institutee, assignment, dimension: dim, categoryResults: sampleResults, })) const rendered = `=== Stufe A: Kategorie-Prompt (Beispiel: ${firstCat?.name ?? '—'}) ===\n\n${catPreview}\n\n=== Stufe B: Dimensions-Verdichtung ===\n\n${dimPreview}` setPreviewText(prev => ({ ...prev, [dim.id!]: rendered })) } finally { setPreviewLoading(prev => { const next = new Set(prev); next.delete(dim.id!); return next }) } } const saveCategorySuggestions = async ( catId: number, parsed: { benefits: string; concerns: string }, ) => { if (!feedback?.id) return const existing = await getFeedbackCategoryText(feedback.id, catId) if (existing?.id) { await updateFeedbackCategoryText(existing.id, { suggestedBenefits: parsed.benefits, suggestedConcerns: parsed.concerns, }) } else { await addFeedbackCategoryText({ assignmentFeedbackId: feedback.id, feedbackCategoryId: catId, benefits: '', concerns: '', suggestedBenefits: parsed.benefits, suggestedConcerns: parsed.concerns, }) } } // ── KI-Kaskade: Stufe A (Kategorie) → Stufe B (Dimension) ───────────────── const generateOneCategory = async ( dim: FeedbackDimension, cat: FeedbackCategory, meetings: MeetingInstance[], instAssessments: Assessment[], instEntries: ConversationEntry[], categoryTemplate: string, settings: { apiKey: string; model: string }, ): Promise => { if (!institutee || !assignment || !feedback?.id) { return { name: cat.name, score: null, benefits: '', concerns: '' } } const catItems = items.filter(i => i.categoryId === cat.id) const context = buildCategoryPromptContext({ institutee, assignment, dimension: dim, category: cat, catItems, ratings, catTrend: catTrends.get(cat.id!), instAssessments, instEntries, meetings, }) const prompt = renderPrompt(categoryTemplate, context) const text = await callOpenRouter(prompt, settings) const parsed = parseCategoryAiResponse(text) await saveCategorySuggestions(cat.id!, parsed) return { name: cat.name, score: parsed.score, benefits: parsed.benefits, concerns: parsed.concerns, } } const generateDimensionCondense = async ( dim: FeedbackDimension, categoryResults: CategoryCondenseEntry[], settings: { apiKey: string; model: string }, ) => { if (!institutee || !assignment || !feedback?.id) return const promptCfg = await getFeedbackDimensionPrompt(dim.id!) const template = promptCfg?.promptTemplate ?? AI_CONFIG.defaultDimensionPromptTemplate const context = buildDimensionCondenseContext({ institutee, assignment, dimension: dim, categoryResults, }) const prompt = renderPrompt(template, context) const text = await callOpenRouter(prompt, settings) const parsed = parseDimensionCondenseResponse(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, }) } } const generateOneDimension = async ( dim: FeedbackDimension, dimIndex: number, dimTotal: number, meetings: MeetingInstance[], instAssessments: Assessment[], instEntries: ConversationEntry[], categoryTemplate: string, settings: { apiKey: string; model: string }, ) => { if (!institutee || !assignment || !feedback?.id) return const dimCategories = categories.filter(c => c.dimensionId === dim.id) const categoryResults: CategoryCondenseEntry[] = [] for (let ci = 0; ci < dimCategories.length; ci++) { const cat = dimCategories[ci] const errKey = `${dim.id}:${cat.id}` setGenerationProgress( `Dimension ${dimIndex + 1}/${dimTotal} · Kategorie ${ci + 1}/${dimCategories.length}: ${cat.name}…`, ) try { const result = await generateOneCategory( dim, cat, meetings, instAssessments, instEntries, categoryTemplate, settings, ) categoryResults.push(result) setCategoryErrors(prev => { const next = { ...prev }; delete next[errKey]; return next }) } catch (e) { const msg = e instanceof Error ? e.message : 'Unbekannter Fehler' setCategoryErrors(prev => ({ ...prev, [errKey]: msg })) throw new Error(`Kategorie „${cat.name}": ${msg}`) } } setGenerationProgress(`Dimension ${dimIndex + 1}/${dimTotal}: ${dim.name} verdichten…`) await generateDimensionCondense(dim, categoryResults, settings) } /** 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 }) setCategoryErrors({}) try { const [meetings, categoryTemplate] = await Promise.all([ listDoneMeetingsChronological(assignmentId), getOrSeedDefaultCategoryPromptTemplate(), ]) 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] try { await generateOneDimension( dim, i, dimsToRun.length, meetings, instAssessments, instEntries, categoryTemplate, 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) } const handleExportPdf = async () => { setExportingPdf(true) try { await exportInstituteeFeedbackPdf(assignmentId, instId) } finally { setExportingPdf(false) } } 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 || Object.keys(categoryErrors).length > 0) && (
{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}
))} )} {Object.entries(categoryErrors).map(([key, msg]) => { const [dimId, catId] = key.split(':').map(Number) const dimName = dimensions.find(d => d.id === dimId)?.name ?? 'Dimension' const catName = categories.find(c => c.id === catId)?.name ?? 'Kategorie' return
• {dimName} → {catName}: {msg}
})} {Object.keys(dimensionErrors).length > 0 && ( )}
)} {/* Dimensionen */} {dimensions.map(dim => { const dimCats = categories.filter(c => c.dimensionId === dim.id) const isOpen = expanded.has(dim.id!) const text = getDimText(dim.id!) const catRatings = dimCats.map(c => getRating(c.id!)).filter(isValidRatingScore) const worstRating = catRatings.length > 0 ? Math.min(...catRatings) : null const summaryStyle = resolveScoreStyle(worstRating, colorBands) return (
{/* Dimension-Header */} {isOpen && (
{/* Prompt-Vorschau */}
{previewOpen.has(dim.id!) && (
{previewLoading.has(dim.id!) ? (
Lädt…
) : ( <>
                            {previewText[dim.id!]}
                          
)}
)}
{/* 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}
setRating(cat.id!, score)} bands={colorBands} /> {(() => { const trend = catTrends.get(cat.id!) if (!trend || trend.suggestion === null) return null const suggestedRating = numberToRating(trend.suggestion) if (suggestedRating === null) return null return (
Vorschlag: {suggestedRating} ({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 Benefits / Concerns */}