CG-Feedback-Monitor/src/pages/AssignmentFeedbackPage.tsx

664 lines
31 KiB
TypeScript
Raw Normal View History

2026-07-02 10:10:06 +02:00
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,
2026-07-07 11:14:00 +02:00
type FeedbackCategoryText,
2026-07-02 10:10:06 +02:00
type FeedbackRating,
2026-07-07 11:14:00 +02:00
type MeetingInstance,
type Assessment,
type ConversationEntry,
2026-07-06 19:08:24 +02:00
getAssignment, getConsultant,
loadFeedbackStructure,
2026-07-07 11:14:00 +02:00
listDoneMeetingsChronological,
2026-07-06 19:08:24 +02:00
listAssessmentsForMeetings, listConversationEntriesForMeetings,
getRatingTrendWeightStep,
getOrCreateAssignmentFeedback, updateAssignmentFeedback,
listFeedbackCategoryRatings, addFeedbackCategoryRating, updateFeedbackCategoryRating,
listFeedbackDimensionTexts, getFeedbackDimensionText, addFeedbackDimensionText, updateFeedbackDimensionText,
2026-07-07 11:14:00 +02:00
listFeedbackCategoryTexts, getFeedbackCategoryText, addFeedbackCategoryText, updateFeedbackCategoryText,
getFeedbackDimensionPrompt,
2026-07-02 10:10:06 +02:00
} from '../db'
2026-07-07 11:14:00 +02:00
import { RATING_OPTIONS, AI_CONFIG } from '../config/constants'
import { buildGroupMeetingScores, computeTrend, numberToRating, type TrendResult } from '../utils/ratingTrend'
2026-07-07 11:14:00 +02:00
import { buildDimensionPromptContext, renderDimensionPrompt, parseDimensionAiResponse } from '../utils/aiPrompt'
2026-07-02 10:10:06 +02:00
const ratingColor = (r: FeedbackRating | undefined) =>
RATING_OPTIONS.find(x => x.value === r)?.color ?? 'bg-gray-50 text-gray-400 border-gray-200'
2026-07-02 10:10:06 +02:00
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
}
2026-07-02 10:10:06 +02:00
function getAiSettings() {
return {
apiKey: localStorage.getItem(AI_CONFIG.localStorageKeyApiKey) ?? '',
model: localStorage.getItem(AI_CONFIG.localStorageKeyModel) ?? AI_CONFIG.defaultModel,
2026-07-02 10:10:06 +02:00
}
}
function saveAiSettings(apiKey: string, model: string) {
localStorage.setItem(AI_CONFIG.localStorageKeyApiKey, apiKey)
localStorage.setItem(AI_CONFIG.localStorageKeyModel, model)
2026-07-02 10:10:06 +02:00
}
2026-07-07 11:14:00 +02:00
/** 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>
)
2026-07-02 10:10:06 +02:00
}
// ─── Komponente ───────────────────────────────────────────────────────────────
export default function AssignmentFeedbackPage() {
const { id, instituteeId } = useParams()
const navigate = useNavigate()
const assignmentId = Number(id)
const instId = Number(instituteeId)
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[]>([])
2026-07-07 11:14:00 +02:00
const [categoryTexts, setCategoryTexts] = useState<FeedbackCategoryText[]>([])
2026-07-02 10:10:06 +02:00
const [expanded, setExpanded] = useState<Set<number>>(new Set())
const [catTrends, setCatTrends] = useState<Map<number, TrendResult>>(new Map())
2026-07-02 10:10:06 +02:00
const [showAiSettings, setShowAiSettings] = useState(false)
const [aiKey, setAiKey] = useState(getAiSettings().apiKey)
const [aiModel, setAiModel] = useState(getAiSettings().model)
const [generating, setGenerating] = useState(false)
2026-07-07 11:14:00 +02:00
const [generationProgress, setGenerationProgress] = useState('')
2026-07-02 10:10:06 +02:00
const [aiError, setAiError] = useState('')
2026-07-07 11:14:00 +02:00
const [dimensionErrors, setDimensionErrors] = useState<Record<number, string>>({})
2026-07-02 10:10:06 +02:00
const saveTimer = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
const load = useCallback(async () => {
2026-07-06 19:08:24 +02:00
const [a, cons, structure] = await Promise.all([
getAssignment(assignmentId),
getConsultant(instId),
loadFeedbackStructure(),
2026-07-02 10:10:06 +02:00
])
setAssignment(a ?? null)
setInstitutee(cons ?? null)
2026-07-06 19:08:24 +02:00
setDimensions(structure.dimensions)
setCategories(structure.categories)
setItems(structure.items)
2026-07-07 11:14:00 +02:00
const { categories: cats, items: itms } = structure
2026-07-02 10:10:06 +02:00
2026-07-06 19:08:24 +02:00
const meetings = await listDoneMeetingsChronological(assignmentId)
const meetingIds = meetings.map(m => m.id!)
2026-07-06 19:08:24 +02:00
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<number, TrendResult>()
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)
2026-07-06 19:08:24 +02:00
const fb = await getOrCreateAssignmentFeedback(assignmentId, instId)
2026-07-02 10:10:06 +02:00
setFeedback(fb ?? null)
if (fb?.id) {
2026-07-07 11:14:00 +02:00
const [rs, txts, catTxts] = await Promise.all([
2026-07-06 19:08:24 +02:00
listFeedbackCategoryRatings(fb.id),
listFeedbackDimensionTexts(fb.id),
2026-07-07 11:14:00 +02:00
listFeedbackCategoryTexts(fb.id),
2026-07-02 10:10:06 +02:00
])
setRatings(rs)
setDimTexts(txts)
2026-07-07 11:14:00 +02:00
setCategoryTexts(catTxts)
2026-07-02 10:10:06 +02:00
}
// Alle Dimensionen initial expandiert
2026-07-07 11:14:00 +02:00
setExpanded(new Set(structure.dimensions.map(d => d.id!)))
2026-07-02 10:10:06 +02:00
}, [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) {
2026-07-06 19:08:24 +02:00
await updateFeedbackCategoryRating(existing.id, { rating })
2026-07-02 10:10:06 +02:00
setRatings(prev => prev.map(r => r.feedbackCategoryId === categoryId ? { ...r, rating } : r))
} else {
2026-07-06 19:08:24 +02:00
const newId = await addFeedbackCategoryRating({
2026-07-02 10:10:06 +02:00
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
2026-07-07 11:14:00 +02:00
// ── Dimension-Text speichern (debounced) ──────────────────────────────────
2026-07-02 10:10:06 +02:00
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 }]
})
2026-07-07 11:14:00 +02:00
const key = `dim-${dimId}-${field}`
2026-07-02 10:10:06 +02:00
const prev = saveTimer.current.get(key)
if (prev) clearTimeout(prev)
saveTimer.current.set(key, setTimeout(async () => {
if (!feedback?.id) return
2026-07-06 19:08:24 +02:00
const existing = await getFeedbackDimensionText(feedback.id, dimId)
2026-07-02 10:10:06 +02:00
if (existing?.id) {
2026-07-06 19:08:24 +02:00
await updateFeedbackDimensionText(existing.id, { [field]: value })
2026-07-02 10:10:06 +02:00
} else {
2026-07-06 19:08:24 +02:00
await addFeedbackDimensionText({
2026-07-02 10:10:06 +02:00
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)
}
2026-07-07 11:14:00 +02:00
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) ──────────────────────────────────
2026-07-02 10:10:06 +02:00
2026-07-07 11:14:00 +02:00
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 },
) => {
2026-07-02 10:10:06 +02:00
if (!institutee || !assignment || !feedback?.id) return
2026-07-07 11:14:00 +02:00
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
2026-07-02 10:10:06 +02:00
const settings = getAiSettings()
if (!settings.apiKey) { setShowAiSettings(true); return }
setGenerating(true)
setAiError('')
2026-07-07 11:14:00 +02:00
setDimensionErrors(prev => {
const next = { ...prev }
for (const d of dimsToRun) delete next[d.id!]
return next
})
2026-07-02 10:10:06 +02:00
2026-07-07 11:14:00 +02:00
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' }))
2026-07-02 10:10:06 +02:00
}
}
2026-07-06 19:08:24 +02:00
await updateAssignmentFeedback(feedback.id, { aiModel: settings.model, updatedAt: new Date().toISOString() })
2026-07-02 10:10:06 +02:00
await load()
} catch (e) {
setAiError(e instanceof Error ? e.message : 'Unbekannter Fehler')
} finally {
setGenerating(false)
2026-07-07 11:14:00 +02:00
setGenerationProgress('')
2026-07-02 10:10:06 +02:00
}
}
2026-07-07 11:14:00 +02:00
const generateAllDimensions = () => runDimensionBatch(dimensions)
const retryFailedDimensions = () => {
const failedIds = new Set(Object.keys(dimensionErrors).map(Number))
runDimensionBatch(dimensions.filter(d => failedIds.has(d.id!)))
}
2026-07-02 10:10:06 +02:00
const toggleFinal = async () => {
if (!feedback?.id) return
const next = feedback.status === 'final' ? 'draft' : 'final'
2026-07-06 19:08:24 +02:00
await updateAssignmentFeedback(feedback.id, { status: next, updatedAt: new Date().toISOString() })
2026-07-02 10:10:06 +02:00
setFeedback(prev => prev ? { ...prev, status: next } : null)
}
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>
2026-07-02 10:10:06 +02:00
<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>
</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"
2026-07-02 10:10:06 +02:00
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">
2026-07-02 10:10:06 +02:00
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
2026-07-07 11:14:00 +02:00
onClick={generateAllDimensions}
2026-07-02 10:10:06 +02:00
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'
2026-07-02 10:10:06 +02:00
}`}>
2026-07-07 11:14:00 +02:00
{generating ? (generationProgress || 'Generiere…') : '✦ KI-Feedback-Entwurf generieren'}
2026-07-02 10:10:06 +02:00
</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>
)}
2026-07-07 11:14:00 +02:00
{!generating && Object.keys(dimensionErrors).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">
<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>
))}
<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>
)}
2026-07-02 10:10:06 +02:00
{/* 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 (
<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 && (
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium ${summaryColor}`}>
{RATING_OPTIONS.find(r => r.value === worstRating)?.short}
2026-07-02 10:10:06 +02:00
</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">
{/* Kategorien */}
{dimCats.map(cat => {
const catItems = items.filter(i => i.categoryId === cat.id)
const currentRating = getRating(cat.id!)
2026-07-07 11:14:00 +02:00
const catText = getCategoryText(cat.id!)
2026-07-02 10:10:06 +02:00
return (
<div key={cat.id} className="space-y-2">
<div className="font-medium text-sm text-gray-700">{cat.name}</div>
{/* Rating-Buttons */}
<div className="flex gap-1 flex-wrap">
{RATING_OPTIONS.map(r => (
2026-07-02 10:10:06 +02:00
<button key={r.value}
onClick={() => setRating(cat.id!, r.value)}
className={`px-2.5 py-1.5 rounded-lg text-xs font-semibold border transition ${
currentRating === r.value
? r.color + ' ring-2 ring-offset-1 ring-current'
: 'bg-gray-50 text-gray-400 border-gray-200 hover:bg-gray-100'
}`}>
{r.short}
</button>
))}
</div>
{/* 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 (
<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">
Vorschlag: {shortLabel} ({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(dim.id!, 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>
)
})()}
2026-07-02 10:10:06 +02:00
{/* 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>
)}
2026-07-07 11:14:00 +02:00
{/* Kategorie-Notiz */}
<div className="space-y-1.5">
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand min-h-[50px]"
placeholder="Kurze Einschätzung zu dieser Kategorie…"
value={catText?.text ?? ''}
onChange={e => saveCategoryText(cat.id!, e.target.value)}
/>
{catText?.suggestedText && (
<AiSuggestionBox
text={catText.suggestedText}
onAccept={() => acceptCategorySuggestion(cat.id!)}
onDiscard={() => discardCategorySuggestion(cat.id!)}
/>
)}
</div>
2026-07-02 10:10:06 +02:00
</div>
)
})}
{/* Textfelder */}
<div className="space-y-3 pt-2 border-t border-gray-50">
2026-07-07 11:14:00 +02:00
<div className="space-y-1.5">
2026-07-02 10:10:06 +02:00
<label className="text-xs font-semibold text-green-700 uppercase tracking-wide block mb-1.5">
Achievements
</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="Konkrete Stärken und positive Beobachtungen…"
value={text?.achievements ?? ''}
onChange={e => saveDimText(dim.id!, 'achievements', e.target.value)}
/>
2026-07-07 11:14:00 +02:00
{text?.suggestedAchievements && (
<AiSuggestionBox
text={text.suggestedAchievements}
onAccept={() => acceptDimSuggestion(dim.id!, 'achievements')}
onDiscard={() => discardDimSuggestion(dim.id!, 'achievements')}
/>
)}
2026-07-02 10:10:06 +02:00
</div>
2026-07-07 11:14:00 +02:00
<div className="space-y-1.5">
2026-07-02 10:10:06 +02:00
<label className="text-xs font-semibold text-amber-700 uppercase tracking-wide block mb-1.5">
Development Needs
</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="Entwicklungsfelder und konkrete Handlungsempfehlungen…"
value={text?.developmentNeeds ?? ''}
onChange={e => saveDimText(dim.id!, 'developmentNeeds', e.target.value)}
/>
2026-07-07 11:14:00 +02:00
{text?.suggestedDevelopmentNeeds && (
<AiSuggestionBox
text={text.suggestedDevelopmentNeeds}
onAccept={() => acceptDimSuggestion(dim.id!, 'developmentNeeds')}
onDiscard={() => discardDimSuggestion(dim.id!, 'developmentNeeds')}
/>
)}
2026-07-02 10:10:06 +02:00
</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>
)
}