diff --git a/src/App.tsx b/src/App.tsx index 8f7b5b7..4e0bb11 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,7 +6,7 @@ import Consultants from './pages/Consultants' import AssignmentCreate from './pages/AssignmentCreate' import AssignmentDetail from './pages/AssignmentDetail' import AssignmentEdit from './pages/AssignmentEdit' -import MeetingView from './pages/MeetingView' +import MeetingView from './pages/meeting/MeetingView' import FeedbackDraftPage from './pages/FeedbackDraftPage' import AssignmentFeedbackPage from './pages/AssignmentFeedbackPage' import Evaluation from './pages/Evaluation' diff --git a/src/pages/MeetingView.tsx b/src/pages/MeetingView.tsx deleted file mode 100644 index 3a7a595..0000000 --- a/src/pages/MeetingView.tsx +++ /dev/null @@ -1,745 +0,0 @@ -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 { INST_COLORS, RATING_OPTIONS } from '../config/constants' - -const NOTATIONS = [ - { sym: '+', tip: 'Gut' }, - { sym: '(+)', tip: 'Gut mit Abstrichen' }, - { sym: '!', tip: 'Negativ' }, - { sym: '(!)', tip: 'Negativ mit Abstrichen' }, - { sym: '>', tip: 'Einwand / Kundeninput' }, - { sym: '[…]', tip: 'Slide-Referenz' }, -] - -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) - // accordion: per instituteeId → expanded categoryId - const [expandedCat, setExpandedCat] = useState>({}) - const noteRef = useRef(null) - const notesTimer = useRef | null>(null) - - // Vollständiger Vorname, bei Duplikaten + erster Buchstabe Nachname - 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 - } - - // Tag-Label für farbige Badges im Protokoll - const tagLabel = (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 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 - setFbItems(filteredItems) - const visibleCatIds = new Set(filteredItems.map(i => i.categoryId)) - 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]) - - // ── Sprecher ────────────────────────────────────────────────────────────── - - 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() - } - - // ── Edit / Delete ───────────────────────────────────────────────────────── - - 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)) - } - - // ── Scores ──────────────────────────────────────────────────────────────── - - 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()) - } - - const getEntryScore = (entryId: number, categoryId: number): FeedbackRating | null => - scores.find(s => s.conversationEntryId === entryId && s.criteriaId === categoryId)?.score ?? null - - // ── Gesamtbewertung ─────────────────────────────────────────────────────── - - // Assessment scored per FeedbackCriterionItem.id - 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 getAssessmentScore = (instituteeId: number, itemId: number): FeedbackRating | null => - assessments.find(a => a.instituteeId === instituteeId && a.criteriaId === itemId)?.score ?? null - - 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()) - } - - // Aus Protokoll: Kategorie-Durchschnitt → alle Items der Kategorie setzen - const autoCalcAssessment = async (instituteeId: number) => { - const numMap: Record = { na: 0, not_client_ready: 1, partially_client_ready: 2, nearly_client_ready: 3, client_ready: 4 } - 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 + numMap[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) - } - } - } - - 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 id = assignment?.assignmentNumber ?? assignment?.title ?? 'export' - return `${id}_${date}_${label}${idx}.${ext}` - } - - const handleExportMd = async () => { - try { - const md = await exportMeetingAsMarkdown(meetingId) - downloadFile(md, buildFilename('md'), 'text/markdown') - } catch (e) { console.error(e) } - setShowExportMenu(false) - } - - const handleExportJson = async () => { - try { - const json = await exportMeetingAsJson(meetingId) - downloadFile(json, 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 saveGeneralNotes = (val: string) => { - setGeneralNotes(val) - if (notesTimer.current) clearTimeout(notesTimer.current) - notesTimer.current = setTimeout(() => { - db.meetingInstances.update(meetingId, { generalNotes: val }) - }, 600) - } - - const finishMeeting = async () => { - await saveEntryNote() - await db.meetingInstances.update(meetingId, { status: 'done' }) - navigate(`/assignment/${assignment?.id}`) - } - - const fillerTotal = (instId: number) => - entries.filter(e => e.instituteeId === instId).reduce((s, e) => s + (e.fillerCount ?? 0), 0) - - if (!meeting || !template) return
Lädt…
- - const hasConv = template.hasConversation - const hasAssess = template.hasAssessment - const activeInstIdx = institutees.findIndex(c => c.id === activeInstId) - const activeColor = INST_COLORS[activeInstIdx % INST_COLORS.length] - const pastEntries = [...entries].filter(e => e.id !== activeEntryId).reverse() - const activeEntry = entries.find(e => e.id === activeEntryId) - - return ( -
- {/* Header */} -
-
- - {assignment?.assignmentNumber && ( -
{assignment.assignmentNumber}
- )} -
{assignment?.title}
-
{template.label}
-
- {assignment?.client} · {new Date(meeting.date).toLocaleDateString('de-DE')} -
-
-
- {/* Export-Menü */} -
- - {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 ? ( -