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-08-08 17:35:05 +02:00
|
|
|
|
type RatingScore,
|
2026-07-07 11:14:00 +02:00
|
|
|
|
type MeetingInstance,
|
|
|
|
|
|
type Assessment,
|
|
|
|
|
|
type ConversationEntry,
|
2026-08-08 17:35:05 +02:00
|
|
|
|
getAssignment, getConsultant, getAssignmentType,
|
|
|
|
|
|
loadFeedbackStructure, resolveVisibleStructure,
|
2026-07-07 11:14:00 +02:00
|
|
|
|
listDoneMeetingsChronological,
|
2026-07-06 19:08:24 +02:00
|
|
|
|
listAssessmentsForMeetings, listConversationEntriesForMeetings,
|
2026-08-08 17:35:05 +02:00
|
|
|
|
listMeetingCategoryRatingsForMeetings,
|
2026-07-06 19:08:24 +02:00
|
|
|
|
getRatingTrendWeightStep,
|
|
|
|
|
|
getOrCreateAssignmentFeedback, updateAssignmentFeedback,
|
2026-08-08 17:35:05 +02:00
|
|
|
|
listFeedbackCategoryRatings, addFeedbackCategoryRating, updateFeedbackCategoryRating, deleteFeedbackCategoryRating,
|
2026-07-06 19:08:24 +02:00
|
|
|
|
listFeedbackDimensionTexts, getFeedbackDimensionText, addFeedbackDimensionText, updateFeedbackDimensionText,
|
2026-07-07 11:14:00 +02:00
|
|
|
|
listFeedbackCategoryTexts, getFeedbackCategoryText, addFeedbackCategoryText, updateFeedbackCategoryText,
|
|
|
|
|
|
getFeedbackDimensionPrompt,
|
2026-08-08 17:35:05 +02:00
|
|
|
|
getOrSeedDefaultCategoryPromptTemplate,
|
|
|
|
|
|
getOrSeedDefaultDimensionPromptTemplate,
|
2026-07-02 10:10:06 +02:00
|
|
|
|
} from '../db'
|
2026-08-08 17:35:05 +02:00
|
|
|
|
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'
|
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-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)
|
2026-08-08 17:35:05 +02:00
|
|
|
|
const colorBands = useRatingColorBands()
|
2026-07-02 10:10:06 +02:00
|
|
|
|
|
|
|
|
|
|
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())
|
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)
|
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-08-08 17:35:05 +02:00
|
|
|
|
const [categoryErrors, setCategoryErrors] = useState<Record<string, string>>({})
|
|
|
|
|
|
const [exportingPdf, setExportingPdf] = useState(false)
|
2026-07-08 09:35:08 +02:00
|
|
|
|
const [previewOpen, setPreviewOpen] = useState<Set<number>>(new Set())
|
|
|
|
|
|
const [previewText, setPreviewText] = useState<Record<number, string>>({})
|
|
|
|
|
|
const [previewLoading, setPreviewLoading] = useState<Set<number>>(new Set())
|
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-08-08 17:35:05 +02:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-07-02 10:10:06 +02:00
|
|
|
|
|
2026-07-06 19:08:24 +02:00
|
|
|
|
const meetings = await listDoneMeetingsChronological(assignmentId)
|
2026-07-03 11:56:22 +02:00
|
|
|
|
const meetingIds = meetings.map(m => m.id!)
|
2026-08-08 17:35:05 +02:00
|
|
|
|
const [meetingAssessments, categoryRatings, weightStep] = await Promise.all([
|
2026-07-06 19:08:24 +02:00
|
|
|
|
listAssessmentsForMeetings(meetingIds),
|
2026-08-08 17:35:05 +02:00
|
|
|
|
listMeetingCategoryRatingsForMeetings(meetingIds),
|
2026-07-06 19:08:24 +02:00
|
|
|
|
getRatingTrendWeightStep(),
|
2026-07-03 11:56:22 +02:00
|
|
|
|
])
|
|
|
|
|
|
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)
|
2026-08-08 17:35:05 +02:00
|
|
|
|
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 })
|
|
|
|
|
|
}
|
2026-07-03 11:56:22 +02:00
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-08 17:35:05 +02:00
|
|
|
|
setExpanded(new Set(visible.dimensions.map(d => d.id!)))
|
2026-07-02 10:10:06 +02:00
|
|
|
|
}, [assignmentId, instId])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => { load() }, [load])
|
|
|
|
|
|
|
|
|
|
|
|
// ── Rating setzen ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-08-08 17:35:05 +02:00
|
|
|
|
const setRating = async (categoryId: number, rating: RatingScore | null) => {
|
2026-07-02 10:10:06 +02:00
|
|
|
|
if (!feedback?.id) return
|
|
|
|
|
|
const existing = ratings.find(r => r.feedbackCategoryId === categoryId)
|
2026-08-08 17:35:05 +02:00
|
|
|
|
if (rating === null) {
|
|
|
|
|
|
if (existing?.id) {
|
|
|
|
|
|
await deleteFeedbackCategoryRating(existing.id)
|
|
|
|
|
|
setRatings(prev => prev.filter(r => r.feedbackCategoryId !== categoryId))
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-02 10:10:06 +02:00
|
|
|
|
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 }])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-08 17:35:05 +02:00
|
|
|
|
const getRating = (categoryId: number): RatingScore | null =>
|
|
|
|
|
|
ratings.find(r => r.feedbackCategoryId === categoryId)?.rating ?? null
|
2026-07-02 10:10:06 +02:00
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-08-08 17:35:05 +02:00
|
|
|
|
const appendTrendSentence = (cat: FeedbackCategory, trend: TrendResult) => {
|
2026-07-03 11:56:22 +02:00
|
|
|
|
if (trend.trend !== 'up' && trend.trend !== 'down') return
|
2026-08-08 17:35:05 +02:00
|
|
|
|
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))
|
2026-07-03 11:56:22 +02:00
|
|
|
|
const verb = trend.trend === 'up' ? 'Verbesserung' : 'Verschlechterung'
|
|
|
|
|
|
const sentence = `Zeigt im Verlauf des Assignments in ${cat.name} eine ${verb} von ${firstLabel} zu ${lastLabel}.`
|
2026-08-08 17:35:05 +02:00
|
|
|
|
const existing = getCategoryText(cat.id!)?.[field] ?? ''
|
|
|
|
|
|
saveCategoryField(cat.id!, field, existing ? `${existing}\n${sentence}` : sentence)
|
2026-07-03 11:56:22 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
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]: '' })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-08 17:35:05 +02:00
|
|
|
|
// ── Kategorie Benefits/Concerns 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)
|
|
|
|
|
|
|
2026-08-08 17:35:05 +02:00
|
|
|
|
const saveCategoryField = (catId: number, field: 'benefits' | 'concerns', value: string) => {
|
2026-07-07 11:14:00 +02:00
|
|
|
|
setCategoryTexts(prev => {
|
|
|
|
|
|
const existing = prev.find(t => t.feedbackCategoryId === catId)
|
2026-08-08 17:35:05 +02:00
|
|
|
|
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,
|
|
|
|
|
|
}]
|
2026-07-07 11:14:00 +02:00
|
|
|
|
})
|
2026-08-08 17:35:05 +02:00
|
|
|
|
const key = `cat-${catId}-${field}`
|
2026-07-07 11:14:00 +02:00
|
|
|
|
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) {
|
2026-08-08 17:35:05 +02:00
|
|
|
|
await updateFeedbackCategoryText(existing.id, { [field]: value })
|
2026-07-07 11:14:00 +02:00
|
|
|
|
} else {
|
2026-08-08 17:35:05 +02:00
|
|
|
|
await addFeedbackCategoryText({
|
|
|
|
|
|
assignmentFeedbackId: feedback.id,
|
|
|
|
|
|
feedbackCategoryId: catId,
|
|
|
|
|
|
benefits: field === 'benefits' ? value : '',
|
|
|
|
|
|
concerns: field === 'concerns' ? value : '',
|
|
|
|
|
|
})
|
2026-07-07 11:14:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
}, 600))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-08 17:35:05 +02:00
|
|
|
|
const acceptCategorySuggestion = (catId: number, field: 'benefits' | 'concerns') => {
|
|
|
|
|
|
const row = getCategoryText(catId)
|
|
|
|
|
|
const value = field === 'benefits' ? row?.suggestedBenefits : row?.suggestedConcerns
|
2026-07-07 11:14:00 +02:00
|
|
|
|
if (!value) return
|
2026-08-08 17:35:05 +02:00
|
|
|
|
saveCategoryField(catId, field, value)
|
|
|
|
|
|
discardCategorySuggestion(catId, field)
|
2026-07-07 11:14:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-08 17:35:05 +02:00
|
|
|
|
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))
|
2026-07-07 11:14:00 +02:00
|
|
|
|
if (!feedback?.id) return
|
|
|
|
|
|
const existing = await getFeedbackCategoryText(feedback.id, catId)
|
2026-08-08 17:35:05 +02:00
|
|
|
|
if (existing?.id) await updateFeedbackCategoryText(existing.id, { [sugField]: '' })
|
2026-07-07 11:14:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-08 09:35:08 +02:00
|
|
|
|
// ── 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 {
|
2026-08-08 17:35:05 +02:00
|
|
|
|
const [meetings, categoryTemplate, dimensionTemplate] = await Promise.all([
|
|
|
|
|
|
listDoneMeetingsChronological(assignmentId),
|
|
|
|
|
|
getOrSeedDefaultCategoryPromptTemplate(),
|
|
|
|
|
|
getOrSeedDefaultDimensionPromptTemplate(),
|
|
|
|
|
|
])
|
2026-07-08 09:35:08 +02:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-08-08 17:35:05 +02:00
|
|
|
|
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}`
|
2026-07-08 09:35:08 +02:00
|
|
|
|
setPreviewText(prev => ({ ...prev, [dim.id!]: rendered }))
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setPreviewLoading(prev => { const next = new Set(prev); next.delete(dim.id!); return next })
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-08 17:35:05 +02:00
|
|
|
|
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) ─────────────────
|
2026-07-07 11:14:00 +02:00
|
|
|
|
|
2026-08-08 17:35:05 +02:00
|
|
|
|
const generateOneCategory = async (
|
2026-07-07 11:14:00 +02:00
|
|
|
|
dim: FeedbackDimension,
|
2026-08-08 17:35:05 +02:00
|
|
|
|
cat: FeedbackCategory,
|
2026-07-07 11:14:00 +02:00
|
|
|
|
meetings: MeetingInstance[],
|
|
|
|
|
|
instAssessments: Assessment[],
|
|
|
|
|
|
instEntries: ConversationEntry[],
|
2026-08-08 17:35:05 +02:00
|
|
|
|
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[],
|
2026-07-07 11:14:00 +02:00
|
|
|
|
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 promptCfg = await getFeedbackDimensionPrompt(dim.id!)
|
|
|
|
|
|
const template = promptCfg?.promptTemplate ?? AI_CONFIG.defaultDimensionPromptTemplate
|
2026-08-08 17:35:05 +02:00
|
|
|
|
const context = buildDimensionCondenseContext({
|
|
|
|
|
|
institutee, assignment, dimension: dim, categoryResults,
|
2026-07-07 11:14:00 +02:00
|
|
|
|
})
|
2026-08-08 17:35:05 +02:00
|
|
|
|
const prompt = renderPrompt(template, context)
|
|
|
|
|
|
const text = await callOpenRouter(prompt, settings)
|
|
|
|
|
|
const parsed = parseDimensionCondenseResponse(text)
|
2026-07-07 11:14:00 +02:00
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2026-08-08 17:35:05 +02:00
|
|
|
|
}
|
2026-07-07 11:14:00 +02:00
|
|
|
|
|
2026-08-08 17:35:05 +02:00
|
|
|
|
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}`)
|
2026-07-07 11:14:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-08 17:35:05 +02:00
|
|
|
|
|
|
|
|
|
|
setGenerationProgress(`Dimension ${dimIndex + 1}/${dimTotal}: ${dim.name} verdichten…`)
|
|
|
|
|
|
await generateDimensionCondense(dim, categoryResults, settings)
|
2026-07-07 11:14:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** 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-08-08 17:35:05 +02:00
|
|
|
|
setCategoryErrors({})
|
2026-07-02 10:10:06 +02:00
|
|
|
|
|
2026-07-07 11:14:00 +02:00
|
|
|
|
try {
|
2026-08-08 17:35:05 +02:00
|
|
|
|
const [meetings, categoryTemplate] = await Promise.all([
|
|
|
|
|
|
listDoneMeetingsChronological(assignmentId),
|
|
|
|
|
|
getOrSeedDefaultCategoryPromptTemplate(),
|
|
|
|
|
|
])
|
2026-07-07 11:14:00 +02:00
|
|
|
|
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 {
|
2026-08-08 17:35:05 +02:00
|
|
|
|
await generateOneDimension(
|
|
|
|
|
|
dim, i, dimsToRun.length, meetings, instAssessments, instEntries, categoryTemplate, settings,
|
|
|
|
|
|
)
|
2026-07-07 11:14:00 +02:00
|
|
|
|
} catch (e) {
|
2026-08-08 17:35:05 +02:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-08 17:35:05 +02:00
|
|
|
|
const handleExportPdf = async () => {
|
|
|
|
|
|
setExportingPdf(true)
|
|
|
|
|
|
try {
|
|
|
|
|
|
await exportInstituteeFeedbackPdf(assignmentId, instId)
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setExportingPdf(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 10:10:06 +02:00
|
|
|
|
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>
|
2026-08-08 17:35:05 +02:00
|
|
|
|
<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>
|
2026-07-02 10:10:06 +02:00
|
|
|
|
</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-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) }}
|
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
|
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'
|
2026-07-02 10:18:51 +02:00
|
|
|
|
: '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-08-08 17:35:05 +02:00
|
|
|
|
{!generating && (Object.keys(dimensionErrors).length > 0 || Object.keys(categoryErrors).length > 0) && (
|
2026-07-07 11:14:00 +02:00
|
|
|
|
<div className="text-xs text-red-600 bg-red-50 border border-red-200 rounded-lg px-3 py-2 space-y-1.5">
|
2026-08-08 17:35:05 +02:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-07-07 11:14:00 +02:00
|
|
|
|
</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!)
|
|
|
|
|
|
|
2026-08-08 17:35:05 +02:00
|
|
|
|
const catRatings = dimCats.map(c => getRating(c.id!)).filter(isValidRatingScore)
|
|
|
|
|
|
const worstRating = catRatings.length > 0 ? Math.min(...catRatings) : null
|
|
|
|
|
|
const summaryStyle = resolveScoreStyle(worstRating, colorBands)
|
2026-07-02 10:10:06 +02:00
|
|
|
|
|
|
|
|
|
|
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>
|
2026-08-08 17:35:05 +02:00
|
|
|
|
{worstRating !== null && (
|
|
|
|
|
|
<span className={`text-xs px-2 py-0.5 rounded-full border font-medium tabular-nums ${summaryStyle.color}`}>
|
|
|
|
|
|
{summaryStyle.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">
|
2026-07-08 09:35:08 +02:00
|
|
|
|
{/* 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>
|
|
|
|
|
|
|
2026-07-02 10:10:06 +02:00
|
|
|
|
{/* 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>
|
|
|
|
|
|
|
2026-08-08 17:35:05 +02:00
|
|
|
|
<ScorePicker
|
|
|
|
|
|
value={currentRating ?? null}
|
|
|
|
|
|
onChange={score => setRating(cat.id!, score)}
|
|
|
|
|
|
bands={colorBands}
|
|
|
|
|
|
/>
|
2026-07-02 10:10:06 +02:00
|
|
|
|
|
2026-07-03 11:56:22 +02:00
|
|
|
|
{(() => {
|
|
|
|
|
|
const trend = catTrends.get(cat.id!)
|
|
|
|
|
|
if (!trend || trend.suggestion === null) return null
|
|
|
|
|
|
const suggestedRating = numberToRating(trend.suggestion)
|
2026-08-08 17:35:05 +02:00
|
|
|
|
if (suggestedRating === null) return null
|
2026-07-03 11:56:22 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex items-center gap-2 flex-wrap">
|
2026-08-08 17:35:05 +02:00
|
|
|
|
<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)}
|
2026-07-03 11:56:22 +02:00
|
|
|
|
</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
|
2026-08-08 17:35:05 +02:00
|
|
|
|
onClick={() => appendTrendSentence(cat, trend)}
|
2026-07-03 11:56:22 +02:00
|
|
|
|
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
|
|
|
|
|
2026-08-08 17:35:05 +02:00
|
|
|
|
{/* 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)}
|
2026-07-07 11:14:00 +02:00
|
|
|
|
/>
|
2026-08-08 17:35:05 +02:00
|
|
|
|
{catText?.suggestedConcerns && (
|
|
|
|
|
|
<AiSuggestionBox
|
|
|
|
|
|
text={catText.suggestedConcerns}
|
|
|
|
|
|
onAccept={() => acceptCategorySuggestion(cat.id!, 'concerns')}
|
|
|
|
|
|
onDiscard={() => discardCategorySuggestion(cat.id!, 'concerns')}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-07-07 11:14:00 +02:00
|
|
|
|
</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">
|
2026-08-08 17:35:05 +02:00
|
|
|
|
Benefits (Dimension)
|
2026-07-02 10:10:06 +02:00
|
|
|
|
</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]"
|
2026-08-08 17:35:05 +02:00
|
|
|
|
placeholder="3–5 Hauptstärken dieser Dimension…"
|
2026-07-02 10:10:06 +02:00
|
|
|
|
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">
|
2026-08-08 17:35:05 +02:00
|
|
|
|
Concerns (Dimension)
|
2026-07-02 10:10:06 +02:00
|
|
|
|
</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]"
|
2026-08-08 17:35:05 +02:00
|
|
|
|
placeholder="3–5 zentrale Entwicklungsfelder dieser Dimension…"
|
2026-07-02 10:10:06 +02:00
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|