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 FeedbackRating, getAssignment, getConsultant, loadFeedbackStructure, listMeetingInstancesForAssignment, listDoneMeetingsChronological, listAssessmentsForMeetings, listConversationEntriesForMeetings, getRatingTrendWeightStep, getOrCreateAssignmentFeedback, updateAssignmentFeedback, listFeedbackCategoryRatings, addFeedbackCategoryRating, updateFeedbackCategoryRating, listFeedbackDimensionTexts, getFeedbackDimensionText, addFeedbackDimensionText, updateFeedbackDimensionText, } from '../db' import { RATING_OPTIONS, RATING_NUM_MAP, AI_CONFIG } from '../config/constants' import { buildGroupMeetingScores, computeTrend, numberToRating, type TrendResult } from '../utils/ratingTrend' import { splitNoteLines } from '../utils/notationParser' const ratingToNum = (r: FeedbackRating | null | undefined): number => r ? (RATING_NUM_MAP[r] ?? 0) : 0 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) } // ── Prompt-Builder ──────────────────────────────────────────────────────────── async function buildAiPrompt( institutee: Consultant, assignment: Assignment, dimensions: FeedbackDimension[], categories: FeedbackCategory[], items: FeedbackCriterionItem[], ratings: FeedbackCategoryRating[], catTrends: Map, ): Promise { const meetings = await listMeetingInstancesForAssignment(assignment.id!) 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 allLineTags = instEntries.flatMap(e => e.lineTags ?? []) const critIds = [...new Set([ ...instAssessments.map(a => a.criteriaId), ...allLineTags.map(t => t.criterionItemId), ])] const crits = items.filter(i => critIds.includes(i.id!)) const scoreLines = crits.map(c => { const scores = instAssessments.filter(a => a.criteriaId === c.id && a.score !== null && a.score !== 'na') const avg = scores.length > 0 ? (scores.reduce((s, a) => s + ratingToNum(a.score), 0) / scores.length).toFixed(1) : '—' const notes = [ ...instAssessments.filter(a => a.criteriaId === c.id && a.note).map(a => a.note), ...allLineTags.filter(t => t.criterionItemId === c.id).map(t => t.text), ] return ` ${c.name}: ${avg}/5${notes.length > 0 ? ` | Notizen: ${notes.join('; ')}` : ''}` }).join('\n') const fillerTotal = instEntries.reduce((s, e) => s + (e.fillerCount ?? 0), 0) const protocolLines = instEntries .flatMap(e => splitNoteLines(e.note).map(line => ({ line, tagged: (e.lineTags ?? []).some(t => t.text === line), }))) .filter(x => !x.tagged) .map(x => ` - ${x.line}`) .join('\n') // Kategorie-Ratings const ratingLines = dimensions.map(dim => { const dimCats = categories.filter(c => c.dimensionId === dim.id) const lines = dimCats.map(cat => { const r = ratings.find(r => r.feedbackCategoryId === cat.id) const rating = RATING_OPTIONS.find(x => x.value === r?.rating)?.label ?? 'nicht bewertet' return ` ${cat.name}: ${rating}` }).join('\n') return ` Dimension "${dim.name}":\n${lines}` }).join('\n') // Entwicklung über die Laufzeit (zeitgewichteter Trend pro Kategorie) const trendLines = dimensions.map(dim => { const dimCats = categories.filter(c => c.dimensionId === dim.id) const lines = dimCats.map(cat => { const t = catTrends.get(cat.id!) if (!t || t.history.length === 0) return ` ${cat.name}: keine Verlaufsdaten` const seq = t.history.map(h => h.score.toFixed(1)).join(' → ') const trendLabel = t.trend === 'up' ? 'Verbesserung über die Laufzeit' : t.trend === 'down' ? 'Verschlechterung über die Laufzeit' : t.trend === 'stable' ? 'stabil' : 'nur ein Meeting, kein Trend ableitbar' return ` ${cat.name}: Verlauf ${seq} (${trendLabel})` }).join('\n') return ` Dimension "${dim.name}":\n${lines}` }).join('\n') // Feedback-Struktur für Output const dimStructure = dimensions.map((dim, i) => `${i + 1}. ${dim.name}` ).join('\n') return `Du bist Führungskraft und Mentor bei Capgemini. Erstelle ein professionelles, konstruktives Performance-Feedback für einen Berater/Trainee auf Basis der folgenden Beobachtungsdaten aus echten Kundenterminen. BERATER: ${institutee.firstName} ${institutee.lastName} ASSIGNMENT: ${assignment.title} | Kunde: ${assignment.client} MEETINGS: ${meetings.length} Kundentermin(e) FÜLLWÖRTER GESAMT: ${fillerTotal} BEOBACHTETE KRITERIEN UND SCORES (1-5): ${scoreLines || '(keine Scores erfasst)'} PROTOKOLL-NOTIZEN, KEINEM KRITERIUM ZUGEORDNET (Notation: + gut, ! negativ, > Kundeninput): ${protocolLines || '(keine nicht zugeordneten Protokolleinträge)'} KATEGORIE-BEWERTUNGEN: ${ratingLines} ENTWICKLUNG ÜBER DIE LAUFZEIT (chronologische Kategorie-Scores aus den Meetings, Skala 0-4): ${trendLines} Generiere für jede der folgenden ${dimensions.length} Dimensionen je einen kurzen Absatz "Achievements" (2-4 Sätze, konkret, anerkennend) und "Development Needs" (2-4 Sätze, konstruktiv, handlungsorientiert). Falls eine Dimension aus den Daten nicht beurteilbar ist, schreibe "n/a". Erwähne eine erkennbare Verbesserung im Achievements-Absatz und eine erkennbare Verschlechterung im Development-Needs-Absatz der jeweiligen Dimension. Ausgabe als JSON: { "dimensions": [ { "name": "", "achievements": "", "developmentNeeds": "" } ] } DIMENSIONEN: ${dimStructure} Wichtig: Beziehe dich auf konkrete Beobachtungen aus den Daten. Schreibe direkt und persönlich (du-Form). Vermeide generische Phrasen.` } // ─── 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 [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 [aiError, setAiError] = 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 { dimensions: dims, 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] = await Promise.all([ listFeedbackCategoryRatings(fb.id), listFeedbackDimensionTexts(fb.id), ]) setRatings(rs) setDimTexts(txts) } // Alle Dimensionen initial expandiert setExpanded(new Set(dims.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 // ── 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 = `${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) } // ── KI-Generierung ──────────────────────────────────────────────────────── const generateWithAi = async () => { if (!institutee || !assignment || !feedback?.id) return const settings = getAiSettings() if (!settings.apiKey) { setShowAiSettings(true); return } setGenerating(true) setAiError('') try { const prompt = await buildAiPrompt(institutee, assignment, dimensions, categories, items, ratings, catTrends) 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 }], response_format: { type: 'json_object' }, }), }) if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`) const data = await res.json() const text = data.choices?.[0]?.message?.content ?? '' const parsed = JSON.parse(text) as { dimensions: { name: string; achievements: string; developmentNeeds: string }[] } // Texte in DB schreiben for (const item of parsed.dimensions) { const dim = dimensions.find(d => d.name === item.name || item.name.includes(d.name.split(',')[0])) if (!dim?.id) continue const existing = await getFeedbackDimensionText(feedback.id, dim.id) if (existing?.id) { await updateFeedbackDimensionText(existing.id, { achievements: item.achievements, developmentNeeds: item.developmentNeeds, }) } else { await addFeedbackDimensionText({ assignmentFeedbackId: feedback.id, feedbackDimensionId: dim.id, achievements: item.achievements, developmentNeeds: item.developmentNeeds, }) } } 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) } } 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}
)} {/* 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!) 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} ))}
)}
) })} {/* Textfelder */}