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 } 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(null) const [assignment, setAssignment] = useState(null) const [template, setTemplate] = useState(null) const [institutees, setInstitutees] = useState([]) const [fbCategories, setFbCategories] = useState([]) const [fbItems, setFbItems] = useState([]) const [entries, setEntries] = useState([]) const [scores, setScores] = useState([]) const [assessments, setAssessments] = useState([]) const [tab, setTab] = useState('conversation') const [activeInstId, setActiveInstId] = useState(null) const [activeEntryId, setActiveEntryId] = useState(null) const [entryNote, setEntryNote] = useState('') const [generalNotes, setGeneralNotes] = useState('') const [editingGeneralNotes, setEditingGeneralNotes] = useState(false) const [editingId, setEditingId] = useState(null) const [editNote, setEditNote] = useState('') const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showExportMenu, setShowExportMenu] = useState(false) const noteRef = useRef(null) const notesTimer = useRef | 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) { 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 () => { await saveEntryNote() setActiveInstId(null) setActiveEntryId(null) setEntryNote('') await loadData() } const startEdit = (entry: ConversationEntry) => { setEditingId(entry.id!); setEditNote(entry.note) } const saveEdit = async (entryId: number) => { await db.conversationEntries.update(entryId, { note: editNote, updatedAt: new Date().toISOString() }) setEntries(prev => prev.map(e => e.id === entryId ? { ...e, note: editNote } : e)) setEditingId(null) } 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 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
Lädt…
const hasConv = template.hasConversation const hasAssess = template.hasAssessment return (
{/* Header */}
{assignment?.assignmentNumber && (
{assignment.assignmentNumber}
)}
{assignment?.title}
{template.label}
{assignment?.client} · {new Date(meeting.date).toLocaleDateString('de-DE')}
{showExportMenu && (
)}
{/* Löschen-Bestätigung */} {showDeleteConfirm && (
Meeting in den Papierkorb verschieben?
Das Meeting wird in den Papierkorb verschoben und kann dort wiederhergestellt oder endgültig gelöscht werden.
)} {/* Allgemeine Notizen */} {editingGeneralNotes ? (