CG-Feedback-Monitor/src/pages/AssignmentDetail.tsx

392 lines
18 KiB
TypeScript
Raw Normal View History

2026-07-02 10:10:06 +02:00
import { useEffect, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
2026-07-06 19:08:24 +02:00
import {
type Assignment, type AssignmentType, type PhaseTemplate, type MeetingInstance, type Consultant, type Assessment, type FeedbackRating, type FeedbackCategory, type FeedbackCriterionItem,
getAssignment, getAssignmentType,
listPhaseTemplatesForType,
listMeetingInstancesForAssignment, addMeetingInstance, softDeleteMeetingInstance, restoreMeetingInstance, hardDeleteMeetingInstance,
getConsultantsByIds,
listAssessmentsForMeetings,
listFeedbackCategoriesAll, listFeedbackCriterionItemsAll, filterVisibleFeedbackStructure,
} from '../db'
2026-07-02 10:10:06 +02:00
import { exportMeetingAsMarkdown, exportMeetingAsJson, downloadFile } from '../utils/meetingExport'
2026-07-06 19:08:24 +02:00
import { weightedAverageRating } from '../utils/ratingTrend'
2026-07-02 10:10:06 +02:00
const RATING_BG: Record<FeedbackRating, string> = {
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',
}
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 [fbCategories, setFbCategories] = useState<FeedbackCategory[]>([])
const [fbItems, setFbItems] = useState<FeedbackCriterionItem[]>([])
const load = async () => {
2026-07-06 19:08:24 +02:00
const a = await getAssignment(assignmentId)
2026-07-02 10:10:06 +02:00
if (!a) return
setAssignment(a)
const [aType, templates, instances, cons] = await Promise.all([
2026-07-06 19:08:24 +02:00
getAssignmentType(a.assignmentTypeId),
listPhaseTemplatesForType(a.assignmentTypeId),
listMeetingInstancesForAssignment(assignmentId),
getConsultantsByIds(a.instituteeIds),
2026-07-02 10:10:06 +02:00
])
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([
2026-07-06 19:08:24 +02:00
listAssessmentsForMeetings(instIds),
listFeedbackCategoriesAll(),
listFeedbackCriterionItemsAll(),
getAssignmentType(a.assignmentTypeId),
2026-07-02 10:10:06 +02:00
])
setAllAssessments(asmts)
2026-07-06 19:08:24 +02:00
const { categories: visibleCats, items: visibleItems } = filterVisibleFeedbackStructure(allCats, allItems, aType2?.criteriaIds)
setFbItems(visibleItems)
setFbCategories(visibleCats)
2026-07-02 10:10:06 +02:00
}
useEffect(() => { load() }, [assignmentId])
const startMeeting = async (templateId: number, existingCount: number) => {
2026-07-06 19:08:24 +02:00
const mid = await addMeetingInstance({
2026-07-02 10:10:06 +02:00
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) => {
2026-07-06 19:08:24 +02:00
await softDeleteMeetingInstance(instId)
2026-07-02 10:10:06 +02:00
setConfirmSoftDelete(null)
await load()
}
const restoreMeeting = async (instId: number) => {
2026-07-06 19:08:24 +02:00
await restoreMeetingInstance(instId)
2026-07-02 10:10:06 +02:00
await load()
}
const hardDeleteMeeting = async (instId: number) => {
2026-07-06 19:08:24 +02:00
await hardDeleteMeetingInstance(instId)
2026-07-02 10:10:06 +02:00
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 <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>
2026-07-02 10:10:06 +02:00
<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>
2026-07-02 10:10:06 +02:00
)}
<h1 className="text-xl font-bold text-gray-800 leading-tight">{assignment.title}</h1>
</div>
<button
onClick={() => navigate(`/assignment/${assignmentId}/edit`)}
className="flex-shrink-0 text-xs px-3 py-1.5 border border-gray-200 rounded-lg text-gray-500 hover:border-brand hover:text-brand transition">
2026-07-02 10:10:06 +02:00
✏️ Bearbeiten
</button>
</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>
{/* 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">
2026-07-02 10:10:06 +02:00
{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">
2026-07-02 10:10:06 +02:00
<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 => (
<button key={c.id}
onClick={() => navigate(`/assignment/${assignmentId}/feedback/${c.id}`)}
className="text-xs bg-amber-600 text-white px-3 py-1 rounded-lg">
{c.firstName}
</button>
))}
</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'
2026-07-02 10:10:06 +02:00
}`}
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)
2026-07-06 19:08:24 +02:00
const mode = weightedAverageRating(catItems, itemId => {
2026-07-02 10:10:06 +02:00
const asmt = allAssessments.find(a =>
a.meetingInstanceId === inst.id &&
a.instituteeId === c.id &&
2026-07-06 19:08:24 +02:00
a.criteriaId === itemId
2026-07-02 10:10:06 +02:00
)
2026-07-06 19:08:24 +02:00
return asmt?.score ?? null
})
return mode ? { cat, mode } : null
2026-07-02 10:10:06 +02:00
}).filter(Boolean) as { cat: FeedbackCategory; mode: FeedbackRating }[]
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 ${RATING_BG[mode]}`}>
{cat.name}
</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>
)
}