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:
parent
594a643068
commit
63ca6583fa
|
|
@ -14,7 +14,6 @@
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-router-dom": "^7.6.2",
|
"react-router-dom": "^7.6.2",
|
||||||
"dexie": "^4.0.11",
|
"dexie": "^4.0.11",
|
||||||
"zustand": "^5.0.5",
|
|
||||||
"recharts": "^2.15.3"
|
"recharts": "^2.15.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1,110 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
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 => {
|
interface CatScore {
|
||||||
const m: Record<FeedbackRating, number> = { na: 0, not_client_ready: 1, partially_client_ready: 2, nearly_client_ready: 3, client_ready: 4 }
|
category: FeedbackCategory
|
||||||
return r ? (m[r] ?? 0) : 0
|
avg: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InstScore {
|
interface InstScore {
|
||||||
institutee: Consultant
|
institutee: Consultant
|
||||||
assignment: Assignment
|
assignment: Assignment
|
||||||
scores: { criterion: Criterion; avg: number | null }[]
|
catScores: CatScore[]
|
||||||
weightedTotal: number | null
|
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() {
|
export default function Evaluation() {
|
||||||
const [data, setData] = useState<InstScore[]>([])
|
const [data, setData] = useState<InstScore[]>([])
|
||||||
const [categories, setCategories] = useState<Category[]>([])
|
const [dimensions, setDimensions] = useState<FeedbackDimension[]>([])
|
||||||
const [selected, setSelected] = useState<string | null>(null)
|
const [categories, setCategories] = useState<FeedbackCategory[]>([])
|
||||||
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
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.assignments.toArray(),
|
||||||
db.consultants.toArray(),
|
db.consultants.toArray(),
|
||||||
db.criteria.orderBy('order').toArray(),
|
db.feedbackDimensions.orderBy('order').toArray(),
|
||||||
db.categories.orderBy('order').toArray(),
|
db.feedbackCategories.orderBy('order').toArray(),
|
||||||
|
db.feedbackCriterionItems.orderBy('order').toArray(),
|
||||||
db.assessments.toArray(),
|
db.assessments.toArray(),
|
||||||
])
|
])
|
||||||
|
setDimensions(dims)
|
||||||
setCategories(cats)
|
setCategories(cats)
|
||||||
|
|
||||||
const rows: InstScore[] = []
|
const rows: InstScore[] = []
|
||||||
for (const a of assignments) {
|
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) {
|
for (const instId of a.instituteeIds) {
|
||||||
const inst = consultants.find(c => c.id === instId)
|
const inst = consultants.find(c => c.id === instId)
|
||||||
if (!inst) continue
|
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(
|
const instAssessments = assessments.filter(
|
||||||
as => as.instituteeId === instId && meetingIds.includes(as.meetingInstanceId)
|
as => as.instituteeId === instId && meetingIds.includes(as.meetingInstanceId)
|
||||||
)
|
)
|
||||||
if (instAssessments.length === 0) continue
|
if (instAssessments.length === 0) continue
|
||||||
|
|
||||||
const usedCritIds = [...new Set(instAssessments.map(a => a.criteriaId))]
|
const getScore = (itemId: number): FeedbackRating | null => {
|
||||||
const usedCrit = criteria.filter(c => usedCritIds.includes(c.id!))
|
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 catScores: CatScore[] = cats.map(cat => ({
|
||||||
const relevant = instAssessments.filter(a => a.criteriaId === crit.id && a.score !== null)
|
category: cat,
|
||||||
const avg = relevant.length > 0
|
avg: weightedAvg(items.filter(i => i.categoryId === cat.id), getScore),
|
||||||
? relevant.reduce((s, a) => s + ratingToNum(a.score), 0) / relevant.length
|
}))
|
||||||
: null
|
|
||||||
return { criterion: crit, avg }
|
|
||||||
})
|
|
||||||
|
|
||||||
const rated = scores.filter(s => s.avg !== null)
|
const overall = weightedAvg(items, getScore)
|
||||||
const weightedTotal = rated.length > 0
|
if (overall === null && catScores.every(c => c.avg === null)) continue
|
||||||
? rated.reduce((s, x) => s + x.avg! * x.criterion.weight, 0) /
|
|
||||||
rated.reduce((s, x) => s + x.criterion.weight, 0)
|
|
||||||
: null
|
|
||||||
|
|
||||||
rows.push({ institutee: inst, assignment: a, scores, weightedTotal })
|
rows.push({ institutee: inst, assignment: a, catScores, overall })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setData(rows)
|
setData(rows)
|
||||||
|
|
@ -69,13 +112,6 @@ export default function Evaluation() {
|
||||||
load()
|
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}`
|
const rowKey = (r: InstScore) => `${r.assignment.id}-${r.institutee.id}`
|
||||||
|
|
||||||
return (
|
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 key={rowKey(row)} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
|
||||||
<div
|
<div
|
||||||
className="flex items-start justify-between cursor-pointer"
|
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>
|
||||||
<div className="font-semibold text-gray-800">
|
<div className="font-semibold text-gray-800">
|
||||||
{row.institutee.firstName} {row.institutee.lastName}
|
{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 className="text-xs text-gray-400">{row.assignment.title} · {row.assignment.client}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className={`px-3 py-1 rounded-full text-sm font-bold ${scoreColor(row.weightedTotal)}`}>
|
<div className={`px-3 py-1 rounded-full text-sm font-bold ${ratingColor(row.overall)}`}>
|
||||||
{row.weightedTotal !== null ? row.weightedTotal.toFixed(1) : '—'} / 5
|
{ratingLabel(row.overall)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); navigate(`/assignment/${row.assignment.id}/feedback/${row.institutee.id}`) }}
|
onClick={e => { e.stopPropagation(); navigate(`/assignment/${row.assignment.id}/feedback/${row.institutee.id}`) }}
|
||||||
|
|
@ -109,18 +144,24 @@ export default function Evaluation() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selected === rowKey(row) && (
|
{selected === rowKey(row) && (
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-4">
|
||||||
{categories.map(cat => {
|
{dimensions.map(dim => {
|
||||||
const catScores = row.scores.filter(s => s.criterion.categoryId === cat.id)
|
const dimCats = categories.filter(c => c.dimensionId === dim.id)
|
||||||
if (catScores.every(s => s.avg === null)) return null
|
const dimScores = row.catScores.filter(cs =>
|
||||||
|
dimCats.some(c => c.id === cs.category.id) && cs.avg !== null
|
||||||
|
)
|
||||||
|
if (dimScores.length === 0) return null
|
||||||
return (
|
return (
|
||||||
<div key={cat.id}>
|
<div key={dim.id}>
|
||||||
<div className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-2">{cat.name}</div>
|
<div className="text-xs font-semibold uppercase text-gray-400 tracking-wide mb-2">
|
||||||
{catScores.map(({ criterion, avg }) => (
|
{dim.name}
|
||||||
<div key={criterion.id} className="flex items-center justify-between py-1.5 border-b border-gray-50 last:border-0">
|
</div>
|
||||||
<span className="text-sm text-gray-700">{criterion.name}</span>
|
{dimScores.map(({ category, avg }) => (
|
||||||
<span className={`text-sm font-semibold px-2 py-0.5 rounded min-w-[36px] text-center ${scoreColor(avg)}`}>
|
<div key={category.id}
|
||||||
{avg !== null ? avg.toFixed(1) : '—'}
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user