import { useEffect, useState } from 'react' import { useNavigate, useParams } from 'react-router-dom' import { db, type Assignment, type AssignmentType, type PhaseTemplate, type MeetingInstance, type Consultant, type Assessment, type FeedbackRating, type FeedbackCategory, type FeedbackCriterionItem } from '../db' import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../utils/meetingExport' const RATING_BG: Record = { na: 'bg-gray-400 text-white', not_client_ready: 'bg-red-500 text-white', partially_client_ready: 'bg-orange-500 text-white', nearly_client_ready: 'bg-green-400 text-white', client_ready: 'bg-green-700 text-white', } const NUM_MAP: Record = { na: 0, not_client_ready: 1, partially_client_ready: 2, nearly_client_ready: 3, client_ready: 4 } const avgToRating = (avg: number): FeedbackRating => avg < 1.5 ? 'not_client_ready' : avg < 2.5 ? 'partially_client_ready' : avg < 3.5 ? 'nearly_client_ready' : 'client_ready' interface PhaseRow { template: PhaseTemplate instances: MeetingInstance[] } export default function AssignmentDetail() { const { id } = useParams() const navigate = useNavigate() const assignmentId = Number(id) const [assignment, setAssignment] = useState(null) const [assignmentType, setAssignmentType] = useState(null) const [phaseRows, setPhaseRows] = useState([]) const [allTemplates, setAllTemplates] = useState([]) const [institutees, setInstitutees] = useState([]) const [deletedInstances, setDeletedInstances] = useState([]) const [showAdHoc, setShowAdHoc] = useState(false) const [showTrash, setShowTrash] = useState(false) const [exportMenuId, setExportMenuId] = useState(null) const [confirmSoftDelete, setConfirmSoftDelete] = useState(null) const [confirmHardDelete, setConfirmHardDelete] = useState(null) const [allAssessments, setAllAssessments] = useState([]) const [fbCategories, setFbCategories] = useState([]) const [fbItems, setFbItems] = useState([]) const load = async () => { const a = await db.assignments.get(assignmentId) if (!a) return setAssignment(a) const [aType, templates, instances, cons] = await Promise.all([ db.assignmentTypes.get(a.assignmentTypeId), db.phaseTemplates.where('assignmentTypeId').equals(a.assignmentTypeId).sortBy('order'), db.meetingInstances.where('assignmentId').equals(assignmentId).toArray(), db.consultants.where('id').anyOf(a.instituteeIds).toArray(), ]) setAssignmentType(aType ?? null) setAllTemplates(templates) setInstitutees(a.instituteeIds.map(cid => cons.find(c => c.id === cid)!).filter(Boolean)) const active = instances.filter(i => !i.deletedAt) const deleted = instances.filter(i => !!i.deletedAt) setDeletedInstances(deleted) setPhaseRows(templates.map(t => ({ template: t, instances: active.filter(i => i.phaseTemplateId === t.id).sort((a, b) => a.phaseIndex - b.phaseIndex), }))) const instIds = active.map(i => i.id!) const [asmts, allCats, allItems, aType2] = await Promise.all([ instIds.length > 0 ? db.assessments.where('meetingInstanceId').anyOf(instIds).toArray() : Promise.resolve([]), db.feedbackCategories.toArray(), db.feedbackCriterionItems.toArray(), db.assignmentTypes.get(a.assignmentTypeId), ]) setAllAssessments(asmts) const itemFilter = aType2?.criteriaIds?.length ? new Set(aType2.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) } useEffect(() => { load() }, [assignmentId]) const startMeeting = async (templateId: number, existingCount: number) => { const mid = await db.meetingInstances.add({ assignmentId, phaseTemplateId: templateId, phaseIndex: existingCount + 1, date: new Date().toISOString().slice(0, 10), status: 'active', generalNotes: '', }) navigate(`/assignment/${assignmentId}/meeting/${mid}`) } const buildFilename = (inst: MeetingInstance, ext: string) => { const tmpl = allTemplates.find(t => t.id === inst.phaseTemplateId) const date = new Date(inst.date).toLocaleDateString('de-DE').replace(/\./g, '-') const label = tmpl?.label ?? 'Meeting' const idx = inst.phaseIndex > 1 ? `-${inst.phaseIndex}` : '' const id = assignment?.assignmentNumber ?? assignment?.title ?? 'export' return `${id}_${date}_${label}${idx}.${ext}` } const handleExportMd = async (inst: MeetingInstance) => { const md = await exportMeetingAsMarkdown(inst.id!) downloadFile(md, buildFilename(inst, 'md'), 'text/markdown') setExportMenuId(null) } const handleExportJson = async (inst: MeetingInstance) => { const json = await exportMeetingAsJson(inst.id!) downloadFile(json, buildFilename(inst, 'json'), 'application/json') setExportMenuId(null) } const softDeleteMeeting = async (instId: number) => { await db.meetingInstances.update(instId, { deletedAt: new Date().toISOString() }) setConfirmSoftDelete(null) await load() } const restoreMeeting = async (instId: number) => { await db.meetingInstances.update(instId, { deletedAt: undefined }) await load() } const hardDeleteMeeting = async (instId: number) => { await db.conversationSkillScores .where('conversationEntryId') .anyOf((await db.conversationEntries.where('meetingInstanceId').equals(instId).primaryKeys())) .delete() await db.conversationEntries.where('meetingInstanceId').equals(instId).delete() await db.assessments.where('meetingInstanceId').equals(instId).delete() await db.meetingInstances.delete(instId) setConfirmHardDelete(null) await load() } const statusBadge = (status: MeetingInstance['status']) => { const map = { planned: 'bg-yellow-100 text-yellow-700', active: 'bg-green-100 text-green-700', done: 'bg-gray-100 text-gray-500', } const label = { planned: 'Geplant', active: 'Aktiv', done: 'Fertig' } return {label[status]} } if (!assignment) return
Lädt…
return (
{assignment.assignmentNumber && (
{assignment.assignmentNumber}
)}

{assignment.title}

{assignment.client} · {assignmentType?.name}
{institutees.map(c => `${c.firstName} ${c.lastName}`).join(', ')}
{/* Ad-hoc Meeting */}
Ad-hoc Meeting starten
{showAdHoc && (
{allTemplates.map(t => ( ))}
)}
{/* Feedback-Entwürfe */} {institutees.length > 0 && (
Feedback-Entwürfe
{institutees.map(c => ( ))}
)} {/* Phasen-Flow */}
setExportMenuId(null)}> {phaseRows.map(({ template, instances }) => { const canStart = instances.length === 0 || template.isRepeatable return (
{template.label} {template.withWhom === 'client' ? 'Kunde' : 'Lead'} {template.hasConversation && ( Protokoll )}
{instances.map(inst => (
{ if (confirmSoftDelete !== inst.id) navigate(`/assignment/${assignmentId}/meeting/${inst.id}`) }} className="flex items-start justify-between py-2 px-3 bg-gray-50 rounded-lg cursor-pointer hover:bg-gray-100 gap-2">
{template.isRepeatable ? `#${inst.phaseIndex} · ` : ''} {new Date(inst.date).toLocaleDateString('de-DE')}
{/* Per-institutee per-category chips */} {institutees.map(c => { const chips = fbCategories.map(cat => { const catItems = fbItems.filter(i => i.categoryId === cat.id) const scored = catItems.map(item => { const asmt = allAssessments.find(a => a.meetingInstanceId === inst.id && a.instituteeId === c.id && a.criteriaId === item.id && a.score !== null && a.score !== 'na' ) return asmt ? { score: asmt.score as FeedbackRating, w: item.weight ?? 1 } : null }).filter(Boolean) as { score: FeedbackRating; w: number }[] if (scored.length === 0) return null const wSum = scored.reduce((s, x) => s + x.w, 0) const avg = scored.reduce((s, x) => s + NUM_MAP[x.score] * x.w, 0) / wSum return { cat, mode: avgToRating(avg) } }).filter(Boolean) as { cat: FeedbackCategory; mode: FeedbackRating }[] if (chips.length === 0) return null return (
{c.firstName}: {chips.map(({ cat, mode }) => ( {cat.name} ))}
) })}
{statusBadge(inst.status)}
{exportMenuId === inst.id && (
)} {confirmSoftDelete === inst.id && (
In Papierkorb verschieben?
)}
))}
) })}
{/* Papierkorb */} {deletedInstances.length > 0 && (
{showTrash && (
{deletedInstances.map(inst => { const tmpl = allTemplates.find(t => t.id === inst.phaseTemplateId) return (
{tmpl?.label ?? '—'}
{new Date(inst.date).toLocaleDateString('de-DE')} {inst.deletedAt && ` · gelöscht ${new Date(inst.deletedAt).toLocaleDateString('de-DE')}`}
{confirmHardDelete === inst.id && (
Endgültig löschen? Alle Daten dieses Meetings werden unwiderruflich entfernt.
)}
) })}
)}
)}
) }