CG-Feedback-Monitor/src/pages/AssignmentFeedbackPage.tsx
Lars 234f87d408 V2-Dev-Stand: Skala 1-10, Benefits/Concerns, DEV-Isolation und Gitea-Doku.
Bündelt die Neuentwicklung in AssigmentMonitorV2 (eigene Ports/DB), die Umstellung auf numerische Bewertungen, Meeting-Checklisten, KI-Tagging und die geplante Feedback-Kaskade — als Basis für Versionsverwaltung in Gitea.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 17:35:05 +02:00

876 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 <span title="Verbesserung über die Laufzeit"></span>
if (trend === 'down') return <span title="Verschlechterung über die Laufzeit"></span>
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 (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-2.5 space-y-1.5">
<div className="text-xs font-semibold text-blue-700"> KI-Vorschlag</div>
<div className="text-sm text-blue-900 whitespace-pre-wrap">{text}</div>
<div className="flex gap-1.5">
<button onClick={onAccept}
className="text-xs bg-blue-600 text-white px-2 py-1 rounded-lg font-medium hover:bg-blue-700">
Übernehmen
</button>
<button onClick={onDiscard}
className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-lg font-medium hover:bg-gray-200">
Verwerfen
</button>
</div>
</div>
)
}
// ─── 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<Assignment | null>(null)
const [institutee, setInstitutee] = useState<Consultant | null>(null)
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
const [categories, setCategories] = useState<FeedbackCategory[]>([])
const [items, setItems] = useState<FeedbackCriterionItem[]>([])
const [feedback, setFeedback] = useState<AssignmentFeedback | null>(null)
const [ratings, setRatings] = useState<FeedbackCategoryRating[]>([])
const [dimTexts, setDimTexts] = useState<FeedbackDimensionText[]>([])
const [categoryTexts, setCategoryTexts] = useState<FeedbackCategoryText[]>([])
const [expanded, setExpanded] = useState<Set<number>>(new Set())
const [catTrends, setCatTrends] = useState<Map<number, TrendResult>>(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<Record<number, string>>({})
const [categoryErrors, setCategoryErrors] = useState<Record<string, string>>({})
const [exportingPdf, setExportingPdf] = useState(false)
const [previewOpen, setPreviewOpen] = useState<Set<number>>(new Set())
const [previewText, setPreviewText] = useState<Record<number, string>>({})
const [previewLoading, setPreviewLoading] = useState<Set<number>>(new Set())
const saveTimer = useRef<Map<string, ReturnType<typeof setTimeout>>>(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<number, TrendResult>()
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<CategoryCondenseEntry> => {
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 <div className="text-center py-10 text-gray-400">Lädt</div>
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<button onClick={() => navigate(`/assignment/${assignmentId}`)}
className="text-xs text-brand mb-0.5 block"> Assignment</button>
<h1 className="text-xl font-bold text-gray-800">
Feedback: {institutee.firstName} {institutee.lastName}
</h1>
<div className="text-sm text-gray-500">{assignment.title} · {assignment.client}</div>
</div>
<div className="flex flex-col gap-1.5 items-end flex-shrink-0">
<button onClick={toggleFinal}
className={`text-xs px-3 py-1.5 rounded-lg font-medium border ${
feedback?.status === 'final'
? 'bg-green-600 text-white border-green-600'
: 'bg-white text-gray-500 border-gray-200'
}`}>
{feedback?.status === 'final' ? '✓ Final' : 'Entwurf'}
</button>
<button onClick={() => setShowAiSettings(p => !p)}
className="text-xs text-gray-400 hover:text-gray-600">
KI-Einstellungen
</button>
<button onClick={handleExportPdf} disabled={exportingPdf}
className="text-xs text-gray-400 hover:text-gray-600 disabled:opacity-50">
{exportingPdf ? 'Erstelle PDF…' : '⬇ Als PDF exportieren'}
</button>
</div>
</div>
{/* KI-Einstellungen */}
{showAiSettings && (
<div className="bg-blue-50 border border-blue-100 rounded-xl p-4 space-y-3">
<div className="text-sm font-semibold text-blue-700">KI-Einstellungen (OpenRouter)</div>
<div className="text-xs text-blue-500">
API-Key von <span className="font-mono">openrouter.ai</span> wird lokal gespeichert, nie übertragen.
</div>
<input
type="password"
className="w-full border border-blue-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand bg-white"
placeholder="sk-or-..."
value={aiKey}
onChange={e => setAiKey(e.target.value)}
/>
<select
className="w-full border border-blue-200 rounded-lg px-3 py-2 text-sm bg-white"
value={aiModel}
onChange={e => setAiModel(e.target.value)}>
<option value="openai/gpt-4o-mini">GPT-4o mini (günstig)</option>
<option value="openai/gpt-4o">GPT-4o</option>
<option value="anthropic/claude-sonnet-4-5">Claude Sonnet 4.5</option>
<option value="anthropic/claude-haiku-4-5">Claude Haiku 4.5 (schnell)</option>
<option value="google/gemini-flash-1.5">Gemini Flash 1.5</option>
</select>
<div className="flex gap-2">
<button onClick={() => { saveAiSettings(aiKey, aiModel); setShowAiSettings(false) }}
className="flex-1 py-2 rounded-lg text-sm font-medium bg-brand text-white">
Speichern
</button>
<button onClick={() => setShowAiSettings(false)}
className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-500">
Abbrechen
</button>
</div>
</div>
)}
{/* KI-Generieren Button */}
<div className="flex items-center gap-3">
<button
onClick={generateAllDimensions}
disabled={generating}
className={`flex-1 py-3 rounded-xl text-sm font-medium transition ${
generating
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-brand text-white hover:bg-brand-dark'
}`}>
{generating ? (generationProgress || 'Generiere…') : '✦ KI-Feedback-Entwurf generieren'}
</button>
</div>
{aiError && (
<div className="text-xs text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2">
{aiError}
</div>
)}
{!generating && (Object.keys(dimensionErrors).length > 0 || Object.keys(categoryErrors).length > 0) && (
<div className="text-xs text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2 space-y-1.5">
{Object.keys(dimensionErrors).length > 0 && (
<>
<div className="font-medium">Fehler bei {Object.keys(dimensionErrors).length} Dimension(en):</div>
{Object.entries(dimensionErrors).map(([dimId, msg]) => (
<div key={dimId}> {dimensions.find(d => d.id === Number(dimId))?.name}: {msg}</div>
))}
</>
)}
{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 <div key={key}> {dimName} {catName}: {msg}</div>
})}
{Object.keys(dimensionErrors).length > 0 && (
<button onClick={retryFailedDimensions}
className="text-xs bg-red-100 text-red-700 px-2 py-1 rounded-lg font-medium hover:bg-red-200">
Fehlgeschlagene Dimensionen erneut versuchen
</button>
)}
</div>
)}
{/* 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 (
<div key={dim.id} className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
{/* Dimension-Header */}
<button
onClick={() => setExpanded(prev => {
const next = new Set(prev)
if (next.has(dim.id!)) next.delete(dim.id!)
else next.add(dim.id!)
return next
})}
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 transition">
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-800 text-sm">{dim.name}</span>
{worstRating !== null && (
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium tabular-nums ${summaryStyle.color}`}>
{summaryStyle.short}
</span>
)}
</div>
<span className="text-gray-400 text-sm">{isOpen ? '▲' : '▼'}</span>
</button>
{isOpen && (
<div className="border-t border-gray-100 p-4 space-y-4">
{/* Prompt-Vorschau */}
<div>
<button onClick={() => togglePreview(dim)}
className="text-xs text-gray-400 hover:text-gray-600 underline">
{previewOpen.has(dim.id!) ? 'KI-Prompt ausblenden' : '👁 KI-Prompt für diese Dimension ansehen'}
</button>
{previewOpen.has(dim.id!) && (
<div className="mt-2 bg-gray-50 border border-gray-200 rounded-lg p-3">
{previewLoading.has(dim.id!) ? (
<div className="text-xs text-gray-400">Lädt</div>
) : (
<>
<pre className="text-xs text-gray-700 whitespace-pre-wrap font-mono max-h-96 overflow-y-auto">
{previewText[dim.id!]}
</pre>
<button
onClick={() => navigator.clipboard.writeText(previewText[dim.id!] ?? '')}
className="mt-2 text-xs bg-gray-200 hover:bg-gray-300 text-gray-700 px-2 py-1 rounded-lg font-medium">
Kopieren
</button>
</>
)}
</div>
)}
</div>
{/* Kategorien */}
{dimCats.map(cat => {
const catItems = items.filter(i => i.categoryId === cat.id)
const currentRating = getRating(cat.id!)
const catText = getCategoryText(cat.id!)
return (
<div key={cat.id} className="space-y-2">
<div className="font-medium text-sm text-gray-700">{cat.name}</div>
<ScorePicker
value={currentRating ?? null}
onChange={score => 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 (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs bg-blue-50 text-blue-700 border border-blue-200 px-2 py-1 rounded-lg font-medium tabular-nums">
Vorschlag: {suggestedRating} ({trend.suggestion.toFixed(1)}) {trendArrow(trend.trend)}
</span>
<button
onClick={() => setRating(cat.id!, suggestedRating)}
className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-lg font-medium hover:bg-blue-200">
Übernehmen
</button>
{(trend.trend === 'up' || trend.trend === 'down') && (
<button
onClick={() => appendTrendSentence(cat, trend)}
className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-lg font-medium hover:bg-gray-200">
Trend in Text übernehmen
</button>
)}
</div>
)
})()}
{/* Kriterium-Items als Referenz */}
{catItems.length > 0 && (
<div className="flex flex-wrap gap-1">
{catItems.map(item => (
<span key={item.id}
className="text-xs bg-gray-50 text-gray-400 border border-gray-100 px-2 py-0.5 rounded-full">
{item.name}
</span>
))}
</div>
)}
{/* Kategorie Benefits / Concerns */}
<div className="space-y-2">
<div className="space-y-1">
<label className="text-xs font-medium text-green-700">Benefits</label>
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand min-h-[44px]"
placeholder="Positive Beobachtungen zu dieser Kategorie…"
value={catText?.benefits ?? ''}
onChange={e => saveCategoryField(cat.id!, 'benefits', e.target.value)}
/>
{catText?.suggestedBenefits && (
<AiSuggestionBox
text={catText.suggestedBenefits}
onAccept={() => acceptCategorySuggestion(cat.id!, 'benefits')}
onDiscard={() => discardCategorySuggestion(cat.id!, 'benefits')}
/>
)}
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-amber-700">Concerns</label>
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand min-h-[44px]"
placeholder="Entwicklungsfelder / Risiken zu dieser Kategorie…"
value={catText?.concerns ?? ''}
onChange={e => saveCategoryField(cat.id!, 'concerns', e.target.value)}
/>
{catText?.suggestedConcerns && (
<AiSuggestionBox
text={catText.suggestedConcerns}
onAccept={() => acceptCategorySuggestion(cat.id!, 'concerns')}
onDiscard={() => discardCategorySuggestion(cat.id!, 'concerns')}
/>
)}
</div>
</div>
</div>
)
})}
{/* Textfelder */}
<div className="space-y-3 pt-2 border-t border-gray-50">
<div className="space-y-1.5">
<label className="text-xs font-semibold text-green-700 uppercase tracking-wide block mb-1.5">
Benefits (Dimension)
</label>
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-green-400 min-h-[80px]"
placeholder="35 Hauptstärken dieser Dimension…"
value={text?.achievements ?? ''}
onChange={e => saveDimText(dim.id!, 'achievements', e.target.value)}
/>
{text?.suggestedAchievements && (
<AiSuggestionBox
text={text.suggestedAchievements}
onAccept={() => acceptDimSuggestion(dim.id!, 'achievements')}
onDiscard={() => discardDimSuggestion(dim.id!, 'achievements')}
/>
)}
</div>
<div className="space-y-1.5">
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wide block mb-1.5">
Concerns (Dimension)
</label>
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-amber-400 min-h-[80px]"
placeholder="35 zentrale Entwicklungsfelder dieser Dimension…"
value={text?.developmentNeeds ?? ''}
onChange={e => saveDimText(dim.id!, 'developmentNeeds', e.target.value)}
/>
{text?.suggestedDevelopmentNeeds && (
<AiSuggestionBox
text={text.suggestedDevelopmentNeeds}
onAccept={() => acceptDimSuggestion(dim.id!, 'developmentNeeds')}
onDiscard={() => discardDimSuggestion(dim.id!, 'developmentNeeds')}
/>
)}
</div>
</div>
</div>
)}
</div>
)
})}
{/* Hinweis für KI-Nutzung */}
<div className="text-xs text-gray-400 text-center pb-4">
KI-Entwürfe vor dem Einsatz bitte sorgfältig prüfen und anpassen.
</div>
</div>
)
}