534 lines
23 KiB
TypeScript
534 lines
23 KiB
TypeScript
|
|
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<FeedbackRating, number> = { 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<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)
|
|||
|
|
|
|||
|
|
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": "<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())
|
|||
|
|
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)
|
|||
|
|
|
|||
|
|
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 <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-[#0070AD] 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>
|
|||
|
|
</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-[#0070AD] 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-[#0070AD] 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={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'
|
|||
|
|
: 'bg-[#0070AD] text-white hover:bg-[#005a8e]'
|
|||
|
|
}`}>
|
|||
|
|
{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}`}>
|
|||
|
|
{RATINGS.find(r => r.value === worstRating)?.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">
|
|||
|
|
{/* 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">
|
|||
|
|
{RATINGS.map(r => (
|
|||
|
|
<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>
|
|||
|
|
|
|||
|
|
{/* 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>
|
|||
|
|
)
|
|||
|
|
}
|