refactor(admin): split training, control-provenance, iace/verification, training/learner, ControlDetail
All 5 files reduced below 500 LOC (hard cap) by extracting sub-components: - training/page.tsx: 780→278 LOC — imports existing _components/, adds BlocksSection - control-provenance/page.tsx: 739→145 LOC — extracts provenance-data.ts, ProvenanceHelpers, LicenseMatrix, SourceRegistry - iace/[projectId]/verification/page.tsx: 673→164 LOC — extracts VerificationForm, CompleteModal, SuggestEvidenceModal, VerificationTable - training/learner/page.tsx: 560→216 LOC — extracts AssignmentsList, ContentView, QuizView, CertificatesView - ControlDetail.tsx: 878→311 LOC — adds ControlSourceCitation, ControlTraceability, ControlRegulatorySection, ControlSimilarControls, ControlReviewActions siblings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
|
||||
import type { TrainingAssignment } from '@/lib/sdk/training/types'
|
||||
import { STATUS_LABELS, STATUS_COLORS } from '@/lib/sdk/training/types'
|
||||
|
||||
interface AssignmentsListProps {
|
||||
assignments: TrainingAssignment[]
|
||||
loading: boolean
|
||||
certGenerating: boolean
|
||||
onStart: (a: TrainingAssignment) => void
|
||||
onResume: (a: TrainingAssignment) => void
|
||||
onGenerateCertificate: (id: string) => void
|
||||
onDownloadPDF: (certId: string) => void
|
||||
}
|
||||
|
||||
export function AssignmentsList({
|
||||
assignments, loading, certGenerating, onStart, onResume, onGenerateCertificate, onDownloadPDF,
|
||||
}: AssignmentsListProps) {
|
||||
if (loading) return <div className="text-center py-12 text-gray-400">Lade Schulungen...</div>
|
||||
if (assignments.length === 0) return <div className="text-center py-12 text-gray-400">Keine Schulungen zugewiesen</div>
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{assignments.map(a => (
|
||||
<div key={a.id} className="bg-white border border-gray-200 rounded-lg p-5 hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-semibold text-gray-900">{a.module_title || a.module_code}</h3>
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[a.status]?.bg || 'bg-gray-100'} ${STATUS_COLORS[a.status]?.text || 'text-gray-700'}`}>
|
||||
{STATUS_LABELS[a.status] || a.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Code: {a.module_code} | Deadline: {new Date(a.deadline).toLocaleDateString('de-DE')}
|
||||
{a.quiz_score != null && ` | Quiz: ${Math.round(a.quiz_score)}%`}
|
||||
</p>
|
||||
<div className="mt-3 w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${a.status === 'completed' ? 'bg-green-500' : 'bg-indigo-500'}`}
|
||||
style={{ width: `${a.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">{a.progress_percent}% abgeschlossen</p>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
{a.status === 'pending' && (
|
||||
<button onClick={() => onStart(a)} className="px-3 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700">Starten</button>
|
||||
)}
|
||||
{a.status === 'in_progress' && (
|
||||
<button onClick={() => onResume(a)} className="px-3 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700">Fortsetzen</button>
|
||||
)}
|
||||
{a.status === 'completed' && a.quiz_passed && !a.certificate_id && (
|
||||
<button onClick={() => onGenerateCertificate(a.id)} disabled={certGenerating} className="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||
{certGenerating ? 'Erstelle...' : 'Zertifikat'}
|
||||
</button>
|
||||
)}
|
||||
{a.certificate_id && (
|
||||
<button onClick={() => onDownloadPDF(a.certificate_id!)} className="px-3 py-1.5 bg-green-100 text-green-700 text-sm rounded-lg hover:bg-green-200">PDF</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import type { TrainingAssignment } from '@/lib/sdk/training/types'
|
||||
|
||||
interface CertificatesViewProps {
|
||||
certificates: TrainingAssignment[]
|
||||
onDownloadPDF: (certId: string) => void
|
||||
}
|
||||
|
||||
export function CertificatesView({ certificates, onDownloadPDF }: CertificatesViewProps) {
|
||||
if (certificates.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
Noch keine Zertifikate vorhanden. Schliessen Sie eine Schulung mit Quiz ab.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{certificates.map(cert => (
|
||||
<div key={cert.id} className="bg-white border border-gray-200 rounded-lg p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="font-semibold text-gray-900 text-sm">{cert.module_title}</h3>
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded-full">Bestanden</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<p>Mitarbeiter: {cert.user_name}</p>
|
||||
<p>Abschluss: {cert.completed_at ? new Date(cert.completed_at).toLocaleDateString('de-DE') : '-'}</p>
|
||||
{cert.quiz_score != null && <p>Ergebnis: {Math.round(cert.quiz_score)}%</p>}
|
||||
<p className="font-mono text-[10px] text-gray-400">ID: {cert.certificate_id?.substring(0, 12)}</p>
|
||||
</div>
|
||||
{cert.certificate_id && (
|
||||
<button
|
||||
onClick={() => onDownloadPDF(cert.certificate_id!)}
|
||||
className="mt-3 w-full px-3 py-1.5 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
PDF herunterladen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import type { TrainingAssignment, ModuleContent, TrainingMedia, InteractiveVideoManifest } from '@/lib/sdk/training/types'
|
||||
import { getMediaStreamURL } from '@/lib/sdk/training/api'
|
||||
import InteractiveVideoPlayer from '@/components/training/InteractiveVideoPlayer'
|
||||
|
||||
function simpleMarkdownToHtml(md: string): string {
|
||||
return md
|
||||
.replace(/^### (.+)$/gm, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2 class="text-xl font-bold mt-6 mb-3">$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1 class="text-2xl font-bold mt-6 mb-3">$1</h1>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
.replace(/^- (.+)$/gm, '<li class="ml-4 list-disc">$1</li>')
|
||||
.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 list-decimal">$2</li>')
|
||||
.replace(/\n\n/g, '<br/><br/>')
|
||||
}
|
||||
|
||||
interface ContentViewProps {
|
||||
selectedAssignment: TrainingAssignment | null
|
||||
content: ModuleContent | null
|
||||
media: TrainingMedia[]
|
||||
interactiveManifest: InteractiveVideoManifest | null
|
||||
onStartQuiz: () => void
|
||||
onAllCheckpointsPassed: () => void
|
||||
}
|
||||
|
||||
export function ContentView({
|
||||
selectedAssignment, content, media, interactiveManifest, onStartQuiz, onAllCheckpointsPassed,
|
||||
}: ContentViewProps) {
|
||||
if (!selectedAssignment) {
|
||||
return <div className="text-center py-12 text-gray-400">Waehlen Sie eine Schulung aus dem Tab "Meine Schulungen"</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{selectedAssignment.module_title}</h2>
|
||||
<button onClick={onStartQuiz} className="px-4 py-2 bg-indigo-600 text-white text-sm rounded-lg hover:bg-indigo-700">Quiz starten</button>
|
||||
</div>
|
||||
|
||||
{interactiveManifest && (
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<p className="text-sm font-medium text-gray-700">Interaktive Video-Schulung</p>
|
||||
<span className="px-2 py-0.5 text-xs bg-purple-100 text-purple-700 rounded-full">Interaktiv</span>
|
||||
</div>
|
||||
<InteractiveVideoPlayer
|
||||
manifest={interactiveManifest}
|
||||
assignmentId={selectedAssignment.id}
|
||||
onAllCheckpointsPassed={onAllCheckpointsPassed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{media.length > 0 && (
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-2">
|
||||
{media.filter(m => m.media_type === 'audio' && m.status === 'completed').map(m => (
|
||||
<div key={m.id} className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Audio-Schulung</p>
|
||||
<audio controls className="w-full" src={getMediaStreamURL(m.id)}>Ihr Browser unterstuetzt kein Audio.</audio>
|
||||
</div>
|
||||
))}
|
||||
{media.filter(m => m.media_type === 'video' && m.status === 'completed' && m.generated_by !== 'tts_ffmpeg_interactive').map(m => (
|
||||
<div key={m.id} className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Video-Schulung</p>
|
||||
<video controls className="w-full rounded" src={getMediaStreamURL(m.id)}>Ihr Browser unterstuetzt kein Video.</video>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{content ? (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||
<div className="prose max-w-none text-gray-800" dangerouslySetInnerHTML={{ __html: simpleMarkdownToHtml(content.content_body) }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-400">Kein Schulungsinhalt verfuegbar</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
|
||||
import type { TrainingAssignment, QuizSubmitResponse } from '@/lib/sdk/training/types'
|
||||
|
||||
interface QuizQuestion {
|
||||
id: string
|
||||
question: string
|
||||
options: string[]
|
||||
difficulty: string
|
||||
}
|
||||
|
||||
function formatTimer(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = seconds % 60
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
interface QuizViewProps {
|
||||
questions: QuizQuestion[]
|
||||
answers: Record<string, number>
|
||||
quizResult: QuizSubmitResponse | null
|
||||
quizSubmitting: boolean
|
||||
quizTimer: number
|
||||
selectedAssignment: TrainingAssignment | null
|
||||
certGenerating: boolean
|
||||
onAnswerChange: (questionId: string, optionIndex: number) => void
|
||||
onSubmitQuiz: () => void
|
||||
onRetryQuiz: () => void
|
||||
onGenerateCertificate: (assignmentId: string) => void
|
||||
}
|
||||
|
||||
export function QuizView({
|
||||
questions, answers, quizResult, quizSubmitting, quizTimer,
|
||||
selectedAssignment, certGenerating, onAnswerChange, onSubmitQuiz, onRetryQuiz, onGenerateCertificate,
|
||||
}: QuizViewProps) {
|
||||
if (questions.length === 0) {
|
||||
return <div className="text-center py-12 text-gray-400">Starten Sie ein Quiz aus dem Schulungsinhalt-Tab</div>
|
||||
}
|
||||
|
||||
if (quizResult) {
|
||||
return (
|
||||
<div className="max-w-lg mx-auto">
|
||||
<div className={`text-center p-8 rounded-lg border-2 ${quizResult.passed ? 'border-green-300 bg-green-50' : 'border-red-300 bg-red-50'}`}>
|
||||
<div className="text-4xl mb-3">{quizResult.passed ? '\u2705' : '\u274C'}</div>
|
||||
<h2 className="text-2xl font-bold mb-2">{quizResult.passed ? 'Bestanden!' : 'Nicht bestanden'}</h2>
|
||||
<p className="text-lg text-gray-700">{quizResult.correct_count} von {quizResult.total_count} richtig ({Math.round(quizResult.score)}%)</p>
|
||||
<p className="text-sm text-gray-500 mt-1">Bestehensgrenze: {quizResult.threshold}% | Zeit: {formatTimer(quizTimer)}</p>
|
||||
{quizResult.passed && selectedAssignment && !selectedAssignment.certificate_id && (
|
||||
<button onClick={() => onGenerateCertificate(selectedAssignment.id)} disabled={certGenerating}
|
||||
className="mt-4 px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||
{certGenerating ? 'Erstelle Zertifikat...' : 'Zertifikat generieren & herunterladen'}
|
||||
</button>
|
||||
)}
|
||||
{!quizResult.passed && (
|
||||
<button onClick={onRetryQuiz} className="mt-4 px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700">
|
||||
Quiz erneut versuchen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Quiz — {selectedAssignment?.module_title}</h2>
|
||||
<span className="text-sm text-gray-500 font-mono bg-gray-100 px-3 py-1 rounded">{formatTimer(quizTimer)}</span>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{questions.map((q, idx) => (
|
||||
<div key={q.id} className="bg-white border border-gray-200 rounded-lg p-5">
|
||||
<p className="font-medium text-gray-900 mb-3">
|
||||
<span className="text-indigo-600 mr-2">Frage {idx + 1}.</span>{q.question}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{q.options.map((opt, oi) => (
|
||||
<label key={oi} className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
answers[q.id] === oi ? 'border-indigo-500 bg-indigo-50' : 'border-gray-200 hover:bg-gray-50'
|
||||
}`}>
|
||||
<input type="radio" name={q.id} checked={answers[q.id] === oi}
|
||||
onChange={() => onAnswerChange(q.id, oi)} className="text-indigo-600" />
|
||||
<span className="text-sm text-gray-700">{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<button onClick={onSubmitQuiz} disabled={quizSubmitting || Object.keys(answers).length < questions.length}
|
||||
className="px-6 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
|
||||
{quizSubmitting ? 'Wird ausgewertet...' : `Quiz abgeben (${Object.keys(answers).length}/${questions.length})`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user