- 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)
399 lines
20 KiB
TypeScript
399 lines
20 KiB
TypeScript
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>
|
||
)
|
||
}
|