CG-Feedback-Monitor/src/pages/AssignmentDetail.tsx
Lars 234f87d408 V2-Dev-Stand: Skala 1-10, Benefits/Concerns, DEV-Isolation und Gitea-Doku.
Bündelt die Neuentwicklung in AssigmentMonitorV2 (eigene Ports/DB), die Umstellung auf numerische Bewertungen, Meeting-Checklisten, KI-Tagging und die geplante Feedback-Kaskade — als Basis für Versionsverwaltung in Gitea.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 17:35:05 +02:00

496 lines
24 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
type Assignment, type AssignmentType, type PhaseTemplate, type MeetingInstance, type Consultant, type Assessment, type RatingScore, type FeedbackCategory, type FeedbackCriterionItem,
getAssignment, getAssignmentType,
listPhaseTemplatesForType,
listMeetingInstancesForAssignment, addMeetingInstance, softDeleteMeetingInstance, restoreMeetingInstance, hardDeleteMeetingInstance,
getConsultantsByIds,
listAssessmentsForMeetings,
listMeetingCategoryRatingsForMeetings,
loadFeedbackStructure, resolveVisibleStructure,
isAssignmentFeedbackComplete, softDeleteAssignment, archiveAssignment,
} from '../db'
import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../utils/meetingExport'
import { exportAssignmentReportPdf, exportInstituteeFeedbackPdf } from '../utils/pdfExport'
import { resolveScoreStyle } from '../config/constants'
import { resolveCategoryChipScore } from '../utils/meetingCategoryScore'
import { useRatingColorBands } from '../hooks/useRatingColorBands'
import type { MeetingCategoryRating } from '../db'
interface PhaseRow {
template: PhaseTemplate
instances: MeetingInstance[]
}
export default function AssignmentDetail() {
const { id } = useParams()
const navigate = useNavigate()
const assignmentId = Number(id)
const [assignment, setAssignment] = useState<Assignment | null>(null)
const [assignmentType, setAssignmentType] = useState<AssignmentType | null>(null)
const [phaseRows, setPhaseRows] = useState<PhaseRow[]>([])
const [allTemplates, setAllTemplates] = useState<PhaseTemplate[]>([])
const [institutees, setInstitutees] = useState<Consultant[]>([])
const [deletedInstances, setDeletedInstances] = useState<MeetingInstance[]>([])
const [showAdHoc, setShowAdHoc] = useState(false)
const [showTrash, setShowTrash] = useState(false)
const [exportMenuId, setExportMenuId] = useState<number | null>(null)
const [confirmSoftDelete, setConfirmSoftDelete] = useState<number | null>(null)
const [confirmHardDelete, setConfirmHardDelete] = useState<number | null>(null)
const [allAssessments, setAllAssessments] = useState<Assessment[]>([])
const [categoryRatings, setCategoryRatings] = useState<MeetingCategoryRating[]>([])
const [fbCategories, setFbCategories] = useState<FeedbackCategory[]>([])
const [fbItems, setFbItems] = useState<FeedbackCriterionItem[]>([])
const colorBands = useRatingColorBands()
const [feedbackComplete, setFeedbackComplete] = useState(false)
const [confirmAssignmentTrash, setConfirmAssignmentTrash] = useState(false)
const [confirmAssignmentArchive, setConfirmAssignmentArchive] = useState(false)
const [pdfExportingKey, setPdfExportingKey] = useState<string | null>(null)
const load = async () => {
const a = await getAssignment(assignmentId)
if (!a) return
setAssignment(a)
setFeedbackComplete(a.status === 'done' ? await isAssignmentFeedbackComplete(assignmentId) : false)
const [aType, templates, instances, cons] = await Promise.all([
getAssignmentType(a.assignmentTypeId),
listPhaseTemplatesForType(a.assignmentTypeId),
listMeetingInstancesForAssignment(assignmentId),
getConsultantsByIds(a.instituteeIds),
])
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, catRatings, structure, aType2] = await Promise.all([
listAssessmentsForMeetings(instIds),
listMeetingCategoryRatingsForMeetings(instIds),
loadFeedbackStructure(),
getAssignmentType(a.assignmentTypeId),
])
setAllAssessments(asmts)
setCategoryRatings(catRatings)
const visible = resolveVisibleStructure(structure.dimensions, structure.categories, structure.items, aType2?.criteriaIds)
setFbItems(visible.items)
setFbCategories(visible.categories)
}
useEffect(() => { load() }, [assignmentId])
const startMeeting = async (templateId: number, existingCount: number) => {
const mid = await addMeetingInstance({
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 softDeleteMeetingInstance(instId)
setConfirmSoftDelete(null)
await load()
}
const restoreMeeting = async (instId: number) => {
await restoreMeetingInstance(instId)
await load()
}
const hardDeleteMeeting = async (instId: number) => {
await hardDeleteMeetingInstance(instId)
setConfirmHardDelete(null)
await load()
}
const trashAssignment = async () => {
await softDeleteAssignment(assignmentId)
navigate('/')
}
const archiveThisAssignment = async () => {
await archiveAssignment(assignmentId)
navigate('/')
}
const handleExportReportPdf = async () => {
setPdfExportingKey('report')
try {
await exportAssignmentReportPdf(assignmentId)
} finally {
setPdfExportingKey(null)
}
}
const handleExportInstituteeFeedbackPdf = async (instituteeId: number) => {
const key = `inst-${instituteeId}`
setPdfExportingKey(key)
try {
await exportInstituteeFeedbackPdf(assignmentId, instituteeId)
} finally {
setPdfExportingKey(null)
}
}
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 <span className={`text-xs px-2 py-0.5 rounded-full font-medium ${map[status]}`}>{label[status]}</span>
}
if (!assignment) return <div className="text-center py-10 text-gray-400">Lädt</div>
return (
<div className="space-y-4">
<div>
<button onClick={() => navigate('/')} className="text-sm text-brand mb-2 block"> Zurück</button>
<div className="flex items-start justify-between gap-2">
<div>
{assignment.assignmentNumber && (
<div className="text-xs font-semibold text-brand uppercase tracking-wide">{assignment.assignmentNumber}</div>
)}
<h1 className="text-xl font-bold text-gray-800 leading-tight">{assignment.title}</h1>
</div>
<div className="flex-shrink-0 flex gap-1.5">
<button
onClick={() => navigate(`/assignment/${assignmentId}/history`)}
className="text-xs px-3 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition">
📜 Gesamtprotokoll
</button>
<button
onClick={handleExportReportPdf}
disabled={pdfExportingKey === 'report'}
title="Gesamtprotokoll + Feedback als PDF"
className="text-xs px-3 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition disabled:opacity-50">
{pdfExportingKey === 'report' ? '⏳' : '⬇ PDF'}
</button>
<button
onClick={() => navigate(`/assignment/${assignmentId}/edit`)}
className="text-xs px-3 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition">
Bearbeiten
</button>
</div>
</div>
<div className="text-sm text-gray-500">{assignment.client} · {assignmentType?.name}</div>
<div className="text-xs text-gray-400 mt-1">
{institutees.map(c => `${c.firstName} ${c.lastName}`).join(', ')}
</div>
</div>
{/* Assignment-Aktionen (nur für abgeschlossene Assignments) */}
{assignment.status === 'done' && (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 space-y-2">
<span className="text-sm font-semibold text-gray-700">Assignment-Aktionen</span>
<div className="flex gap-2 flex-wrap">
<button
onClick={() => setConfirmAssignmentTrash(p => !p)}
disabled={!feedbackComplete}
title={!feedbackComplete ? 'Erst möglich, wenn für alle Institutees ein finales Feedback vorliegt' : undefined}
className={`text-xs px-3 py-1.5 rounded-lg font-medium ${
feedbackComplete ? 'bg-red-50 text-red-600 border border-red-200 hover:bg-red-100' : 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}>
🗑 In Papierkorb verschieben
</button>
<button
onClick={() => setConfirmAssignmentArchive(p => !p)}
className="text-xs px-3 py-1.5 rounded-lg font-medium bg-gray-50 text-gray-600 border border-gray-200 hover:bg-gray-100">
🗄 Archivieren
</button>
</div>
{!feedbackComplete && (
<div className="text-xs text-gray-400">
Löschen erst möglich, sobald für alle Institutees ein finales Feedback vorliegt.
</div>
)}
{confirmAssignmentTrash && (
<div className="bg-red-50 border border-red-200 rounded-lg px-3 py-2.5 flex items-center justify-between gap-2">
<span className="text-xs text-red-700">Assignment in den Papierkorb verschieben?</span>
<div className="flex gap-1.5">
<button onClick={trashAssignment} className="text-xs bg-red-600 text-white px-2.5 py-1 rounded-lg font-medium">Ja</button>
<button onClick={() => setConfirmAssignmentTrash(false)} className="text-xs border border-gray-200 text-gray-500 px-2.5 py-1 rounded-lg">Nein</button>
</div>
</div>
)}
{confirmAssignmentArchive && (
<div className="bg-gray-50 border border-gray-200 rounded-lg px-3 py-2.5 flex items-center justify-between gap-2">
<span className="text-xs text-gray-600">Assignment archivieren?</span>
<div className="flex gap-1.5">
<button onClick={archiveThisAssignment} className="text-xs bg-gray-700 text-white px-2.5 py-1 rounded-lg font-medium">Ja</button>
<button onClick={() => setConfirmAssignmentArchive(false)} className="text-xs border border-gray-200 text-gray-500 px-2.5 py-1 rounded-lg">Nein</button>
</div>
</div>
)}
</div>
)}
{/* Ad-hoc Meeting */}
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-gray-700">Ad-hoc Meeting starten</span>
<button onClick={() => setShowAdHoc(p => !p)}
className="text-xs bg-brand text-white px-3 py-1.5 rounded-lg font-medium">
{showAdHoc ? 'Abbrechen' : '+ Ad-hoc'}
</button>
</div>
{showAdHoc && (
<div className="mt-3 grid grid-cols-2 gap-2">
{allTemplates.map(t => (
<button key={t.id}
onClick={() => {
const count = phaseRows.find(r => r.template.id === t.id)?.instances.length ?? 0
startMeeting(t.id!, count)
}}
className="py-2.5 px-3 text-left rounded-lg border border-gray-200 hover:border-brand hover:bg-blue-50 transition">
<div className="text-sm font-medium text-gray-700">{t.label}</div>
<div className={`text-xs mt-0.5 ${t.withWhom === 'client' ? 'text-blue-500' : 'text-purple-500'}`}>
{t.withWhom === 'client' ? 'Kundentermin' : 'Lead-intern'}
</div>
</button>
))}
</div>
)}
</div>
{/* Feedback-Entwürfe */}
{institutees.length > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-3 flex items-center justify-between">
<span className="text-sm text-amber-800 font-medium">Feedback-Entwürfe</span>
<div className="flex gap-2 flex-wrap">
{institutees.map(c => (
<div key={c.id} className="flex items-center gap-1">
<button
onClick={() => navigate(`/assignment/${assignmentId}/feedback/${c.id}`)}
className="text-xs bg-amber-600 text-white px-3 py-1 rounded-lg">
{c.firstName}
</button>
<button
onClick={() => handleExportInstituteeFeedbackPdf(c.id!)}
disabled={pdfExportingKey === `inst-${c.id}`}
title={`Feedback für ${c.firstName} als PDF`}
className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded-lg disabled:opacity-50">
{pdfExportingKey === `inst-${c.id}` ? '⏳' : '⬇'}
</button>
</div>
))}
</div>
</div>
)}
{/* Phasen-Flow */}
<div className="space-y-3" onClick={() => setExportMenuId(null)}>
{phaseRows.map(({ template, instances }) => {
const canStart = instances.length === 0 || template.isRepeatable
return (
<div key={template.id} className="bg-white rounded-xl shadow-sm border border-gray-100 p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex-1 min-w-0">
<span className="font-semibold text-gray-800 text-sm">{template.label}</span>
<span className={`ml-2 text-xs px-2 py-0.5 rounded-full ${
template.withWhom === 'client' ? 'bg-blue-100 text-blue-600' : 'bg-purple-100 text-purple-600'
}`}>
{template.withWhom === 'client' ? 'Kunde' : 'Lead'}
</span>
{template.hasConversation && (
<span className="ml-1 text-xs bg-orange-100 text-orange-600 px-2 py-0.5 rounded-full">
Protokoll
</span>
)}
</div>
<button
onClick={() => canStart && startMeeting(template.id!, instances.length)}
className={`ml-2 flex-shrink-0 text-xs px-3 py-1.5 rounded-lg font-medium ${
canStart ? 'bg-brand text-white' : 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
disabled={!canStart}>
{instances.length === 0 ? 'Starten' : template.isRepeatable ? '+ Wiederholen' : 'Erledigt'}
</button>
</div>
{instances.map(inst => (
<div key={inst.id} className="mt-2 relative">
<div
onClick={() => { 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">
<div className="flex-1 min-w-0">
<div className="text-sm text-gray-700">
{template.isRepeatable ? `#${inst.phaseIndex} · ` : ''}
{new Date(inst.date).toLocaleDateString('de-DE')}
</div>
{/* Per-institutee per-category chips */}
{institutees.map(c => {
const chips = fbCategories.map(cat => {
const catItems = fbItems.filter(i => i.categoryId === cat.id)
const mode = resolveCategoryChipScore({
meetingId: inst.id!,
instituteeId: c.id!,
categoryId: cat.id!,
categoryRatings,
assessments: allAssessments,
catItems,
})
return mode ? { cat, mode } : null
}).filter(Boolean) as { cat: FeedbackCategory; mode: RatingScore }[]
if (chips.length === 0) return null
return (
<div key={c.id} className="mt-1">
<span className="text-xs text-gray-400 mr-1">{c.firstName}:</span>
{chips.map(({ cat, mode }) => (
<span key={cat.id} className={`inline-block text-xs px-2 py-0.5 rounded font-medium mr-1 tabular-nums ${resolveScoreStyle(mode, colorBands).active}`}>
{cat.name} {mode}
</span>
))}
</div>
)
})}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{statusBadge(inst.status)}
<button
onClick={e => { e.stopPropagation(); setExportMenuId(exportMenuId === inst.id ? null : inst.id!); setConfirmSoftDelete(null) }}
className="text-xs text-gray-400 hover:text-gray-600 px-1.5 py-1">
</button>
<button
onClick={e => { e.stopPropagation(); setConfirmSoftDelete(confirmSoftDelete === inst.id ? null : inst.id!); setExportMenuId(null) }}
className="text-xs text-red-300 hover:text-red-500 px-1.5 py-1">
🗑
</button>
</div>
</div>
{exportMenuId === inst.id && (
<div className="absolute right-8 top-full mt-0.5 bg-white border border-gray-200 rounded-xl shadow-lg z-10 min-w-[150px] overflow-hidden">
<button onClick={() => handleExportMd(inst)}
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(inst)}
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50">
🗂 JSON-Backup
</button>
</div>
)}
{confirmSoftDelete === inst.id && (
<div className="mt-1 bg-red-50 border border-red-200 rounded-lg px-3 py-2.5 flex items-center justify-between gap-2">
<span className="text-xs text-red-700">In Papierkorb verschieben?</span>
<div className="flex gap-1.5">
<button onClick={() => softDeleteMeeting(inst.id!)}
className="text-xs bg-red-600 text-white px-2.5 py-1 rounded-lg font-medium">
Ja
</button>
<button onClick={() => setConfirmSoftDelete(null)}
className="text-xs border border-gray-200 text-gray-500 px-2.5 py-1 rounded-lg">
Nein
</button>
</div>
</div>
)}
</div>
))}
</div>
)
})}
</div>
{/* Papierkorb */}
{deletedInstances.length > 0 && (
<div className="border border-dashed border-gray-200 rounded-xl overflow-hidden">
<button
onClick={() => setShowTrash(p => !p)}
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-gray-50 transition">
<span className="text-sm text-gray-400 font-medium">
🗑 Papierkorb ({deletedInstances.length})
</span>
<span className="text-gray-300 text-xs">{showTrash ? '▲' : '▼'}</span>
</button>
{showTrash && (
<div className="border-t border-dashed border-gray-200 divide-y divide-gray-100">
{deletedInstances.map(inst => {
const tmpl = allTemplates.find(t => t.id === inst.phaseTemplateId)
return (
<div key={inst.id} className="px-4 py-3 bg-gray-50">
<div className="flex items-center justify-between">
<div>
<div className="text-sm text-gray-500">{tmpl?.label ?? '—'}</div>
<div className="text-xs text-gray-400">
{new Date(inst.date).toLocaleDateString('de-DE')}
{inst.deletedAt && ` · gelöscht ${new Date(inst.deletedAt).toLocaleDateString('de-DE')}`}
</div>
</div>
<div className="flex gap-2">
<button onClick={() => restoreMeeting(inst.id!)}
className="text-xs bg-white border border-gray-200 text-gray-600 px-2.5 py-1 rounded-lg hover:bg-gray-100">
Wiederherstellen
</button>
<button onClick={() => setConfirmHardDelete(inst.id!)}
className="text-xs text-red-400 hover:text-red-600 px-2 py-1">
</button>
</div>
</div>
{confirmHardDelete === inst.id && (
<div className="mt-2 bg-red-50 border border-red-200 rounded-lg p-3 space-y-2">
<div className="text-xs text-red-700 font-medium">Endgültig löschen? Alle Daten dieses Meetings werden unwiderruflich entfernt.</div>
<div className="flex gap-2">
<button onClick={() => hardDeleteMeeting(inst.id!)}
className="flex-1 py-1.5 rounded-lg text-xs font-medium bg-red-600 text-white">
Endgültig löschen
</button>
<button onClick={() => setConfirmHardDelete(null)}
className="px-3 py-1.5 rounded-lg text-xs border border-gray-200 text-gray-500">
Abbrechen
</button>
</div>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)}
</div>
)
}