452 lines
20 KiB
TypeScript
452 lines
20 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
import { useNavigate, useParams } from 'react-router-dom'
|
|
import {
|
|
db,
|
|
type Assignment, type MeetingInstance, type PhaseTemplate,
|
|
type FeedbackCategory, type FeedbackCriterionItem, type FeedbackRating,
|
|
type Consultant, type ConversationEntry, type ConversationSkillScore, type Assessment,
|
|
} from '../../db'
|
|
import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../../utils/meetingExport'
|
|
import { NotationText, splitNoteLines } from '../../utils/notationParser'
|
|
import { RATING_NUM_MAP } from '../../config/constants'
|
|
import { ConversationTab } from './ConversationTab'
|
|
import { AssessmentTab } from './AssessmentTab'
|
|
|
|
type Tab = 'conversation' | 'assessment'
|
|
|
|
export default function MeetingView() {
|
|
const { id, meetingId: meetingIdParam } = useParams()
|
|
const navigate = useNavigate()
|
|
const meetingId = Number(meetingIdParam ?? id)
|
|
|
|
const [meeting, setMeeting] = useState<MeetingInstance | null>(null)
|
|
const [assignment, setAssignment] = useState<Assignment | null>(null)
|
|
const [template, setTemplate] = useState<PhaseTemplate | null>(null)
|
|
const [institutees, setInstitutees] = useState<Consultant[]>([])
|
|
const [fbCategories, setFbCategories] = useState<FeedbackCategory[]>([])
|
|
const [fbItems, setFbItems] = useState<FeedbackCriterionItem[]>([])
|
|
const [entries, setEntries] = useState<ConversationEntry[]>([])
|
|
const [scores, setScores] = useState<ConversationSkillScore[]>([])
|
|
const [assessments, setAssessments] = useState<Assessment[]>([])
|
|
const [tab, setTab] = useState<Tab>('conversation')
|
|
const [activeInstId, setActiveInstId] = useState<number | null>(null)
|
|
const [activeEntryId, setActiveEntryId] = useState<number | null>(null)
|
|
const [entryNote, setEntryNote] = useState('')
|
|
const [generalNotes, setGeneralNotes] = useState('')
|
|
const [editingGeneralNotes, setEditingGeneralNotes] = useState(false)
|
|
const [editingId, setEditingId] = useState<number | null>(null)
|
|
const [editNote, setEditNote] = useState('')
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
|
const [showExportMenu, setShowExportMenu] = useState(false)
|
|
const noteRef = useRef<HTMLTextAreaElement>(null)
|
|
const notesTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
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
|
|
}
|
|
|
|
const tagLabel = (inst: Consultant) => displayName(inst)
|
|
|
|
const fillerTotal = (instId: number) =>
|
|
entries.filter(e => e.instituteeId === instId).reduce((s, e) => s + (e.fillerCount ?? 0), 0)
|
|
|
|
const loadData = useCallback(async () => {
|
|
const m = await db.meetingInstances.get(meetingId)
|
|
if (!m) return
|
|
setMeeting(m)
|
|
setGeneralNotes(m.generalNotes ?? '')
|
|
|
|
const [tmpl, a] = await Promise.all([
|
|
db.phaseTemplates.get(m.phaseTemplateId),
|
|
db.assignments.get(m.assignmentId),
|
|
])
|
|
setTemplate(tmpl ?? null)
|
|
setAssignment(a ?? null)
|
|
|
|
if (a) {
|
|
const cons = await db.consultants.where('id').anyOf(a.instituteeIds).toArray()
|
|
setInstitutees(a.instituteeIds.map(cid => cons.find(c => c.id === cid)!).filter(Boolean))
|
|
}
|
|
|
|
const [allCats, allItems, aType] = await Promise.all([
|
|
db.feedbackCategories.toArray(),
|
|
db.feedbackCriterionItems.toArray(),
|
|
a ? db.assignmentTypes.get(a.assignmentTypeId) : Promise.resolve(undefined),
|
|
])
|
|
const itemFilter = aType?.criteriaIds?.length ? new Set(aType.criteriaIds) : null
|
|
const filteredItems = itemFilter ? allItems.filter(i => itemFilter.has(i.id!)) : allItems
|
|
const visibleCatIds = new Set(filteredItems.map(i => i.categoryId))
|
|
setFbItems(filteredItems)
|
|
setFbCategories(itemFilter ? allCats.filter(c => visibleCatIds.has(c.id!)) : allCats)
|
|
|
|
const [convEntries, convScores, asmts] = await Promise.all([
|
|
db.conversationEntries.where('meetingInstanceId').equals(meetingId).sortBy('sequenceIndex'),
|
|
db.conversationSkillScores.toArray(),
|
|
db.assessments.where('meetingInstanceId').equals(meetingId).toArray(),
|
|
])
|
|
setEntries(convEntries)
|
|
setScores(convScores)
|
|
setAssessments(asmts)
|
|
}, [meetingId])
|
|
|
|
useEffect(() => { loadData() }, [loadData])
|
|
|
|
// ── Conversation handlers ─────────────────────────────────────────────────
|
|
|
|
const activateInstitutee = async (instId: number) => {
|
|
if (activeEntryId !== null) {
|
|
if (entryNote.trim() === '') {
|
|
await db.conversationEntries.delete(activeEntryId)
|
|
} else {
|
|
await db.conversationEntries.update(activeEntryId, { note: entryNote, updatedAt: new Date().toISOString() })
|
|
}
|
|
}
|
|
const maxSeq = entries.reduce((m, e) => Math.max(m, e.sequenceIndex), 0)
|
|
const eid = await db.conversationEntries.add({
|
|
meetingInstanceId: meetingId, instituteeId: instId,
|
|
sequenceIndex: maxSeq + 1, note: '', fillerCount: 0, updatedAt: new Date().toISOString(),
|
|
})
|
|
setActiveInstId(instId)
|
|
setActiveEntryId(eid)
|
|
setEntryNote('')
|
|
await loadData()
|
|
setTimeout(() => noteRef.current?.focus(), 50)
|
|
}
|
|
|
|
const insertNotation = (sym: string) => {
|
|
const el = noteRef.current
|
|
if (!el) { setEntryNote(p => p + sym + ' '); return }
|
|
const start = el.selectionStart ?? entryNote.length
|
|
const end = el.selectionEnd ?? entryNote.length
|
|
const next = entryNote.slice(0, start) + sym + ' ' + entryNote.slice(end)
|
|
setEntryNote(next)
|
|
setTimeout(() => { el.selectionStart = el.selectionEnd = start + sym.length + 1; el.focus() }, 0)
|
|
}
|
|
|
|
const saveEntryNote = async () => {
|
|
if (activeEntryId === null) return
|
|
await db.conversationEntries.update(activeEntryId, { note: entryNote, updatedAt: new Date().toISOString() })
|
|
setEntries(prev => prev.map(e => e.id === activeEntryId ? { ...e, note: entryNote } : e))
|
|
}
|
|
|
|
const incrementFiller = async () => {
|
|
if (activeEntryId === null) return
|
|
const entry = entries.find(e => e.id === activeEntryId)
|
|
const newCount = (entry?.fillerCount ?? 0) + 1
|
|
await db.conversationEntries.update(activeEntryId, { fillerCount: newCount })
|
|
setEntries(prev => prev.map(e => e.id === activeEntryId ? { ...e, fillerCount: newCount } : e))
|
|
}
|
|
|
|
const closeActiveEntry = async () => {
|
|
if (activeEntryId !== null && entryNote.trim() === '') {
|
|
await db.conversationEntries.delete(activeEntryId)
|
|
} else {
|
|
await saveEntryNote()
|
|
}
|
|
setActiveInstId(null)
|
|
setActiveEntryId(null)
|
|
setEntryNote('')
|
|
await loadData()
|
|
}
|
|
|
|
const startEdit = (entry: ConversationEntry) => { setEditingId(entry.id!); setEditNote(entry.note) }
|
|
|
|
const saveEdit = async (entryId: number) => {
|
|
const entry = entries.find(e => e.id === entryId)
|
|
const currentLines = splitNoteLines(editNote)
|
|
const prunedTags = (entry?.lineTags ?? []).filter(t => currentLines.includes(t.text))
|
|
await db.conversationEntries.update(entryId, { note: editNote, lineTags: prunedTags, updatedAt: new Date().toISOString() })
|
|
setEntries(prev => prev.map(e => e.id === entryId ? { ...e, note: editNote, lineTags: prunedTags } : e))
|
|
setEditingId(null)
|
|
}
|
|
|
|
const setLineCriterion = async (entryId: number, lineText: string, criterionItemId: number | null) => {
|
|
const entry = entries.find(e => e.id === entryId)
|
|
if (!entry) return
|
|
const currentLines = splitNoteLines(entry.note)
|
|
const kept = (entry.lineTags ?? []).filter(t => t.text !== lineText && currentLines.includes(t.text))
|
|
const nextTags = criterionItemId === null ? kept : [...kept, { text: lineText, criterionItemId }]
|
|
await db.conversationEntries.update(entryId, { lineTags: nextTags })
|
|
setEntries(prev => prev.map(e => e.id === entryId ? { ...e, lineTags: nextTags } : e))
|
|
}
|
|
|
|
const deleteEntry = async (entryId: number) => {
|
|
await db.conversationSkillScores.where('conversationEntryId').equals(entryId).delete()
|
|
await db.conversationEntries.delete(entryId)
|
|
setEntries(prev => prev.filter(e => e.id !== entryId))
|
|
}
|
|
|
|
const adjustFiller = async (entry: ConversationEntry, delta: number) => {
|
|
const newCount = Math.max(0, (entry.fillerCount ?? 0) + delta)
|
|
await db.conversationEntries.update(entry.id!, { fillerCount: newCount })
|
|
setEntries(prev => prev.map(e => e.id === entry.id ? { ...e, fillerCount: newCount } : e))
|
|
}
|
|
|
|
const changeEntrySpeaker = async (entryId: number, instituteeId: number) => {
|
|
await db.conversationEntries.update(entryId, { instituteeId })
|
|
setEntries(prev => prev.map(e => e.id === entryId ? { ...e, instituteeId } : e))
|
|
}
|
|
|
|
const getEntryScore = (entryId: number, categoryId: number): FeedbackRating | null =>
|
|
scores.find(s => s.conversationEntryId === entryId && s.criteriaId === categoryId)?.score ?? null
|
|
|
|
const setEntryScore = async (entryId: number, categoryId: number, score: FeedbackRating) => {
|
|
const existing = scores.find(s => s.conversationEntryId === entryId && s.criteriaId === categoryId)
|
|
if (existing?.id) {
|
|
await db.conversationSkillScores.update(existing.id, { score })
|
|
} else {
|
|
await db.conversationSkillScores.add({ conversationEntryId: entryId, criteriaId: categoryId, score })
|
|
}
|
|
setScores(await db.conversationSkillScores.toArray())
|
|
}
|
|
|
|
// ── Assessment handlers ───────────────────────────────────────────────────
|
|
|
|
const getAssessmentScore = (instituteeId: number, itemId: number): FeedbackRating | null =>
|
|
assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)?.score ?? null
|
|
|
|
const setAssessmentScore = async (instituteeId: number, itemId: number, score: FeedbackRating) => {
|
|
const existing = assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)
|
|
const now = new Date().toISOString()
|
|
if (existing?.id) {
|
|
await db.assessments.update(existing.id, { score, updatedAt: now })
|
|
} else {
|
|
await db.assessments.add({ meetingInstanceId: meetingId, instituteeId, criteriaId: itemId, score, note: '', updatedAt: now })
|
|
}
|
|
setAssessments(await db.assessments.where('meetingInstanceId').equals(meetingId).toArray())
|
|
}
|
|
|
|
const saveAssessmentNote = async (instituteeId: number, itemId: number, note: string) => {
|
|
const existing = assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)
|
|
const now = new Date().toISOString()
|
|
if (existing?.id) {
|
|
await db.assessments.update(existing.id, { note, updatedAt: now })
|
|
} else {
|
|
await db.assessments.add({ meetingInstanceId: meetingId, instituteeId, criteriaId: itemId, score: null, note, updatedAt: now })
|
|
}
|
|
setAssessments(await db.assessments.where('meetingInstanceId').equals(meetingId).toArray())
|
|
}
|
|
|
|
const autoCalcAssessment = async (instituteeId: number) => {
|
|
const instEntries = entries.filter(e => e.instituteeId === instituteeId)
|
|
for (const cat of fbCategories) {
|
|
const vals = instEntries
|
|
.map(e => getEntryScore(e.id!, cat.id!))
|
|
.filter((s): s is FeedbackRating => s !== null && s !== 'na')
|
|
if (vals.length === 0) continue
|
|
const avg = vals.reduce((s, v) => s + RATING_NUM_MAP[v], 0) / vals.length
|
|
const rating: FeedbackRating = avg < 1.5 ? 'not_client_ready' : avg < 2.5 ? 'partially_client_ready' : avg < 3.5 ? 'nearly_client_ready' : 'client_ready'
|
|
for (const item of fbItems.filter(i => i.categoryId === cat.id)) {
|
|
await setAssessmentScore(instituteeId, item.id!, rating)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Meeting-level handlers ────────────────────────────────────────────────
|
|
|
|
const saveGeneralNotes = (val: string) => {
|
|
setGeneralNotes(val)
|
|
if (notesTimer.current) clearTimeout(notesTimer.current)
|
|
notesTimer.current = setTimeout(() => db.meetingInstances.update(meetingId, { generalNotes: val }), 600)
|
|
}
|
|
|
|
const buildFilename = (ext: string) => {
|
|
const date = new Date(meeting?.date ?? '').toLocaleDateString('de-DE').replace(/\./g, '-')
|
|
const label = template?.label ?? 'Meeting'
|
|
const idx = (meeting?.phaseIndex ?? 1) > 1 ? `-${meeting!.phaseIndex}` : ''
|
|
const name = assignment?.assignmentNumber ?? assignment?.title ?? 'export'
|
|
return `${name}_${date}_${label}${idx}.${ext}`
|
|
}
|
|
|
|
const handleExportMd = async () => {
|
|
try { downloadFile(await exportMeetingAsMarkdown(meetingId), buildFilename('md'), 'text/markdown') }
|
|
catch (e) { console.error(e) }
|
|
setShowExportMenu(false)
|
|
}
|
|
|
|
const handleExportJson = async () => {
|
|
try { downloadFile(await exportMeetingAsJson(meetingId), buildFilename('json'), 'application/json') }
|
|
catch (e) { console.error(e) }
|
|
setShowExportMenu(false)
|
|
}
|
|
|
|
const handleDeleteMeeting = async () => {
|
|
await saveEntryNote()
|
|
await db.meetingInstances.update(meetingId, { deletedAt: new Date().toISOString() })
|
|
navigate(`/assignment/${assignment?.id}`)
|
|
}
|
|
|
|
const finishMeeting = async () => {
|
|
await saveEntryNote()
|
|
await db.meetingInstances.update(meetingId, { status: 'done' })
|
|
navigate(`/assignment/${assignment?.id}`)
|
|
}
|
|
|
|
if (!meeting || !template) return <div className="text-center py-10 text-gray-400">Lädt…</div>
|
|
|
|
const hasConv = template.hasConversation
|
|
const hasAssess = template.hasAssessment
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<button onClick={() => navigate(`/assignment/${assignment?.id}`)}
|
|
className="text-xs text-brand mb-0.5 block">← Assignment</button>
|
|
{assignment?.assignmentNumber && (
|
|
<div className="text-xs font-semibold text-brand uppercase tracking-wide leading-none mb-0.5">{assignment.assignmentNumber}</div>
|
|
)}
|
|
<div className="text-sm font-semibold text-gray-600 truncate">{assignment?.title}</div>
|
|
<div className="font-bold text-gray-800 truncate">{template.label}</div>
|
|
<div className="text-xs text-gray-400">
|
|
{assignment?.client} · {new Date(meeting.date).toLocaleDateString('de-DE')}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-1.5 flex-shrink-0 items-start">
|
|
<div className="relative">
|
|
<button onClick={() => setShowExportMenu(p => !p)}
|
|
className="text-sm bg-white border border-gray-200 text-gray-600 px-2.5 py-1.5 rounded-lg font-medium">
|
|
↓ Export
|
|
</button>
|
|
{showExportMenu && (
|
|
<div className="absolute right-0 top-full mt-1 bg-white border border-gray-200 rounded-xl shadow-lg z-10 min-w-[160px] overflow-hidden">
|
|
<button onClick={handleExportMd}
|
|
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 border-b border-gray-100">
|
|
📄 Markdown
|
|
</button>
|
|
<button onClick={handleExportJson}
|
|
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50">
|
|
🗂 JSON-Backup
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button onClick={() => setShowDeleteConfirm(p => !p)}
|
|
title="Meeting löschen"
|
|
className="text-sm bg-white border border-red-200 text-red-400 hover:text-red-600 px-2.5 py-1.5 rounded-lg font-medium">
|
|
🗑
|
|
</button>
|
|
<button onClick={finishMeeting}
|
|
className="text-sm bg-green-600 text-white px-3 py-1.5 rounded-lg font-medium">
|
|
Fertig
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Löschen-Bestätigung */}
|
|
{showDeleteConfirm && (
|
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4 space-y-3">
|
|
<div className="text-sm font-semibold text-red-700">Meeting in den Papierkorb verschieben?</div>
|
|
<div className="text-xs text-red-500">Das Meeting wird in den Papierkorb verschoben und kann dort wiederhergestellt oder endgültig gelöscht werden.</div>
|
|
<div className="flex gap-2">
|
|
<button onClick={handleDeleteMeeting}
|
|
className="flex-1 py-2 rounded-lg text-sm font-medium bg-red-600 text-white">
|
|
Ja, in Papierkorb
|
|
</button>
|
|
<button onClick={() => setShowDeleteConfirm(false)}
|
|
className="px-4 py-2 rounded-lg text-sm border border-gray-200 text-gray-500">
|
|
Abbrechen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Allgemeine Notizen */}
|
|
{editingGeneralNotes ? (
|
|
<textarea
|
|
autoFocus
|
|
className="w-full border border-brand rounded-xl px-3 py-2 text-sm bg-white focus:outline-none resize-none"
|
|
style={{ minHeight: '4rem', fieldSizing: 'content' } as React.CSSProperties}
|
|
placeholder="Allgemeine Notizen zum Meeting… (+ gut, ! negativ, > Kundeninput)"
|
|
value={generalNotes}
|
|
onChange={e => saveGeneralNotes(e.target.value)}
|
|
onBlur={() => setEditingGeneralNotes(false)}
|
|
/>
|
|
) : (
|
|
<div onClick={() => setEditingGeneralNotes(true)}
|
|
className="w-full border border-gray-200 rounded-xl px-3 py-2 text-sm bg-white cursor-text min-h-[2.5rem]">
|
|
{generalNotes.trim()
|
|
? <NotationText text={generalNotes} />
|
|
: <span className="text-gray-300">Allgemeine Notizen zum Meeting…</span>
|
|
}
|
|
</div>
|
|
)}
|
|
|
|
{/* Tabs */}
|
|
{(hasConv || hasAssess) && (
|
|
<div className="flex bg-gray-100 rounded-xl p-1">
|
|
{hasConv && (
|
|
<button onClick={() => setTab('conversation')}
|
|
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${
|
|
tab === 'conversation' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'
|
|
}`}>
|
|
Gesprächsprotokoll
|
|
</button>
|
|
)}
|
|
{hasAssess && (
|
|
<button onClick={() => setTab('assessment')}
|
|
className={`flex-1 py-2 rounded-lg text-sm font-medium transition ${
|
|
tab === 'assessment' ? 'bg-white text-brand shadow-sm' : 'text-gray-500'
|
|
}`}>
|
|
Gesamtbewertung
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{tab === 'conversation' && hasConv && (
|
|
<ConversationTab
|
|
institutees={institutees}
|
|
fbCategories={fbCategories}
|
|
fbItems={fbItems}
|
|
entries={entries}
|
|
activeInstId={activeInstId}
|
|
activeEntryId={activeEntryId}
|
|
entryNote={entryNote}
|
|
editingId={editingId}
|
|
editNote={editNote}
|
|
noteRef={noteRef}
|
|
displayName={displayName}
|
|
tagLabel={tagLabel}
|
|
fillerTotal={fillerTotal}
|
|
getEntryScore={getEntryScore}
|
|
onActivateInstitutee={activateInstitutee}
|
|
onCloseActiveEntry={closeActiveEntry}
|
|
onInsertNotation={insertNotation}
|
|
onEntryNoteChange={setEntryNote}
|
|
onSaveEntryNote={saveEntryNote}
|
|
onIncrementFiller={incrementFiller}
|
|
onSetEntryScore={setEntryScore}
|
|
onSetLineCriterion={setLineCriterion}
|
|
onChangeSpeaker={changeEntrySpeaker}
|
|
onStartEdit={startEdit}
|
|
onSaveEdit={saveEdit}
|
|
onCancelEdit={() => setEditingId(null)}
|
|
onEditNoteChange={setEditNote}
|
|
onDeleteEntry={deleteEntry}
|
|
onAdjustFiller={adjustFiller}
|
|
/>
|
|
)}
|
|
|
|
{tab === 'assessment' && hasAssess && (
|
|
<AssessmentTab
|
|
institutees={institutees}
|
|
fbCategories={fbCategories}
|
|
fbItems={fbItems}
|
|
assessments={assessments}
|
|
entries={entries}
|
|
hasConv={hasConv}
|
|
tagLabel={tagLabel}
|
|
fillerTotal={fillerTotal}
|
|
getAssessmentScore={getAssessmentScore}
|
|
onSetAssessmentScore={setAssessmentScore}
|
|
onSaveAssessmentNote={saveAssessmentNote}
|
|
onAutoCalc={autoCalcAssessment}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|