202 lines
9.0 KiB
TypeScript
202 lines
9.0 KiB
TypeScript
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>
|
||
)
|
||
}
|