import { useCallback, useEffect, useRef, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { db, type Assignment, type Consultant, type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem, type AssignmentFeedback, type FeedbackCategoryRating, type FeedbackDimensionText, type FeedbackRating, } from '../db' const ratingToNum = (r: FeedbackRating | null | undefined): number => { const m: Record = { na: 0, not_client_ready: 1, partially_client_ready: 2, nearly_client_ready: 3, client_ready: 4 } return r ? (m[r] ?? 0) : 0 } // ── Rating-Skala ───────────────────────────────────────────────────────────── const RATINGS: { value: FeedbackRating; short: string; label: string; color: string }[] = [ { value: 'na', short: 'N/A', label: 'N/A (0)', color: 'bg-gray-100 text-gray-500 border-gray-300' }, { value: 'not_client_ready', short: 'Not', label: 'Not (1)', color: 'bg-red-100 text-red-700 border-red-300' }, { value: 'partially_client_ready', short: 'Partially', label: 'Partially (2)', color: 'bg-orange-100 text-orange-700 border-orange-300' }, { value: 'nearly_client_ready', short: 'Nearly', label: 'Nearly (3)', color: 'bg-green-100 text-green-600 border-green-300' }, { value: 'client_ready', short: 'Fully', label: 'Fully (4)', color: 'bg-green-100 text-green-800 border-green-400' }, ] const ratingColor = (r: FeedbackRating | undefined) => RATINGS.find(x => x.value === r)?.color ?? 'bg-gray-50 text-gray-400 border-gray-200' // ── KI-Einstellungen aus localStorage ──────────────────────────────────────── const AI_KEY = 'ai_api_key' const AI_MODEL = 'ai_model' function getAiSettings() { return { apiKey: localStorage.getItem(AI_KEY) ?? '', model: localStorage.getItem(AI_MODEL) ?? 'openai/gpt-4o-mini', } } function saveAiSettings(apiKey: string, model: string) { localStorage.setItem(AI_KEY, apiKey) localStorage.setItem(AI_MODEL, model) } // ── Prompt-Builder ──────────────────────────────────────────────────────────── async function buildAiPrompt( institutee: Consultant, assignment: Assignment, dimensions: FeedbackDimension[], categories: FeedbackCategory[], _items: FeedbackCriterionItem[], ratings: FeedbackCategoryRating[], _feedbackId: number, ): Promise { const meetings = await db.meetingInstances.where('assignmentId').equals(assignment.id!).toArray() const meetingIds = meetings.map(m => m.id!) const [allAssessments, allEntries] = await Promise.all([ db.assessments.where('meetingInstanceId').anyOf(meetingIds).toArray(), db.conversationEntries.where('meetingInstanceId').anyOf(meetingIds).toArray(), ]) const instAssessments = allAssessments.filter(a => a.instituteeId === institutee.id) const instEntries = allEntries.filter(e => e.instituteeId === institutee.id) const critIds = [...new Set(instAssessments.map(a => a.criteriaId))] const crits = critIds.length > 0 ? await db.criteria.where('id').anyOf(critIds).toArray() : [] const scoreLines = crits.map(c => { const scores = instAssessments.filter(a => a.criteriaId === c.id && a.score !== null) 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) 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 .filter(e => e.note) .map(e => ` - ${e.note}${(e.fillerCount ?? 0) > 0 ? ` [äh×${e.fillerCount}]` : ''}`) .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 = RATINGS.find(x => x.value === r?.rating)?.label ?? 'nicht bewertet' return ` ${cat.name}: ${rating}` }).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 (Kurznotizen aus dem Gespräch, Notation: + gut, ! negativ, > Kundeninput): ${protocolLines || '(keine Protokolleinträge)'} KATEGORIE-BEWERTUNGEN: ${ratingLines} 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". 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 [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, dims, cats, itms] = await Promise.all([ db.assignments.get(assignmentId), db.consultants.get(instId), db.feedbackDimensions.orderBy('order').toArray(), db.feedbackCategories.orderBy('order').toArray(), db.feedbackCriterionItems.orderBy('order').toArray(), ]) setAssignment(a ?? null) setInstitutee(cons ?? null) setDimensions(dims) setCategories(cats) setItems(itms) let fb = await db.assignmentFeedbacks .where('assignmentId').equals(assignmentId) .filter(f => f.instituteeId === instId) .first() if (!fb) { const fbId = await db.assignmentFeedbacks.add({ assignmentId, instituteeId: instId, status: 'draft', aiModel: '', updatedAt: new Date().toISOString(), }) fb = await db.assignmentFeedbacks.get(fbId) } setFeedback(fb ?? null) if (fb?.id) { const [rs, txts] = await Promise.all([ db.feedbackCategoryRatings.where('assignmentFeedbackId').equals(fb.id).toArray(), db.feedbackDimensionTexts.where('assignmentFeedbackId').equals(fb.id).toArray(), ]) 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 db.feedbackCategoryRatings.update(existing.id, { rating }) setRatings(prev => prev.map(r => r.feedbackCategoryId === categoryId ? { ...r, rating } : r)) } else { const newId = await db.feedbackCategoryRatings.add({ 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 db.feedbackDimensionTexts .where('assignmentFeedbackId').equals(feedback.id) .filter(t => t.feedbackDimensionId === dimId) .first() if (existing?.id) { await db.feedbackDimensionTexts.update(existing.id, { [field]: value }) } else { await db.feedbackDimensionTexts.add({ assignmentFeedbackId: feedback.id, feedbackDimensionId: dimId, achievements: field === 'achievements' ? value : '', developmentNeeds: field === 'developmentNeeds' ? value : '', }) } }, 600)) } const getDimText = (dimId: number) => dimTexts.find(t => t.feedbackDimensionId === dimId) // ── 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, feedback.id) 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 db.feedbackDimensionTexts .where('assignmentFeedbackId').equals(feedback.id) .filter(t => t.feedbackDimensionId === dim.id) .first() if (existing?.id) { await db.feedbackDimensionTexts.update(existing.id, { achievements: item.achievements, developmentNeeds: item.developmentNeeds, }) } else { await db.feedbackDimensionTexts.add({ assignmentFeedbackId: feedback.id, feedbackDimensionId: dim.id, achievements: item.achievements, developmentNeeds: item.developmentNeeds, }) } } await db.assignmentFeedbacks.update(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 db.assignmentFeedbacks.update(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 */}
{RATINGS.map(r => ( ))}
{/* Kriterium-Items als Referenz */} {catItems.length > 0 && (
{catItems.map(item => ( {item.name} ))}
)}
) })} {/* Textfelder */}