refactor: MeetingView in 3 Dateien aufgeteilt (meeting/ Unterordner)
This commit is contained in:
parent
068c4cdcfd
commit
594a643068
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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<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)
|
||||
// accordion: per instituteeId → expanded categoryId
|
||||
const [expandedCat, setExpandedCat] = useState<Record<number, number | null>>({})
|
||||
const noteRef = useRef<HTMLTextAreaElement>(null)
|
||||
const notesTimer = useRef<ReturnType<typeof setTimeout> | 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<FeedbackRating, number> = { 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 <div className="text-center py-10 text-gray-400">Lädt…</div>
|
||||
|
||||
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 (
|
||||
<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">
|
||||
{/* Export-Menü */}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* ── GESPRÄCHSPROTOKOLL ───────────────────────────────────────────── */}
|
||||
{tab === 'conversation' && hasConv && (
|
||||
<div className="space-y-3">
|
||||
|
||||
{/* Aktiver Sprecher-Bereich */}
|
||||
<div className={`bg-white rounded-xl shadow-sm border-2 p-4 space-y-3 ${
|
||||
activeInstId !== null ? (activeColor?.border ?? 'border-brand') : 'border-gray-200'
|
||||
}`}>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-2">Wer spricht?</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{institutees.map((c, idx) => {
|
||||
const color = INST_COLORS[idx % INST_COLORS.length]
|
||||
const isActive = activeInstId === c.id
|
||||
const filler = fillerTotal(c.id!)
|
||||
return (
|
||||
<button key={c.id}
|
||||
onClick={() => activateInstitutee(c.id!)}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-semibold border-2 transition ${
|
||||
isActive
|
||||
? `${color.tag} text-white border-transparent`
|
||||
: `bg-white ${color.text} border-current`
|
||||
}`}>
|
||||
{displayName(c)}
|
||||
{filler > 0 && (
|
||||
<span className={`ml-1.5 text-xs font-normal ${isActive ? 'text-white/80' : 'text-gray-400'}`}>
|
||||
äh×{filler}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{activeInstId !== null && (
|
||||
<button onClick={closeActiveEntry}
|
||||
className="px-3 py-2 rounded-lg text-sm border border-gray-200 text-gray-500">
|
||||
✓ Schließen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeInstId !== null && (
|
||||
<>
|
||||
{/* Notation + Äh-Counter */}
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex gap-1.5 flex-wrap flex-1">
|
||||
{NOTATIONS.map(({ sym, tip }) => (
|
||||
<button key={sym}
|
||||
onClick={() => insertNotation(sym)}
|
||||
title={tip}
|
||||
className="px-2.5 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm font-mono rounded-lg transition">
|
||||
{sym}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={incrementFiller}
|
||||
className={`flex-shrink-0 flex flex-col items-center justify-center w-14 h-14 rounded-xl border-2 border-dashed font-bold transition active:scale-95
|
||||
${activeColor?.text ?? 'text-blue-700'} ${activeColor?.border ?? 'border-blue-300'}
|
||||
hover:bg-gray-50`}>
|
||||
<span className="text-lg leading-none">äh</span>
|
||||
<span className="text-base font-bold leading-none mt-0.5">
|
||||
{activeEntry?.fillerCount ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Notiz */}
|
||||
<textarea
|
||||
ref={noteRef}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand"
|
||||
rows={4}
|
||||
placeholder="Notizen… (+ gut, (!) negativ mit Abstrichen, > Kundeninput, [Slide 3])"
|
||||
value={entryNote}
|
||||
onChange={e => setEntryNote(e.target.value)}
|
||||
onBlur={saveEntryNote}
|
||||
/>
|
||||
|
||||
{/* Bewertungen per Kategorie (optional) */}
|
||||
{fbCategories.length > 0 && activeEntryId !== null && (
|
||||
<details>
|
||||
<summary className="text-xs text-gray-400 cursor-pointer select-none hover:text-gray-600">
|
||||
Kategorie-Bewertung für diesen Beitrag (optional)
|
||||
</summary>
|
||||
<div className="mt-2 space-y-3">
|
||||
{fbCategories.map(cat => {
|
||||
const catItems = fbItems.filter(i => i.categoryId === cat.id)
|
||||
const current = getEntryScore(activeEntryId, cat.id!)
|
||||
return (
|
||||
<div key={cat.id}>
|
||||
<div className="text-xs font-medium text-gray-600 mb-1">{cat.name}</div>
|
||||
{catItems.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-1.5">
|
||||
{catItems.map(i => (
|
||||
<span key={i.id} className="text-xs text-gray-400 bg-gray-50 border border-gray-100 px-1.5 py-0.5 rounded-full">{i.name}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
{RATING_OPTIONS.map(r => (
|
||||
<button key={r.value}
|
||||
onClick={() => setEntryScore(activeEntryId, cat.id!, r.value)}
|
||||
className={`flex-1 py-1.5 rounded text-xs font-bold border transition ${
|
||||
current === r.value ? r.active : r.color
|
||||
}`}>
|
||||
{r.short}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Protokoll-Zeitleiste */}
|
||||
{pastEntries.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide px-1">Protokoll</div>
|
||||
{pastEntries.map(entry => {
|
||||
const instIdx = institutees.findIndex(c => c.id === entry.instituteeId)
|
||||
const inst = institutees[instIdx]
|
||||
const color = INST_COLORS[instIdx % INST_COLORS.length]
|
||||
const scoreChips = fbCategories
|
||||
.map(c => ({ name: c.name, s: getEntryScore(entry.id!, c.id!) }))
|
||||
.filter(x => x.s !== null)
|
||||
const isEditing = editingId === entry.id
|
||||
|
||||
return (
|
||||
<div key={entry.id}
|
||||
className={`rounded-xl border px-3 py-2.5 ${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 ? tagLabel(inst) : '?'}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
className="w-full border border-gray-300 rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:border-brand bg-white"
|
||||
rows={3}
|
||||
value={editNote}
|
||||
onChange={e => setEditNote(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500">Füllwörter:</span>
|
||||
<button onClick={() => adjustFiller(entry, -1)}
|
||||
className="w-7 h-7 rounded-full border border-gray-300 text-gray-600 flex items-center justify-center">−</button>
|
||||
<span className="text-sm font-semibold w-6 text-center">{entry.fillerCount ?? 0}</span>
|
||||
<button onClick={() => adjustFiller(entry, +1)}
|
||||
className="w-7 h-7 rounded-full border border-gray-300 text-gray-600 flex items-center justify-center">+</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => saveEdit(entry.id!)}
|
||||
className="text-xs bg-brand text-white px-3 py-1.5 rounded-lg">Speichern</button>
|
||||
<button onClick={() => setEditingId(null)}
|
||||
className="text-xs border border-gray-200 text-gray-500 px-3 py-1.5 rounded-lg">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{entry.note && (
|
||||
<NotationText text={entry.note} />
|
||||
)}
|
||||
<div className="flex gap-1 flex-wrap mt-1">
|
||||
{(entry.fillerCount ?? 0) > 0 && (
|
||||
<span className="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>
|
||||
)}
|
||||
{scoreChips.map(({ name, s }) => {
|
||||
const ro = RATING_OPTIONS.find(r => r.value === s)
|
||||
return (
|
||||
<span key={name} className={`text-xs px-1.5 py-0.5 rounded border font-medium ${ro?.active ?? 'bg-gray-100 text-gray-500 border-gray-200'}`}>
|
||||
{name.slice(0, 8)}: {ro?.short ?? s}
|
||||
</span>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1 flex-shrink-0">
|
||||
<button onClick={() => startEdit(entry)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 px-1.5 py-1 rounded leading-none">✏️</button>
|
||||
<button onClick={() => deleteEntry(entry.id!)}
|
||||
className="text-xs text-red-300 hover:text-red-500 px-1.5 py-1 rounded leading-none">🗑</button>
|
||||
</div>
|
||||
<span className="text-xs text-gray-300 flex-shrink-0 self-start">#{entry.sequenceIndex}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── GESAMTBEWERTUNG ────────────────────────────────────────────────── */}
|
||||
{tab === 'assessment' && hasAssess && (
|
||||
<div className="space-y-4">
|
||||
{institutees.map((inst, idx) => {
|
||||
const color = INST_COLORS[idx % INST_COLORS.length]
|
||||
const filler = fillerTotal(inst.id!)
|
||||
|
||||
// Helper: weighted average rating for a category (N/A excluded, scale 1-4)
|
||||
const catMode = (cat: FeedbackCategory): FeedbackRating | null => {
|
||||
const numMap: Record<FeedbackRating, number> = { na: 0, not_client_ready: 1, partially_client_ready: 2, nearly_client_ready: 3, client_ready: 4 }
|
||||
const scored = fbItems
|
||||
.filter(i => i.categoryId === cat.id)
|
||||
.map(i => ({ score: getAssessmentScore(inst.id!, i.id!), w: i.weight ?? 1 }))
|
||||
.filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na')
|
||||
if (scored.length === 0) return null
|
||||
const wSum = scored.reduce((s, x) => s + x.w, 0)
|
||||
const avg = scored.reduce((s, x) => s + numMap[x.score] * x.w, 0) / wSum
|
||||
if (avg < 1.5) return 'not_client_ready'
|
||||
if (avg < 2.5) return 'partially_client_ready'
|
||||
if (avg < 3.5) return 'nearly_client_ready'
|
||||
return 'client_ready'
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={inst.id} className={`rounded-xl border-2 ${color.border} p-4 space-y-3 bg-white`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-xs font-bold text-white px-2 py-1 rounded ${color.tag}`}>
|
||||
{tagLabel(inst)}
|
||||
</span>
|
||||
<h3 className={`font-semibold ${color.text}`}>{inst.firstName} {inst.lastName}</h3>
|
||||
{filler > 0 && (
|
||||
<span className="text-xs bg-red-50 text-red-500 border border-red-200 px-2 py-0.5 rounded">
|
||||
äh×{filler}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasConv && (
|
||||
<button onClick={() => autoCalcAssessment(inst.id!)}
|
||||
className="text-xs bg-gray-100 text-gray-600 border border-gray-200 px-3 py-1 rounded-lg">
|
||||
Aus Protokoll
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fbCategories.map(cat => {
|
||||
const catItems = fbItems.filter(i => i.categoryId === cat.id)
|
||||
if (catItems.length === 0) return null
|
||||
const ratedCount = catItems.filter(i => { const s = getAssessmentScore(inst.id!, i.id!); return s !== null && s !== 'na' }).length
|
||||
const isOpen = expandedCat[inst.id!] === cat.id
|
||||
const mode = catMode(cat)
|
||||
const headerRo = mode ? RATING_OPTIONS.find(r => r.value === mode) : null
|
||||
return (
|
||||
<div key={cat.id} className="rounded-xl overflow-hidden border border-gray-100">
|
||||
{/* Category header — colored when rated */}
|
||||
<button
|
||||
onClick={() => setExpandedCat(prev => ({
|
||||
...prev,
|
||||
[inst.id!]: prev[inst.id!] === cat.id ? null : cat.id!,
|
||||
}))}
|
||||
className={`w-full flex items-center justify-between px-3 py-2.5 text-left transition ${
|
||||
headerRo ? headerRo.active : 'bg-white hover:bg-gray-50'
|
||||
}`}>
|
||||
<span className={`text-sm font-medium ${headerRo ? 'text-white' : 'text-gray-700'}`}>{cat.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{!headerRo && ratedCount > 0 && (
|
||||
<span className="text-xs text-brand font-medium">{ratedCount}/{catItems.length}</span>
|
||||
)}
|
||||
<span className={`text-xs ${headerRo ? 'text-white/70' : 'text-gray-300'}`}>{isOpen ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Criterion items */}
|
||||
{isOpen && (
|
||||
<div className="border-t border-gray-100 divide-y divide-gray-50">
|
||||
{catItems.map(item => {
|
||||
const current = getAssessmentScore(inst.id!, item.id!)
|
||||
return (
|
||||
<div key={item.id} className="px-3 py-2.5 space-y-1.5">
|
||||
<div className="text-xs text-gray-600 font-medium">{item.name}</div>
|
||||
<div className="flex gap-1">
|
||||
{RATING_OPTIONS.map(r => (
|
||||
<button key={r.value}
|
||||
onClick={() => setAssessmentScore(inst.id!, item.id!, r.value)}
|
||||
className={`flex-1 py-1.5 rounded-lg text-xs font-semibold border transition ${
|
||||
current === r.value ? r.active : r.color
|
||||
}`}>
|
||||
{r.short}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="px-3 py-2 bg-gray-50">
|
||||
<textarea
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-brand bg-white"
|
||||
rows={1}
|
||||
placeholder="Kommentar zur Kategorie"
|
||||
defaultValue={assessments.find(a => a.instituteeId === inst.id && a.criteriaId === cat.id)?.note ?? ''}
|
||||
onBlur={e => saveAssessmentNote(inst.id!, cat.id!, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
145
src/pages/meeting/AssessmentTab.tsx
Normal file
145
src/pages/meeting/AssessmentTab.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { useState } from 'react'
|
||||
import type {
|
||||
Consultant, FeedbackCategory, FeedbackCriterionItem,
|
||||
Assessment, FeedbackRating,
|
||||
} from '../../db'
|
||||
import { INST_COLORS, RATING_OPTIONS, RATING_NUM_MAP } from '../../config/constants'
|
||||
|
||||
interface Props {
|
||||
institutees: Consultant[]
|
||||
fbCategories: FeedbackCategory[]
|
||||
fbItems: FeedbackCriterionItem[]
|
||||
assessments: Assessment[]
|
||||
hasConv: boolean
|
||||
tagLabel: (inst: Consultant) => string
|
||||
fillerTotal: (instId: number) => number
|
||||
getAssessmentScore: (instituteeId: number, itemId: number) => FeedbackRating | null
|
||||
onSetAssessmentScore: (instituteeId: number, itemId: number, score: FeedbackRating) => void
|
||||
onSaveAssessmentNote: (instituteeId: number, itemId: number, note: string) => void
|
||||
onAutoCalc: (instituteeId: number) => void
|
||||
}
|
||||
|
||||
function catMode(
|
||||
cat: FeedbackCategory,
|
||||
fbItems: FeedbackCriterionItem[],
|
||||
getScore: (itemId: number) => FeedbackRating | null,
|
||||
): FeedbackRating | null {
|
||||
const scored = fbItems
|
||||
.filter(i => i.categoryId === cat.id)
|
||||
.map(i => ({ score: getScore(i.id!), w: i.weight ?? 1 }))
|
||||
.filter((x): x is { score: FeedbackRating; w: number } => x.score !== null && x.score !== 'na')
|
||||
if (scored.length === 0) return null
|
||||
const wSum = scored.reduce((s, x) => s + x.w, 0)
|
||||
const avg = scored.reduce((s, x) => s + RATING_NUM_MAP[x.score] * x.w, 0) / wSum
|
||||
if (avg < 1.5) return 'not_client_ready'
|
||||
if (avg < 2.5) return 'partially_client_ready'
|
||||
if (avg < 3.5) return 'nearly_client_ready'
|
||||
return 'client_ready'
|
||||
}
|
||||
|
||||
export function AssessmentTab({
|
||||
institutees, fbCategories, fbItems, assessments, hasConv,
|
||||
tagLabel, fillerTotal,
|
||||
getAssessmentScore, onSetAssessmentScore, onSaveAssessmentNote, onAutoCalc,
|
||||
}: Props) {
|
||||
const [expandedCat, setExpandedCat] = useState<Record<number, number | null>>({})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{institutees.map((inst, idx) => {
|
||||
const color = INST_COLORS[idx % INST_COLORS.length]
|
||||
const filler = fillerTotal(inst.id!)
|
||||
|
||||
return (
|
||||
<div key={inst.id} className={`rounded-xl border-2 ${color.border} p-4 space-y-3 bg-white`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-xs font-bold text-white px-2 py-1 rounded ${color.tag}`}>
|
||||
{tagLabel(inst)}
|
||||
</span>
|
||||
<h3 className={`font-semibold ${color.text}`}>{inst.firstName} {inst.lastName}</h3>
|
||||
{filler > 0 && (
|
||||
<span className="text-xs bg-red-50 text-red-500 border border-red-200 px-2 py-0.5 rounded">
|
||||
äh×{filler}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasConv && (
|
||||
<button onClick={() => onAutoCalc(inst.id!)}
|
||||
className="text-xs bg-gray-100 text-gray-600 border border-gray-200 px-3 py-1 rounded-lg">
|
||||
Aus Protokoll
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fbCategories.map(cat => {
|
||||
const catItems = fbItems.filter(i => i.categoryId === cat.id)
|
||||
if (catItems.length === 0) return null
|
||||
const ratedCount = catItems.filter(i => {
|
||||
const s = getAssessmentScore(inst.id!, i.id!)
|
||||
return s !== null && s !== 'na'
|
||||
}).length
|
||||
const isOpen = expandedCat[inst.id!] === cat.id
|
||||
const mode = catMode(cat, fbItems, itemId => getAssessmentScore(inst.id!, itemId))
|
||||
const headerRo = mode ? RATING_OPTIONS.find(r => r.value === mode) : null
|
||||
|
||||
return (
|
||||
<div key={cat.id} className="rounded-xl overflow-hidden border border-gray-100">
|
||||
<button
|
||||
onClick={() => setExpandedCat(prev => ({
|
||||
...prev,
|
||||
[inst.id!]: prev[inst.id!] === cat.id ? null : cat.id!,
|
||||
}))}
|
||||
className={`w-full flex items-center justify-between px-3 py-2.5 text-left transition ${
|
||||
headerRo ? headerRo.active : 'bg-white hover:bg-gray-50'
|
||||
}`}>
|
||||
<span className={`text-sm font-medium ${headerRo ? 'text-white' : 'text-gray-700'}`}>{cat.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{!headerRo && ratedCount > 0 && (
|
||||
<span className="text-xs text-brand font-medium">{ratedCount}/{catItems.length}</span>
|
||||
)}
|
||||
<span className={`text-xs ${headerRo ? 'text-white/70' : 'text-gray-300'}`}>{isOpen ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="border-t border-gray-100 divide-y divide-gray-50">
|
||||
{catItems.map(item => {
|
||||
const current = getAssessmentScore(inst.id!, item.id!)
|
||||
return (
|
||||
<div key={item.id} className="px-3 py-2.5 space-y-1.5">
|
||||
<div className="text-xs text-gray-600 font-medium">{item.name}</div>
|
||||
<div className="flex gap-1">
|
||||
{RATING_OPTIONS.map(r => (
|
||||
<button key={r.value}
|
||||
onClick={() => onSetAssessmentScore(inst.id!, item.id!, r.value)}
|
||||
className={`flex-1 py-1.5 rounded-lg text-xs font-semibold border transition ${
|
||||
current === r.value ? r.active : r.color
|
||||
}`}>
|
||||
{r.short}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="px-3 py-2 bg-gray-50">
|
||||
<textarea
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-1.5 text-xs focus:outline-none focus:border-brand bg-white"
|
||||
rows={1}
|
||||
placeholder="Kommentar zur Kategorie"
|
||||
defaultValue={assessments.find(a => a.instituteeId === inst.id && a.criteriaId === cat.id)?.note ?? ''}
|
||||
onBlur={e => onSaveAssessmentNote(inst.id!, cat.id!, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
265
src/pages/meeting/ConversationTab.tsx
Normal file
265
src/pages/meeting/ConversationTab.tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import type React from 'react'
|
||||
import type {
|
||||
Consultant, FeedbackCategory, FeedbackCriterionItem,
|
||||
ConversationEntry, FeedbackRating,
|
||||
} from '../../db'
|
||||
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' },
|
||||
]
|
||||
|
||||
interface Props {
|
||||
institutees: Consultant[]
|
||||
fbCategories: FeedbackCategory[]
|
||||
fbItems: FeedbackCriterionItem[]
|
||||
entries: ConversationEntry[]
|
||||
activeInstId: number | null
|
||||
activeEntryId: number | null
|
||||
entryNote: string
|
||||
editingId: number | null
|
||||
editNote: string
|
||||
noteRef: React.RefObject<HTMLTextAreaElement | null>
|
||||
displayName: (inst: Consultant) => string
|
||||
tagLabel: (inst: Consultant) => string
|
||||
fillerTotal: (instId: number) => number
|
||||
getEntryScore: (entryId: number, categoryId: number) => FeedbackRating | null
|
||||
onActivateInstitutee: (id: number) => void
|
||||
onCloseActiveEntry: () => void
|
||||
onInsertNotation: (sym: string) => void
|
||||
onEntryNoteChange: (note: string) => void
|
||||
onSaveEntryNote: () => void
|
||||
onIncrementFiller: () => void
|
||||
onSetEntryScore: (entryId: number, categoryId: number, score: FeedbackRating) => void
|
||||
onStartEdit: (entry: ConversationEntry) => void
|
||||
onSaveEdit: (entryId: number) => void
|
||||
onCancelEdit: () => void
|
||||
onEditNoteChange: (note: string) => void
|
||||
onDeleteEntry: (entryId: number) => void
|
||||
onAdjustFiller: (entry: ConversationEntry, delta: number) => void
|
||||
}
|
||||
|
||||
export function ConversationTab({
|
||||
institutees, fbCategories, fbItems, entries,
|
||||
activeInstId, activeEntryId, entryNote, editingId, editNote, noteRef,
|
||||
displayName, tagLabel, fillerTotal, getEntryScore,
|
||||
onActivateInstitutee, onCloseActiveEntry, onInsertNotation,
|
||||
onEntryNoteChange, onSaveEntryNote, onIncrementFiller, onSetEntryScore,
|
||||
onStartEdit, onSaveEdit, onCancelEdit, onEditNoteChange, onDeleteEntry, onAdjustFiller,
|
||||
}: Props) {
|
||||
const activeInstIdx = institutees.findIndex(c => c.id === activeInstId)
|
||||
const activeColor = INST_COLORS[activeInstIdx % INST_COLORS.length]
|
||||
const activeEntry = entries.find(e => e.id === activeEntryId)
|
||||
const pastEntries = [...entries].filter(e => e.id !== activeEntryId).reverse()
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Aktiver Sprecher-Bereich */}
|
||||
<div className={`bg-white rounded-xl shadow-sm border-2 p-4 space-y-3 ${
|
||||
activeInstId !== null ? (activeColor?.border ?? 'border-brand') : 'border-gray-200'
|
||||
}`}>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-2">Wer spricht?</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{institutees.map((c, idx) => {
|
||||
const color = INST_COLORS[idx % INST_COLORS.length]
|
||||
const isActive = activeInstId === c.id
|
||||
const filler = fillerTotal(c.id!)
|
||||
return (
|
||||
<button key={c.id}
|
||||
onClick={() => onActivateInstitutee(c.id!)}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-semibold border-2 transition ${
|
||||
isActive
|
||||
? `${color.tag} text-white border-transparent`
|
||||
: `bg-white ${color.text} border-current`
|
||||
}`}>
|
||||
{displayName(c)}
|
||||
{filler > 0 && (
|
||||
<span className={`ml-1.5 text-xs font-normal ${isActive ? 'text-white/80' : 'text-gray-400'}`}>
|
||||
äh×{filler}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{activeInstId !== null && (
|
||||
<button onClick={onCloseActiveEntry}
|
||||
className="px-3 py-2 rounded-lg text-sm border border-gray-200 text-gray-500">
|
||||
✓ Schließen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeInstId !== null && (
|
||||
<>
|
||||
{/* Notation + Äh-Counter */}
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex gap-1.5 flex-wrap flex-1">
|
||||
{NOTATIONS.map(({ sym, tip }) => (
|
||||
<button key={sym}
|
||||
onClick={() => onInsertNotation(sym)}
|
||||
title={tip}
|
||||
className="px-2.5 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 text-sm font-mono rounded-lg transition">
|
||||
{sym}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={onIncrementFiller}
|
||||
className={`flex-shrink-0 flex flex-col items-center justify-center w-14 h-14 rounded-xl border-2 border-dashed font-bold transition active:scale-95
|
||||
${activeColor?.text ?? 'text-blue-700'} ${activeColor?.border ?? 'border-blue-300'}
|
||||
hover:bg-gray-50`}>
|
||||
<span className="text-lg leading-none">äh</span>
|
||||
<span className="text-base font-bold leading-none mt-0.5">
|
||||
{activeEntry?.fillerCount ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Notiz */}
|
||||
<textarea
|
||||
ref={noteRef}
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand"
|
||||
rows={4}
|
||||
placeholder="Notizen… (+ gut, (!) negativ mit Abstrichen, > Kundeninput, [Slide 3])"
|
||||
value={entryNote}
|
||||
onChange={e => onEntryNoteChange(e.target.value)}
|
||||
onBlur={onSaveEntryNote}
|
||||
/>
|
||||
|
||||
{/* Kategorie-Bewertung (optional) */}
|
||||
{fbCategories.length > 0 && activeEntryId !== null && (
|
||||
<details>
|
||||
<summary className="text-xs text-gray-400 cursor-pointer select-none hover:text-gray-600">
|
||||
Kategorie-Bewertung für diesen Beitrag (optional)
|
||||
</summary>
|
||||
<div className="mt-2 space-y-3">
|
||||
{fbCategories.map(cat => {
|
||||
const catItems = fbItems.filter(i => i.categoryId === cat.id)
|
||||
const current = getEntryScore(activeEntryId, cat.id!)
|
||||
return (
|
||||
<div key={cat.id}>
|
||||
<div className="text-xs font-medium text-gray-600 mb-1">{cat.name}</div>
|
||||
{catItems.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-1.5">
|
||||
{catItems.map(i => (
|
||||
<span key={i.id} className="text-xs text-gray-400 bg-gray-50 border border-gray-100 px-1.5 py-0.5 rounded-full">{i.name}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
{RATING_OPTIONS.map(r => (
|
||||
<button key={r.value}
|
||||
onClick={() => onSetEntryScore(activeEntryId, cat.id!, r.value)}
|
||||
className={`flex-1 py-1.5 rounded text-xs font-bold border transition ${
|
||||
current === r.value ? r.active : r.color
|
||||
}`}>
|
||||
{r.short}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Protokoll-Zeitleiste */}
|
||||
{pastEntries.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide px-1">Protokoll</div>
|
||||
{pastEntries.map(entry => {
|
||||
const instIdx = institutees.findIndex(c => c.id === entry.instituteeId)
|
||||
const inst = institutees[instIdx]
|
||||
const color = INST_COLORS[instIdx % INST_COLORS.length]
|
||||
const scoreChips = fbCategories
|
||||
.map(c => ({ name: c.name, s: getEntryScore(entry.id!, c.id!) }))
|
||||
.filter(x => x.s !== null)
|
||||
const isEditing = editingId === entry.id
|
||||
|
||||
return (
|
||||
<div key={entry.id}
|
||||
className={`rounded-xl border px-3 py-2.5 ${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 ? tagLabel(inst) : '?'}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
className="w-full border border-gray-300 rounded-lg px-2 py-1.5 text-sm focus:outline-none focus:border-brand bg-white"
|
||||
rows={3}
|
||||
value={editNote}
|
||||
onChange={e => onEditNoteChange(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-500">Füllwörter:</span>
|
||||
<button onClick={() => onAdjustFiller(entry, -1)}
|
||||
className="w-7 h-7 rounded-full border border-gray-300 text-gray-600 flex items-center justify-center">−</button>
|
||||
<span className="text-sm font-semibold w-6 text-center">{entry.fillerCount ?? 0}</span>
|
||||
<button onClick={() => onAdjustFiller(entry, +1)}
|
||||
className="w-7 h-7 rounded-full border border-gray-300 text-gray-600 flex items-center justify-center">+</button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => onSaveEdit(entry.id!)}
|
||||
className="text-xs bg-brand text-white px-3 py-1.5 rounded-lg">Speichern</button>
|
||||
<button onClick={onCancelEdit}
|
||||
className="text-xs border border-gray-200 text-gray-500 px-3 py-1.5 rounded-lg">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{entry.note && <NotationText text={entry.note} />}
|
||||
<div className="flex gap-1 flex-wrap mt-1">
|
||||
{(entry.fillerCount ?? 0) > 0 && (
|
||||
<span className="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>
|
||||
)}
|
||||
{scoreChips.map(({ name, s }) => {
|
||||
const ro = RATING_OPTIONS.find(r => r.value === s)
|
||||
return (
|
||||
<span key={name} className={`text-xs px-1.5 py-0.5 rounded border font-medium ${ro?.active ?? 'bg-gray-100 text-gray-500 border-gray-200'}`}>
|
||||
{name.slice(0, 8)}: {ro?.short ?? s}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1 flex-shrink-0">
|
||||
<button onClick={() => onStartEdit(entry)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 px-1.5 py-1 rounded leading-none">✏️</button>
|
||||
<button onClick={() => onDeleteEntry(entry.id!)}
|
||||
className="text-xs text-red-300 hover:text-red-500 px-1.5 py-1 rounded leading-none">🗑</button>
|
||||
</div>
|
||||
<span className="text-xs text-gray-300 flex-shrink-0 self-start">#{entry.sequenceIndex}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
422
src/pages/meeting/MeetingView.tsx
Normal file
422
src/pages/meeting/MeetingView.tsx
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
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<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) {
|
||||
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 <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}
|
||||
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}
|
||||
hasConv={hasConv}
|
||||
tagLabel={tagLabel}
|
||||
fillerTotal={fillerTotal}
|
||||
getAssessmentScore={getAssessmentScore}
|
||||
onSetAssessmentScore={setAssessmentScore}
|
||||
onSaveAssessmentNote={saveAssessmentNote}
|
||||
onAutoCalc={autoCalcAssessment}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user