CG-Feedback-Monitor/src/pages/MeetingHistory.tsx

202 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
type Assignment, type PhaseTemplate, type MeetingInstance, type Consultant,
type FeedbackCategory, type FeedbackCriterionItem, type ConversationEntry, type Assessment, type FeedbackRating,
getAssignment, getAssignmentType,
listPhaseTemplatesForType,
listDoneMeetingsChronological,
getConsultantsByIds,
listFeedbackCategoriesAll, listFeedbackCriterionItemsAll, filterVisibleFeedbackStructure,
listConversationEntriesForMeetings,
listAssessmentsForMeetings,
} from '../db'
import { NotationText } from '../utils/notationParser'
import { INST_COLORS } from '../config/constants'
import { weightedAverageRating } from '../utils/ratingTrend'
const RATING_BG: Record<FeedbackRating, string> = {
na: 'bg-gray-400 text-white',
not_client_ready: 'bg-red-500 text-white',
partially_client_ready: 'bg-orange-500 text-white',
nearly_client_ready: 'bg-green-400 text-white',
client_ready: 'bg-green-700 text-white',
}
export default function MeetingHistory() {
const { id } = useParams()
const navigate = useNavigate()
const assignmentId = Number(id)
const [assignment, setAssignment] = useState<Assignment | null>(null)
const [templates, setTemplates] = useState<PhaseTemplate[]>([])
const [meetings, setMeetings] = useState<MeetingInstance[]>([])
const [institutees, setInstitutees] = useState<Consultant[]>([])
const [fbCategories, setFbCategories] = useState<FeedbackCategory[]>([])
const [fbItems, setFbItems] = useState<FeedbackCriterionItem[]>([])
const [entries, setEntries] = useState<ConversationEntry[]>([])
const [assessments, setAssessments] = useState<Assessment[]>([])
const [loaded, setLoaded] = useState(false)
const latestRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const load = async () => {
const a = await getAssignment(assignmentId)
if (!a) return
setAssignment(a)
const [aType, tmpls, doneMeetings, cons] = await Promise.all([
getAssignmentType(a.assignmentTypeId),
listPhaseTemplatesForType(a.assignmentTypeId),
listDoneMeetingsChronological(assignmentId),
getConsultantsByIds(a.instituteeIds),
])
setTemplates(tmpls)
setMeetings(doneMeetings)
setInstitutees(a.instituteeIds.map(cid => cons.find(c => c.id === cid)!).filter(Boolean))
const [allCats, allItems] = await Promise.all([
listFeedbackCategoriesAll(),
listFeedbackCriterionItemsAll(),
])
const { categories: visibleCats, items: visibleItems } = filterVisibleFeedbackStructure(allCats, allItems, aType?.criteriaIds)
setFbCategories(visibleCats)
setFbItems(visibleItems)
const meetingIds = doneMeetings.map(m => m.id!)
const [convEntries, asmts] = await Promise.all([
listConversationEntriesForMeetings(meetingIds),
listAssessmentsForMeetings(meetingIds),
])
setEntries(convEntries)
setAssessments(asmts)
setLoaded(true)
}
load()
}, [assignmentId])
useEffect(() => {
if (loaded) latestRef.current?.scrollIntoView({ block: 'start' })
}, [loaded])
const displayName = (inst: Consultant) => {
const dup = institutees.filter(c => c.firstName === inst.firstName && c.id !== inst.id).length > 0
return dup ? `${inst.firstName} ${inst.lastName.charAt(0)}.` : inst.firstName
}
if (!assignment) return <div className="text-center py-10 text-gray-400">Lädt</div>
return (
<div className="space-y-4">
<div>
<button onClick={() => navigate(`/assignment/${assignmentId}`)} className="text-sm text-brand mb-2 block"> Assignment</button>
<h1 className="text-xl font-bold text-gray-800">📜 Gesamtprotokoll</h1>
<div className="text-sm text-gray-500">{assignment.title} · {assignment.client}</div>
</div>
{meetings.length === 0 && (
<p className="text-gray-400 text-sm text-center py-8">Noch keine abgeschlossenen Meetings.</p>
)}
{meetings.map((meeting, idx) => {
const template = templates.find(t => t.id === meeting.phaseTemplateId)
const meetingEntries = entries
.filter(e => e.meetingInstanceId === meeting.id)
.sort((a, b) => a.sequenceIndex - b.sequenceIndex)
const meetingAssessments = assessments.filter(a => a.meetingInstanceId === meeting.id)
const isLatest = idx === meetings.length - 1
return (
<div key={meeting.id}
ref={isLatest ? latestRef : undefined}
className={`bg-white rounded-xl shadow-sm p-4 space-y-3 ${isLatest ? 'border-2 border-brand' : 'border border-gray-100'}`}>
<div className="flex items-center justify-between">
<div>
<div className="font-semibold text-gray-800">
{template?.label ?? 'Meeting'}{template?.isRepeatable ? ` #${meeting.phaseIndex}` : ''}
</div>
<div className="text-xs text-gray-400">{new Date(meeting.date).toLocaleDateString('de-DE')}</div>
</div>
{isLatest && (
<span className="text-xs bg-brand text-white px-2 py-1 rounded-full font-medium flex-shrink-0">
Aktuellstes Meeting
</span>
)}
</div>
{meeting.generalNotes?.trim() && (
<div className="bg-gray-50 rounded-lg px-3 py-2">
<NotationText text={meeting.generalNotes} />
</div>
)}
{meetingEntries.length > 0 && (
<div className="space-y-1.5">
{meetingEntries.map(entry => {
const instIdx = institutees.findIndex(c => c.id === entry.instituteeId)
const inst = institutees[instIdx]
const color = INST_COLORS[instIdx % INST_COLORS.length]
return (
<div key={entry.id}
className={`rounded-lg border px-3 py-2 ${color?.bg ?? 'bg-gray-50'} ${color?.border ?? 'border-gray-200'}`}>
<div className="flex items-start gap-2">
<span className={`flex-shrink-0 text-xs font-bold text-white px-2 py-0.5 rounded mt-0.5 ${color?.tag ?? 'bg-gray-400'}`}>
{inst ? displayName(inst) : '?'}
</span>
<div className="flex-1 min-w-0">
{entry.note && (
<NotationText
text={entry.note}
renderLineAddon={line => {
const critId = entry.lineTags?.find(t => t.text === line)?.criterionItemId
const critName = critId ? fbItems.find(i => i.id === critId)?.name : null
return critName
? <span className="flex-shrink-0 text-xs bg-indigo-50 text-indigo-700 border border-indigo-200 rounded-full px-2 py-0.5 mt-0.5">{critName}</span>
: null
}}
/>
)}
{(entry.fillerCount ?? 0) > 0 && (
<span className="inline-block mt-1 text-xs bg-red-50 text-red-500 border border-red-200 px-1.5 py-0.5 rounded font-medium">
äh×{entry.fillerCount}
</span>
)}
</div>
</div>
</div>
)
})}
</div>
)}
{institutees.length > 0 && fbCategories.length > 0 && meetingAssessments.length > 0 && (
<div className="space-y-1 pt-1 border-t border-gray-50">
{institutees.map(inst => {
const chips = fbCategories.map(cat => {
const catItems = fbItems.filter(i => i.categoryId === cat.id)
const mode = weightedAverageRating(catItems, itemId =>
meetingAssessments.find(a => a.instituteeId === inst.id && a.criteriaId === itemId)?.score ?? null,
)
return mode ? { cat, mode } : null
}).filter(Boolean) as { cat: FeedbackCategory; mode: FeedbackRating }[]
if (chips.length === 0) return null
return (
<div key={inst.id} className="flex items-center gap-1 flex-wrap">
<span className="text-xs text-gray-400">{displayName(inst)}:</span>
{chips.map(({ cat, mode }) => (
<span key={cat.id} className={`text-xs px-2 py-0.5 rounded font-medium ${RATING_BG[mode]}`}>
{cat.name}
</span>
))}
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
)
}