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

202 lines
9.0 KiB
TypeScript
Raw Normal View History

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>
)
}