refactor: Evaluation.tsx auf feedbackCriterionItems migriert, zustand entfernt

- Liest jetzt feedbackDimensions/feedbackCategories/feedbackCriterionItems statt
  alter categories/criteria-Tabellen
- Korrekte 1-4 Rating-Skala mit RATING_OPTIONS-Labels
- Aggregation via RATING_NUM_MAP aus constants.ts
- zustand aus package.json entfernt (war nicht genutzt)
This commit is contained in:
Lars 2026-07-02 10:58:44 +02:00
parent 594a643068
commit 63ca6583fa
2 changed files with 92 additions and 52 deletions

View File

@ -14,7 +14,6 @@
"react-dom": "^19.0.0",
"react-router-dom": "^7.6.2",
"dexie": "^4.0.11",
"zustand": "^5.0.5",
"recharts": "^2.15.3"
},
"devDependencies": {

View File

@ -1,67 +1,110 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { db, type Assignment, type Consultant, type Category, type Criterion, type FeedbackRating } from '../db'
import {
db,
type Assignment, type Consultant,
type FeedbackDimension, type FeedbackCategory, type FeedbackCriterionItem,
type FeedbackRating,
} from '../db'
import { RATING_OPTIONS, RATING_NUM_MAP } from '../config/constants'
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
interface CatScore {
category: FeedbackCategory
avg: number | null
}
interface InstScore {
institutee: Consultant
assignment: Assignment
scores: { criterion: Criterion; avg: number | null }[]
weightedTotal: number | null
catScores: CatScore[]
overall: number | null
}
function weightedAvg(
items: FeedbackCriterionItem[],
getScore: (itemId: number) => FeedbackRating | null,
): number | null {
const rated = items
.map(i => ({ score: getScore(i.id!), w: i.weight ?? 1 }))
.filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na')
if (rated.length === 0) return null
const wSum = rated.reduce((s, x) => s + x.w, 0)
return rated.reduce((s, x) => s + RATING_NUM_MAP[x.score] * x.w, 0) / wSum
}
function ratingColor(avg: number | null): string {
if (avg === null) return 'bg-gray-100 text-gray-400'
if (avg >= 3.5) return 'bg-green-100 text-green-700'
if (avg >= 2.5) return 'bg-lime-100 text-lime-700'
if (avg >= 1.5) return 'bg-orange-100 text-orange-700'
return 'bg-red-100 text-red-700'
}
function ratingLabel(avg: number | null): string {
if (avg === null) return '—'
const r = RATING_OPTIONS.find(o =>
avg >= 3.5 ? o.value === 'client_ready' :
avg >= 2.5 ? o.value === 'nearly_client_ready' :
avg >= 1.5 ? o.value === 'partially_client_ready' :
o.value === 'not_client_ready'
)
return r ? `${r.short} (${avg.toFixed(1)})` : avg.toFixed(1)
}
export default function Evaluation() {
const [data, setData] = useState<InstScore[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [selected, setSelected] = useState<string | null>(null)
const [data, setData] = useState<InstScore[]>([])
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
const [categories, setCategories] = useState<FeedbackCategory[]>([])
const [selected, setSelected] = useState<string | null>(null)
const navigate = useNavigate()
useEffect(() => {
const load = async () => {
const [assignments, consultants, criteria, cats, assessments] = await Promise.all([
const [assignments, consultants, dims, cats, items, assessments] = await Promise.all([
db.assignments.toArray(),
db.consultants.toArray(),
db.criteria.orderBy('order').toArray(),
db.categories.orderBy('order').toArray(),
db.feedbackDimensions.orderBy('order').toArray(),
db.feedbackCategories.orderBy('order').toArray(),
db.feedbackCriterionItems.orderBy('order').toArray(),
db.assessments.toArray(),
])
setDimensions(dims)
setCategories(cats)
const rows: InstScore[] = []
for (const a of assignments) {
const meetingIds = (await db.meetingInstances
.where('assignmentId').equals(a.id!).toArray()
).map(m => m.id!)
for (const instId of a.instituteeIds) {
const inst = consultants.find(c => c.id === instId)
if (!inst) continue
// Nur Meetings dieses Assignments
const meetingIds = (await db.meetingInstances.where('assignmentId').equals(a.id!).toArray()).map(m => m.id!)
const instAssessments = assessments.filter(
as => as.instituteeId === instId && meetingIds.includes(as.meetingInstanceId)
)
if (instAssessments.length === 0) continue
const usedCritIds = [...new Set(instAssessments.map(a => a.criteriaId))]
const usedCrit = criteria.filter(c => usedCritIds.includes(c.id!))
const getScore = (itemId: number): FeedbackRating | null => {
const relevant = instAssessments.filter(a => a.criteriaId === itemId && a.score !== null)
if (relevant.length === 0) return null
const avg = relevant.reduce((s, a) => s + RATING_NUM_MAP[a.score!], 0) / relevant.length
if (avg < 1.5) return 'not_client_ready'
if (avg < 2.5) return 'partially_client_ready'
if (avg < 3.5) return 'nearly_client_ready'
return 'client_ready'
}
const scores = usedCrit.map(crit => {
const relevant = instAssessments.filter(a => a.criteriaId === crit.id && a.score !== null)
const avg = relevant.length > 0
? relevant.reduce((s, a) => s + ratingToNum(a.score), 0) / relevant.length
: null
return { criterion: crit, avg }
})
const catScores: CatScore[] = cats.map(cat => ({
category: cat,
avg: weightedAvg(items.filter(i => i.categoryId === cat.id), getScore),
}))
const rated = scores.filter(s => s.avg !== null)
const weightedTotal = rated.length > 0
? rated.reduce((s, x) => s + x.avg! * x.criterion.weight, 0) /
rated.reduce((s, x) => s + x.criterion.weight, 0)
: null
const overall = weightedAvg(items, getScore)
if (overall === null && catScores.every(c => c.avg === null)) continue
rows.push({ institutee: inst, assignment: a, scores, weightedTotal })
rows.push({ institutee: inst, assignment: a, catScores, overall })
}
}
setData(rows)
@ -69,13 +112,6 @@ export default function Evaluation() {
load()
}, [])
const scoreColor = (avg: number | null) => {
if (avg === null) return 'bg-gray-100 text-gray-400'
if (avg >= 4) return 'bg-green-100 text-green-700'
if (avg >= 3) return 'bg-yellow-100 text-yellow-700'
return 'bg-red-100 text-red-700'
}
const rowKey = (r: InstScore) => `${r.assignment.id}-${r.institutee.id}`
return (
@ -88,8 +124,7 @@ export default function Evaluation() {
<div key={rowKey(row)} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<div
className="flex items-start justify-between cursor-pointer"
onClick={() => setSelected(selected === rowKey(row) ? null : rowKey(row))}
>
onClick={() => setSelected(selected === rowKey(row) ? null : rowKey(row))}>
<div>
<div className="font-semibold text-gray-800">
{row.institutee.firstName} {row.institutee.lastName}
@ -97,8 +132,8 @@ export default function Evaluation() {
<div className="text-xs text-gray-400">{row.assignment.title} · {row.assignment.client}</div>
</div>
<div className="flex items-center gap-2">
<div className={`px-3 py-1 rounded-full text-sm font-bold ${scoreColor(row.weightedTotal)}`}>
{row.weightedTotal !== null ? row.weightedTotal.toFixed(1) : '—'} / 5
<div className={`px-3 py-1 rounded-full text-sm font-bold ${ratingColor(row.overall)}`}>
{ratingLabel(row.overall)}
</div>
<button
onClick={e => { e.stopPropagation(); navigate(`/assignment/${row.assignment.id}/feedback/${row.institutee.id}`) }}
@ -109,18 +144,24 @@ export default function Evaluation() {
</div>
{selected === rowKey(row) && (
<div className="mt-4 space-y-3">
{categories.map(cat => {
const catScores = row.scores.filter(s => s.criterion.categoryId === cat.id)
if (catScores.every(s => s.avg === null)) return null
<div className="mt-4 space-y-4">
{dimensions.map(dim => {
const dimCats = categories.filter(c => c.dimensionId === dim.id)
const dimScores = row.catScores.filter(cs =>
dimCats.some(c => c.id === cs.category.id) && cs.avg !== null
)
if (dimScores.length === 0) return null
return (
<div key={cat.id}>
<div className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-2">{cat.name}</div>
{catScores.map(({ criterion, avg }) => (
<div key={criterion.id} className="flex items-center justify-between py-1.5 border-b border-gray-50 last:border-0">
<span className="text-sm text-gray-700">{criterion.name}</span>
<span className={`text-sm font-semibold px-2 py-0.5 rounded min-w-[36px] text-center ${scoreColor(avg)}`}>
{avg !== null ? avg.toFixed(1) : '—'}
<div key={dim.id}>
<div className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-2">
{dim.name}
</div>
{dimScores.map(({ category, avg }) => (
<div key={category.id}
className="flex items-center justify-between py-1.5 border-b border-gray-50 last:border-0">
<span className="text-sm text-gray-700">{category.name}</span>
<span className={`text-xs font-semibold px-2 py-0.5 rounded ${ratingColor(avg)}`}>
{ratingLabel(avg)}
</span>
</div>
))}