CG-Feedback-Monitor/src/pages/AssignmentDetail.tsx
Lars 97e3eeb8a6 refactor: zentralisiere Konfiguration in src/config/constants.ts
- RATING_OPTIONS, INST_COLORS, AI_CONFIG, RATING_NUM_MAP aus Komponenten extrahiert
- #0070AD als @theme-Farbe 'brand' in Tailwind v4 (index.css)
- Alle ~70 hardcoded #0070AD-Vorkommen in Pages durch 'brand' ersetzt
- BRAND_COLOR als JS-Konstante für style-Props (App.tsx)
2026-07-02 10:18:51 +02:00

399 lines
20 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 { 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<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',
}
const NUM_MAP: Record<FeedbackRating, number> = { 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<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 () => {
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 <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>
<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">
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">
{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 => (
<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'
}`}
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 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 (
<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>
)
}