2026-07-02 10:10:06 +02:00
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'
2026-07-03 11:56:22 +02:00
import { RATING_OPTIONS , RATING_NUM_MAP , AI_CONFIG , DEFAULT_RATING_TREND_WEIGHT_STEP } from '../config/constants'
import { buildGroupMeetingScores , computeTrend , numberToRating , type TrendResult } from '../utils/ratingTrend'
import { splitNoteLines } from '../utils/notationParser'
2026-07-02 10:10:06 +02:00
2026-07-02 10:18:51 +02:00
const ratingToNum = ( r : FeedbackRating | null | undefined ) : number = >
r ? ( RATING_NUM_MAP [ r ] ? ? 0 ) : 0
2026-07-02 10:10:06 +02:00
const ratingColor = ( r : FeedbackRating | undefined ) = >
2026-07-02 10:18:51 +02:00
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
2026-07-03 11:56:22 +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 {
2026-07-02 10:18:51 +02:00
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 ) {
2026-07-02 10:18:51 +02:00
localStorage . setItem ( AI_CONFIG . localStorageKeyApiKey , apiKey )
localStorage . setItem ( AI_CONFIG . localStorageKeyModel , model )
2026-07-02 10:10:06 +02:00
}
// ── Prompt-Builder ────────────────────────────────────────────────────────────
async function buildAiPrompt (
institutee : Consultant ,
assignment : Assignment ,
dimensions : FeedbackDimension [ ] ,
categories : FeedbackCategory [ ] ,
2026-07-03 11:56:22 +02:00
items : FeedbackCriterionItem [ ] ,
2026-07-02 10:10:06 +02:00
ratings : FeedbackCategoryRating [ ] ,
2026-07-03 11:56:22 +02:00
catTrends : Map < number , TrendResult > ,
2026-07-02 10:10:06 +02:00
) : Promise < string > {
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 )
2026-07-03 11:56:22 +02:00
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 ! ) )
2026-07-02 10:10:06 +02:00
const scoreLines = crits . map ( c = > {
2026-07-03 13:54:19 +02:00
const scores = instAssessments . filter ( a = > a . criteriaId === c . id && a . score !== null && a . score !== 'na' )
2026-07-02 10:10:06 +02:00
const avg = scores . length > 0
? ( scores . reduce ( ( s , a ) = > s + ratingToNum ( a . score ) , 0 ) / scores . length ) . toFixed ( 1 )
: '—'
2026-07-03 11:56:22 +02:00
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 ) ,
]
2026-07-02 10:10:06 +02:00
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
2026-07-03 11:56:22 +02:00
. flatMap ( e = > splitNoteLines ( e . note ) . map ( line = > ( {
line , tagged : ( e . lineTags ? ? [ ] ) . some ( t = > t . text === line ) ,
} ) ) )
. filter ( x = > ! x . tagged )
. map ( x = > ` - ${ x . line } ` )
2026-07-02 10:10:06 +02:00
. 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 )
2026-07-02 10:18:51 +02:00
const rating = RATING_OPTIONS . find ( x = > x . value === r ? . rating ) ? . label ? ? 'nicht bewertet'
2026-07-02 10:10:06 +02:00
return ` ${ cat . name } : ${ rating } `
} ) . join ( '\n' )
return ` Dimension " ${ dim . name } ": \ n ${ lines } `
} ) . join ( '\n' )
2026-07-03 11:56:22 +02:00
// 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' )
2026-07-02 10:10:06 +02:00
// 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)' }
2026-07-03 11:56:22 +02:00
PROTOKOLL - NOTIZEN , KEINEM KRITERIUM ZUGEORDNET ( Notation : + gut , ! negativ , > Kundeninput ) :
$ { protocolLines || '(keine nicht zugeordneten Protokolleinträge)' }
2026-07-02 10:10:06 +02:00
KATEGORIE - BEWERTUNGEN :
$ { ratingLines }
2026-07-03 11:56:22 +02:00
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 .
2026-07-02 10:10:06 +02:00
Ausgabe als JSON :
{
"dimensions" : [
{
"name" : "<Dimensionsname>" ,
"achievements" : "<Text>" ,
"developmentNeeds" : "<Text>"
}
]
}
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 < 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 [ expanded , setExpanded ] = useState < Set < number > > ( new Set ( ) )
2026-07-03 11:56:22 +02:00
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 )
const [ aiError , setAiError ] = useState ( '' )
const saveTimer = useRef < Map < string , ReturnType < typeof setTimeout > >> ( 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 )
2026-07-03 11:56:22 +02:00
const meetings = ( await db . meetingInstances . where ( 'assignmentId' ) . equals ( assignmentId ) . toArray ( ) )
. filter ( m = > ! m . deletedAt && m . status === 'done' )
. sort ( ( x , y ) = > new Date ( x . date ) . getTime ( ) - new Date ( y . date ) . getTime ( ) )
const meetingIds = meetings . map ( m = > m . id ! )
const [ meetingAssessments , settings ] = await Promise . all ( [
meetingIds . length > 0 ? db . assessments . where ( 'meetingInstanceId' ) . anyOf ( meetingIds ) . toArray ( ) : Promise . resolve ( [ ] ) ,
db . appSettings . get ( 1 ) ,
] )
const weightStep = settings ? . ratingTrendWeightStep ? ? DEFAULT_RATING_TREND_WEIGHT_STEP
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-02 10:10:06 +02:00
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 )
2026-07-03 11:56:22 +02:00
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-02 10:10:06 +02:00
// ── KI-Generierung ────────────────────────────────────────────────────────
const generateWithAi = async ( ) = > {
if ( ! institutee || ! assignment || ! feedback ? . id ) return
const settings = getAiSettings ( )
if ( ! settings . apiKey ) { setShowAiSettings ( true ) ; return }
setGenerating ( true )
setAiError ( '' )
try {
2026-07-03 11:56:22 +02:00
const prompt = await buildAiPrompt ( institutee , assignment , dimensions , categories , items , ratings , catTrends )
2026-07-02 10:10:06 +02:00
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 < 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 } ` ) }
2026-07-02 10:18:51 +02:00
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"
2026-07-02 10:18:51 +02:00
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 - 4 o mini ( günstig ) < / option >
< option value = "openai/gpt-4o" > GPT - 4 o < / 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 ) } }
2026-07-02 10:18:51 +02:00
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
onClick = { generateWithAi }
disabled = { generating }
className = { ` flex-1 py-3 rounded-xl text-sm font-medium transition ${
generating
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
2026-07-02 10:18:51 +02:00
: 'bg-brand text-white hover:bg-brand-dark'
2026-07-02 10:10:06 +02:00
} ` }>
{ generating ? 'Generiere Entwurf…' : '✦ 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 >
) }
{ /* 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 } ` } >
2026-07-02 10:18:51 +02:00
{ 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 ! )
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" >
2026-07-02 10:18:51 +02:00
{ 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 >
2026-07-03 11:56:22 +02:00
{ /* 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 >
) }
< / div >
)
} ) }
{ /* Textfelder */ }
< div className = "space-y-3 pt-2 border-t border-gray-50" >
< div >
< 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 ) }
/ >
< / div >
< div >
< 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 ) }
/ >
< / 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 >
)
}