Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Failing after 27s
CI / test-python-backend-compliance (push) Successful in 37s
CI / test-python-document-crawler (push) Successful in 20s
CI / test-python-dsms-gateway (push) Successful in 16s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
611 lines
25 KiB
TypeScript
611 lines
25 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
|
|
// =============================================================================
|
|
// TYPES
|
|
// =============================================================================
|
|
|
|
interface WorkshopSession {
|
|
id: string
|
|
title: string
|
|
description: string
|
|
session_type: 'ucca' | 'dsfa' | 'custom'
|
|
status: 'DRAFT' | 'SCHEDULED' | 'ACTIVE' | 'PAUSED' | 'COMPLETED' | 'CANCELLED'
|
|
current_step: number
|
|
total_steps: number
|
|
join_code: string
|
|
require_auth: boolean
|
|
allow_anonymous: boolean
|
|
scheduled_start: string | null
|
|
scheduled_end: string | null
|
|
actual_start: string | null
|
|
actual_end: string | null
|
|
assessment_id: string | null
|
|
roadmap_id: string | null
|
|
portfolio_id: string | null
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
interface Participant {
|
|
id: string
|
|
session_id: string
|
|
user_id: string | null
|
|
name: string
|
|
email: string
|
|
role: 'FACILITATOR' | 'EXPERT' | 'STAKEHOLDER' | 'OBSERVER'
|
|
department: string
|
|
is_active: boolean
|
|
last_active_at: string | null
|
|
joined_at: string
|
|
can_edit: boolean
|
|
can_comment: boolean
|
|
can_approve: boolean
|
|
}
|
|
|
|
interface WorkshopResponse {
|
|
id: string
|
|
session_id: string
|
|
participant_id: string
|
|
step_number: number
|
|
field_id: string
|
|
value: unknown
|
|
value_type: string
|
|
response_status: 'PENDING' | 'DRAFT' | 'SUBMITTED' | 'REVIEWED'
|
|
created_at: string
|
|
}
|
|
|
|
interface WorkshopComment {
|
|
id: string
|
|
session_id: string
|
|
participant_id: string
|
|
step_number: number | null
|
|
field_id: string | null
|
|
text: string
|
|
is_resolved: boolean
|
|
created_at: string
|
|
}
|
|
|
|
interface SessionStats {
|
|
total_participants: number
|
|
active_participants: number
|
|
total_responses: number
|
|
completed_steps: number
|
|
total_steps: number
|
|
progress: number
|
|
}
|
|
|
|
// =============================================================================
|
|
// API
|
|
// =============================================================================
|
|
|
|
const API_BASE = '/api/sdk/v1/workshops'
|
|
|
|
async function api<T>(path: string, options?: RequestInit): Promise<T> {
|
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
...options,
|
|
})
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: res.statusText }))
|
|
throw new Error(err.error || err.message || `HTTP ${res.status}`)
|
|
}
|
|
return res.json()
|
|
}
|
|
|
|
// =============================================================================
|
|
// COMPONENTS
|
|
// =============================================================================
|
|
|
|
const statusColors: Record<string, string> = {
|
|
DRAFT: 'bg-gray-100 text-gray-700',
|
|
SCHEDULED: 'bg-blue-100 text-blue-700',
|
|
ACTIVE: 'bg-green-100 text-green-700',
|
|
PAUSED: 'bg-yellow-100 text-yellow-700',
|
|
COMPLETED: 'bg-purple-100 text-purple-700',
|
|
CANCELLED: 'bg-red-100 text-red-700',
|
|
}
|
|
|
|
const statusLabels: Record<string, string> = {
|
|
DRAFT: 'Entwurf',
|
|
SCHEDULED: 'Geplant',
|
|
ACTIVE: 'Aktiv',
|
|
PAUSED: 'Pausiert',
|
|
COMPLETED: 'Abgeschlossen',
|
|
CANCELLED: 'Abgebrochen',
|
|
}
|
|
|
|
const typeLabels: Record<string, string> = {
|
|
ucca: 'UCCA Assessment',
|
|
dsfa: 'DSFA Workshop',
|
|
custom: 'Benutzerdefiniert',
|
|
}
|
|
|
|
function SessionCard({ session, onSelect, onDelete }: {
|
|
session: WorkshopSession
|
|
onSelect: (s: WorkshopSession) => void
|
|
onDelete: (id: string) => void
|
|
}) {
|
|
const progress = session.total_steps > 0
|
|
? Math.round((session.current_step / session.total_steps) * 100)
|
|
: 0
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl border-2 border-gray-200 p-6 hover:border-purple-300 transition-colors cursor-pointer"
|
|
onClick={() => onSelect(session)}>
|
|
<div className="flex items-start justify-between mb-3">
|
|
<div>
|
|
<h4 className="font-semibold text-gray-900">{session.title}</h4>
|
|
<span className="text-xs text-gray-500">{typeLabels[session.session_type] || session.session_type}</span>
|
|
</div>
|
|
<span className={`px-2 py-1 text-xs rounded-full ${statusColors[session.status] || 'bg-gray-100 text-gray-700'}`}>
|
|
{statusLabels[session.status] || session.status}
|
|
</span>
|
|
</div>
|
|
{session.description && (
|
|
<p className="text-sm text-gray-600 mb-3 line-clamp-2">{session.description}</p>
|
|
)}
|
|
<div className="flex items-center gap-4 text-xs text-gray-500 mb-3">
|
|
<span>Code: <code className="bg-gray-100 px-1 rounded">{session.join_code}</code></span>
|
|
<span>Schritt {session.current_step}/{session.total_steps}</span>
|
|
</div>
|
|
<div className="w-full h-2 bg-gray-100 rounded-full overflow-hidden mb-3">
|
|
<div className="h-full bg-purple-500 rounded-full transition-all" style={{ width: `${progress}%` }} />
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-xs text-gray-400">
|
|
{new Date(session.created_at).toLocaleDateString('de-DE')}
|
|
</span>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); onDelete(session.id) }}
|
|
className="text-xs text-red-500 hover:text-red-700 hover:bg-red-50 px-2 py-1 rounded"
|
|
>
|
|
Loeschen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CreateSessionModal({ onClose, onCreated }: {
|
|
onClose: () => void
|
|
onCreated: () => void
|
|
}) {
|
|
const [title, setTitle] = useState('')
|
|
const [description, setDescription] = useState('')
|
|
const [sessionType, setSessionType] = useState<'ucca' | 'dsfa' | 'custom'>('custom')
|
|
const [totalSteps, setTotalSteps] = useState(5)
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
const handleCreate = async () => {
|
|
if (!title.trim()) return
|
|
setSaving(true)
|
|
try {
|
|
await api('', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
title: title.trim(),
|
|
description: description.trim(),
|
|
session_type: sessionType,
|
|
total_steps: totalSteps,
|
|
}),
|
|
})
|
|
onCreated()
|
|
} catch (err) {
|
|
console.error('Create session error:', err)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
|
|
<div className="bg-white rounded-2xl p-6 w-full max-w-lg" onClick={e => e.stopPropagation()}>
|
|
<h3 className="text-lg font-bold text-gray-900 mb-4">Neuer Workshop</h3>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
|
|
<input
|
|
type="text" value={title} onChange={e => setTitle(e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
placeholder="z.B. DSFA Workshop Q1"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
|
|
<textarea
|
|
value={description} onChange={e => setDescription(e.target.value)}
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
rows={3} placeholder="Beschreibung des Workshops..."
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Typ</label>
|
|
<select value={sessionType} onChange={e => setSessionType(e.target.value as 'ucca' | 'dsfa' | 'custom')}
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500">
|
|
<option value="custom">Benutzerdefiniert</option>
|
|
<option value="ucca">UCCA Assessment</option>
|
|
<option value="dsfa">DSFA Workshop</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Schritte</label>
|
|
<input type="number" value={totalSteps} onChange={e => setTotalSteps(Number(e.target.value))}
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-purple-500" min={1} max={50}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-3 mt-6">
|
|
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg">Abbrechen</button>
|
|
<button onClick={handleCreate} disabled={!title.trim() || saving}
|
|
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
|
{saving ? 'Erstelle...' : 'Erstellen'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SessionDetailView({ session, onBack, onRefresh }: {
|
|
session: WorkshopSession
|
|
onBack: () => void
|
|
onRefresh: () => void
|
|
}) {
|
|
const [participants, setParticipants] = useState<Participant[]>([])
|
|
const [responses, setResponses] = useState<WorkshopResponse[]>([])
|
|
const [comments, setComments] = useState<WorkshopComment[]>([])
|
|
const [stats, setStats] = useState<SessionStats | null>(null)
|
|
const [activeTab, setActiveTab] = useState<'participants' | 'responses' | 'comments'>('participants')
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
const loadDetails = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const [p, r, c, s] = await Promise.all([
|
|
api<Participant[]>(`/${session.id}/participants`).catch(() => []),
|
|
api<WorkshopResponse[]>(`/${session.id}/responses`).catch(() => []),
|
|
api<WorkshopComment[]>(`/${session.id}/comments`).catch(() => []),
|
|
api<SessionStats>(`/${session.id}/stats`).catch(() => null),
|
|
])
|
|
setParticipants(Array.isArray(p) ? p : [])
|
|
setResponses(Array.isArray(r) ? r : [])
|
|
setComments(Array.isArray(c) ? c : [])
|
|
setStats(s)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [session.id])
|
|
|
|
useEffect(() => { loadDetails() }, [loadDetails])
|
|
|
|
const handleLifecycle = async (action: 'start' | 'pause' | 'complete') => {
|
|
try {
|
|
await api(`/${session.id}/${action}`, { method: 'POST' })
|
|
onRefresh()
|
|
} catch (err) {
|
|
console.error(`${action} error:`, err)
|
|
}
|
|
}
|
|
|
|
const handleExport = async () => {
|
|
try {
|
|
const data = await api(`/${session.id}/export`)
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url; a.download = `workshop-${session.id}.json`; a.click()
|
|
URL.revokeObjectURL(url)
|
|
} catch (err) {
|
|
console.error('Export error:', err)
|
|
}
|
|
}
|
|
|
|
const roleColors: Record<string, string> = {
|
|
FACILITATOR: 'bg-purple-100 text-purple-700',
|
|
EXPERT: 'bg-blue-100 text-blue-700',
|
|
STAKEHOLDER: 'bg-green-100 text-green-700',
|
|
OBSERVER: 'bg-gray-100 text-gray-700',
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<button onClick={onBack} className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 mb-4">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
Zurueck zur Uebersicht
|
|
</button>
|
|
|
|
<div className="bg-white rounded-xl border-2 border-gray-200 p-6 mb-6">
|
|
<div className="flex items-start justify-between mb-4">
|
|
<div>
|
|
<h2 className="text-xl font-bold text-gray-900">{session.title}</h2>
|
|
<p className="text-sm text-gray-500 mt-1">{session.description}</p>
|
|
</div>
|
|
<span className={`px-3 py-1 text-sm rounded-full ${statusColors[session.status]}`}>
|
|
{statusLabels[session.status]}
|
|
</span>
|
|
</div>
|
|
|
|
{stats && (
|
|
<div className="grid grid-cols-4 gap-4 mb-4">
|
|
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
|
<div className="text-2xl font-bold text-gray-900">{stats.total_participants}</div>
|
|
<div className="text-xs text-gray-500">Teilnehmer</div>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
|
<div className="text-2xl font-bold text-gray-900">{stats.active_participants}</div>
|
|
<div className="text-xs text-gray-500">Aktiv</div>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
|
<div className="text-2xl font-bold text-gray-900">{stats.total_responses}</div>
|
|
<div className="text-xs text-gray-500">Antworten</div>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-lg p-3 text-center">
|
|
<div className="text-2xl font-bold text-purple-600">{stats.progress}%</div>
|
|
<div className="text-xs text-gray-500">Fortschritt</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<span className="text-sm text-gray-500">Join-Code: <code className="bg-gray-100 px-2 py-0.5 rounded font-mono">{session.join_code}</code></span>
|
|
<span className="text-sm text-gray-500">Typ: {typeLabels[session.session_type]}</span>
|
|
<span className="text-sm text-gray-500">Schritt {session.current_step}/{session.total_steps}</span>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
{session.status === 'DRAFT' && (
|
|
<button onClick={() => handleLifecycle('start')} className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700">Starten</button>
|
|
)}
|
|
{session.status === 'ACTIVE' && (
|
|
<button onClick={() => handleLifecycle('pause')} className="px-3 py-1.5 text-sm bg-yellow-600 text-white rounded-lg hover:bg-yellow-700">Pausieren</button>
|
|
)}
|
|
{(session.status === 'ACTIVE' || session.status === 'PAUSED') && (
|
|
<button onClick={() => handleLifecycle('complete')} className="px-3 py-1.5 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">Abschliessen</button>
|
|
)}
|
|
<button onClick={handleExport} className="px-3 py-1.5 text-sm border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">Exportieren</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-1 mb-4 bg-gray-100 p-1 rounded-lg">
|
|
{(['participants', 'responses', 'comments'] as const).map(tab => (
|
|
<button key={tab} onClick={() => setActiveTab(tab)}
|
|
className={`flex-1 px-4 py-2 text-sm rounded-md transition-colors ${activeTab === tab ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'}`}>
|
|
{tab === 'participants' ? `Teilnehmer (${participants.length})` :
|
|
tab === 'responses' ? `Antworten (${responses.length})` :
|
|
`Kommentare (${comments.length})`}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="text-center py-8 text-gray-500">Laden...</div>
|
|
) : (
|
|
<>
|
|
{activeTab === 'participants' && (
|
|
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Rolle</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Abteilung</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
|
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Beigetreten</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200">
|
|
{participants.map(p => (
|
|
<tr key={p.id} className="hover:bg-gray-50">
|
|
<td className="px-4 py-3">
|
|
<div className="font-medium text-gray-900">{p.name}</div>
|
|
<div className="text-xs text-gray-500">{p.email}</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className={`px-2 py-0.5 text-xs rounded-full ${roleColors[p.role] || 'bg-gray-100 text-gray-700'}`}>
|
|
{p.role}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-600">{p.department || '-'}</td>
|
|
<td className="px-4 py-3">
|
|
<span className={`inline-block w-2 h-2 rounded-full ${p.is_active ? 'bg-green-500' : 'bg-gray-300'}`} />
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-gray-500">
|
|
{new Date(p.joined_at).toLocaleDateString('de-DE')}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{participants.length === 0 && (
|
|
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-500">Keine Teilnehmer</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'responses' && (
|
|
<div className="space-y-3">
|
|
{responses.map(r => (
|
|
<div key={r.id} className="bg-white rounded-lg border border-gray-200 p-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm font-medium text-gray-900">Schritt {r.step_number} / {r.field_id}</span>
|
|
<span className={`px-2 py-0.5 text-xs rounded-full ${
|
|
r.response_status === 'SUBMITTED' ? 'bg-green-100 text-green-700' :
|
|
r.response_status === 'REVIEWED' ? 'bg-purple-100 text-purple-700' :
|
|
'bg-gray-100 text-gray-700'
|
|
}`}>{r.response_status}</span>
|
|
</div>
|
|
<pre className="text-sm text-gray-600 bg-gray-50 p-2 rounded overflow-auto max-h-32">
|
|
{typeof r.value === 'string' ? r.value : JSON.stringify(r.value, null, 2)}
|
|
</pre>
|
|
<div className="text-xs text-gray-400 mt-2">
|
|
{new Date(r.created_at).toLocaleString('de-DE')}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{responses.length === 0 && (
|
|
<div className="text-center py-8 text-gray-500">Keine Antworten</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'comments' && (
|
|
<div className="space-y-3">
|
|
{comments.map(c => (
|
|
<div key={c.id} className={`bg-white rounded-lg border p-4 ${c.is_resolved ? 'border-green-200' : 'border-gray-200'}`}>
|
|
<div className="flex items-center justify-between mb-2">
|
|
{c.step_number != null && <span className="text-xs text-gray-500">Schritt {c.step_number}</span>}
|
|
{c.is_resolved && <span className="text-xs text-green-600">Geloest</span>}
|
|
</div>
|
|
<p className="text-sm text-gray-700">{c.text}</p>
|
|
<div className="text-xs text-gray-400 mt-2">
|
|
{new Date(c.created_at).toLocaleString('de-DE')}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{comments.length === 0 && (
|
|
<div className="text-center py-8 text-gray-500">Keine Kommentare</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// MAIN PAGE
|
|
// =============================================================================
|
|
|
|
export default function WorkshopPage() {
|
|
const [sessions, setSessions] = useState<WorkshopSession[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
const [selectedSession, setSelectedSession] = useState<WorkshopSession | null>(null)
|
|
const [filter, setFilter] = useState<string>('all')
|
|
|
|
const loadSessions = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const data = await api<WorkshopSession[] | { sessions: WorkshopSession[] }>('')
|
|
const list = Array.isArray(data) ? data : (data.sessions || [])
|
|
setSessions(list)
|
|
} catch (err) {
|
|
console.error('Load sessions error:', err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => { loadSessions() }, [loadSessions])
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (!confirm('Workshop wirklich loeschen?')) return
|
|
try {
|
|
await api(`/${id}`, { method: 'DELETE' })
|
|
setSessions(prev => prev.filter(s => s.id !== id))
|
|
} catch (err) {
|
|
console.error('Delete error:', err)
|
|
}
|
|
}
|
|
|
|
const filteredSessions = filter === 'all'
|
|
? sessions
|
|
: sessions.filter(s => s.status === filter)
|
|
|
|
if (selectedSession) {
|
|
return (
|
|
<div className="p-6 max-w-6xl mx-auto">
|
|
<SessionDetailView
|
|
session={selectedSession}
|
|
onBack={() => { setSelectedSession(null); loadSessions() }}
|
|
onRefresh={() => {
|
|
loadSessions().then(() => {
|
|
const updated = sessions.find(s => s.id === selectedSession.id)
|
|
if (updated) setSelectedSession(updated)
|
|
})
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 max-w-6xl mx-auto">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">Compliance Workshops</h1>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
Kollaborative Workshops fuer DSFA, UCCA und andere Compliance-Prozesse
|
|
</p>
|
|
</div>
|
|
<button onClick={() => setShowCreate(true)}
|
|
className="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 flex items-center gap-2">
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
</svg>
|
|
Neuer Workshop
|
|
</button>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-4 gap-4 mb-6">
|
|
{[
|
|
{ label: 'Gesamt', value: sessions.length, color: 'text-gray-900' },
|
|
{ label: 'Aktiv', value: sessions.filter(s => s.status === 'ACTIVE').length, color: 'text-green-600' },
|
|
{ label: 'Entwurf', value: sessions.filter(s => s.status === 'DRAFT').length, color: 'text-gray-600' },
|
|
{ label: 'Abgeschlossen', value: sessions.filter(s => s.status === 'COMPLETED').length, color: 'text-purple-600' },
|
|
].map(stat => (
|
|
<div key={stat.label} className="bg-white rounded-xl border border-gray-200 p-4 text-center">
|
|
<div className={`text-2xl font-bold ${stat.color}`}>{stat.value}</div>
|
|
<div className="text-xs text-gray-500">{stat.label}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Filter */}
|
|
<div className="flex gap-2 mb-6">
|
|
{['all', 'DRAFT', 'ACTIVE', 'PAUSED', 'COMPLETED'].map(f => (
|
|
<button key={f} onClick={() => setFilter(f)}
|
|
className={`px-3 py-1.5 text-sm rounded-lg ${filter === f ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
|
|
{f === 'all' ? 'Alle' : statusLabels[f] || f}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="text-center py-12 text-gray-500">Workshops werden geladen...</div>
|
|
) : filteredSessions.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<div className="text-gray-400 mb-2">
|
|
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
</div>
|
|
<p className="text-gray-500">Keine Workshops gefunden</p>
|
|
<button onClick={() => setShowCreate(true)} className="mt-3 text-sm text-purple-600 hover:text-purple-700">
|
|
Ersten Workshop erstellen
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{filteredSessions.map(s => (
|
|
<SessionCard key={s.id} session={s} onSelect={setSelectedSession} onDelete={handleDelete} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{showCreate && (
|
|
<CreateSessionModal onClose={() => setShowCreate(false)} onCreated={() => { setShowCreate(false); loadSessions() }} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|