refactor(admin): split workshop, vendor-compliance, advisory-board/documentation pages

Each page.tsx was >500 LOC (610/602/596). Extracted React components to
_components/ and custom hook to _hooks/ per-route, reducing all three
page.tsx orchestrators to 107/229/120 LOC respectively. Zero behavior changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Sharang Parnerkar
2026-04-16 13:14:28 +02:00
parent 8044ddb776
commit 87f2ce9692
19 changed files with 1294 additions and 1404 deletions

View File

@@ -0,0 +1,86 @@
'use client'
import { useState } from 'react'
import { api } from './workshopApi'
export 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>
)
}

View File

@@ -0,0 +1,49 @@
'use client'
import { WorkshopSession, statusColors, statusLabels, typeLabels } from './types'
export 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>
)
}

View File

@@ -0,0 +1,237 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import {
WorkshopSession, Participant, WorkshopResponse, WorkshopComment,
SessionStats, statusColors, statusLabels, typeLabels,
} from './types'
import { api } from './workshopApi'
export 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>
<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>
)
}

View File

@@ -0,0 +1,93 @@
export 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
}
export 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
}
export 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
}
export 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
}
export interface SessionStats {
total_participants: number
active_participants: number
total_responses: number
completed_steps: number
total_steps: number
progress: number
}
export 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',
}
export const statusLabels: Record<string, string> = {
DRAFT: 'Entwurf',
SCHEDULED: 'Geplant',
ACTIVE: 'Aktiv',
PAUSED: 'Pausiert',
COMPLETED: 'Abgeschlossen',
CANCELLED: 'Abgebrochen',
}
export const typeLabels: Record<string, string> = {
ucca: 'UCCA Assessment',
dsfa: 'DSFA Workshop',
custom: 'Benutzerdefiniert',
}

View File

@@ -0,0 +1,13 @@
export const API_BASE = '/api/sdk/v1/workshops'
export 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()
}

View File

@@ -0,0 +1,37 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { WorkshopSession } from '../_components/types'
import { api } from '../_components/workshopApi'
export function useWorkshopSessions() {
const [sessions, setSessions] = useState<WorkshopSession[]>([])
const [loading, setLoading] = useState(true)
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)
}
}
return { sessions, loading, loadSessions, handleDelete }
}

View File

@@ -1,521 +1,18 @@
'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
// =============================================================================
import { useState } from 'react'
import { WorkshopSession, statusLabels } from './_components/types'
import { SessionCard } from './_components/SessionCard'
import { CreateSessionModal } from './_components/CreateSessionModal'
import { SessionDetailView } from './_components/SessionDetailView'
import { useWorkshopSessions } from './_hooks/useWorkshopSessions'
export default function WorkshopPage() {
const [sessions, setSessions] = useState<WorkshopSession[]>([])
const [loading, setLoading] = useState(true)
const { sessions, loading, loadSessions, handleDelete } = useWorkshopSessions()
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)