'use client'
import React, { useState, useEffect, useMemo } from 'react'
import Link from 'next/link'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
import {
Course,
CourseCategory,
Enrollment,
EnrollmentStatus,
AcademyStatistics,
COURSE_CATEGORY_INFO,
ENROLLMENT_STATUS_INFO,
isEnrollmentOverdue,
getDaysUntilDeadline
} from '@/lib/sdk/academy/types'
import {
fetchSDKAcademyList, generateAllCourses, fetchCertificates,
deleteEnrollment, updateEnrollment, updateCourse, completeEnrollment
} from '@/lib/sdk/academy/api'
import type { Certificate } from '@/lib/sdk/academy/types'
// =============================================================================
// TYPES
// =============================================================================
type TabId = 'overview' | 'courses' | 'enrollments' | 'certificates' | 'settings'
interface Tab {
id: TabId
label: string
count?: number
countColor?: string
}
// =============================================================================
// COMPONENTS
// =============================================================================
function TabNavigation({
tabs,
activeTab,
onTabChange
}: {
tabs: Tab[]
activeTab: TabId
onTabChange: (tab: TabId) => void
}) {
return (
{tabs.map(tab => (
onTabChange(tab.id)}
className={`
px-4 py-3 text-sm font-medium border-b-2 transition-colors
${activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
{tab.label}
{tab.count !== undefined && tab.count > 0 && (
{tab.count}
)}
))}
)
}
function StatCard({
label,
value,
color = 'gray',
icon,
trend
}: {
label: string
value: number | string
color?: 'gray' | 'blue' | 'yellow' | 'red' | 'green' | 'purple'
icon?: React.ReactNode
trend?: { value: number; label: string }
}) {
const colorClasses = {
gray: 'border-gray-200 text-gray-900',
blue: 'border-blue-200 text-blue-600',
yellow: 'border-yellow-200 text-yellow-600',
red: 'border-red-200 text-red-600',
green: 'border-green-200 text-green-600',
purple: 'border-purple-200 text-purple-600'
}
return (
{label}
{value}
{trend && (
= 0 ? 'text-green-600' : 'text-red-600'}`}>
{trend.value >= 0 ? '+' : ''}{trend.value} {trend.label}
)}
{icon && (
{icon}
)}
)
}
function CourseCard({ course, enrollmentCount, onEdit }: { course: Course; enrollmentCount: number; onEdit?: (course: Course) => void }) {
const categoryInfo = COURSE_CATEGORY_INFO[course.category] || COURSE_CATEGORY_INFO['custom']
return (
{categoryInfo.label}
{course.status === 'published' && (
Veroeffentlicht
)}
{course.title}
{course.description}
{course.lessons.length} Lektionen
{course.durationMinutes} Min.
{enrollmentCount} Teilnehmer
Bestehensgrenze: {course.passingScore}%
{course.requiredForRoles.includes('all') ? 'Pflicht fuer alle' : `${course.requiredForRoles.length} Rollen`}
{new Date(course.updatedAt).toLocaleDateString('de-DE')}
Erstellt: {new Date(course.createdAt).toLocaleDateString('de-DE')}
Details
{onEdit && (
{ e.preventDefault(); onEdit(course) }}
className="absolute top-3 right-3 p-1.5 bg-white rounded-lg shadow border border-gray-200 text-gray-400 hover:text-purple-600 hover:border-purple-300 opacity-0 group-hover:opacity-100 transition-all z-10"
title="Kurs bearbeiten"
>
)}
)
}
function EnrollmentCard({ enrollment, courseName, onEdit, onComplete, onDelete }: {
enrollment: Enrollment
courseName: string
onEdit?: (enrollment: Enrollment) => void
onComplete?: (id: string) => void
onDelete?: (id: string) => void
}) {
const statusInfo = ENROLLMENT_STATUS_INFO[enrollment.status]
const overdue = isEnrollmentOverdue(enrollment)
const daysUntil = getDaysUntilDeadline(enrollment.deadline)
return (
{/* Status Badge */}
{statusInfo.label}
{overdue && (
Ueberfaellig
)}
{/* User Info */}
{enrollment.userName}
{enrollment.userEmail}
{courseName}
{/* Progress Bar */}
Fortschritt
{enrollment.progress}%
{/* Right Side - Deadline */}
{enrollment.status === 'completed'
? 'Abgeschlossen'
: overdue
? `${Math.abs(daysUntil)} Tage ueberfaellig`
: `${daysUntil} Tage verbleibend`
}
Frist: {new Date(enrollment.deadline).toLocaleDateString('de-DE')}
{/* Footer */}
Gestartet: {new Date(enrollment.startedAt).toLocaleDateString('de-DE')}
{enrollment.completedAt && (
Abgeschlossen: {new Date(enrollment.completedAt).toLocaleDateString('de-DE')}
)}
{enrollment.status === 'in_progress' && onComplete && (
onComplete(enrollment.id)}
className="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Abschliessen
)}
{onEdit && (
onEdit(enrollment)}
className="px-3 py-1 text-xs bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
Bearbeiten
)}
{onDelete && (
onDelete(enrollment.id)}
className="px-3 py-1 text-xs bg-red-50 text-red-600 rounded-lg hover:bg-red-100 transition-colors"
>
Loeschen
)}
)
}
function FilterBar({
selectedCategory,
selectedStatus,
onCategoryChange,
onStatusChange,
onClear
}: {
selectedCategory: CourseCategory | 'all'
selectedStatus: EnrollmentStatus | 'all'
onCategoryChange: (category: CourseCategory | 'all') => void
onStatusChange: (status: EnrollmentStatus | 'all') => void
onClear: () => void
}) {
const hasFilters = selectedCategory !== 'all' || selectedStatus !== 'all'
return (
Filter:
{/* Category Filter */}
onCategoryChange(e.target.value as CourseCategory | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
Alle Kategorien
{Object.entries(COURSE_CATEGORY_INFO).map(([cat, info]) => (
{info.label}
))}
{/* Enrollment Status Filter */}
onStatusChange(e.target.value as EnrollmentStatus | 'all')}
className="px-3 py-1.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
Alle Status
{Object.entries(ENROLLMENT_STATUS_INFO).map(([status, info]) => (
{info.label}
))}
{/* Clear Filters */}
{hasFilters && (
Filter zuruecksetzen
)}
)
}
// =============================================================================
// MAIN PAGE
// =============================================================================
export default function AcademyPage() {
const { state } = useSDK()
const [activeTab, setActiveTab] = useState('overview')
const [courses, setCourses] = useState([])
const [enrollments, setEnrollments] = useState([])
const [certificates, setCertificates] = useState([])
const [statistics, setStatistics] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const [isGenerating, setIsGenerating] = useState(false)
const [generateResult, setGenerateResult] = useState<{ generated: number; skipped: number; errors: string[] } | null>(null)
// Modal states
const [editingEnrollment, setEditingEnrollment] = useState(null)
const [editingCourse, setEditingCourse] = useState(null)
const [settingsSaved, setSettingsSaved] = useState(false)
// Filters
const [selectedCategory, setSelectedCategory] = useState('all')
const [selectedStatus, setSelectedStatus] = useState('all')
const [certSearch, setCertSearch] = useState('')
// Load data from SDK backend
useEffect(() => {
loadData()
}, [])
// Calculate tab counts
const tabCounts = useMemo(() => {
return {
courses: courses.length,
enrollments: enrollments.filter(e => e.status !== 'completed').length,
certificates: certificates.length || enrollments.filter(e => e.certificateId).length,
overdue: enrollments.filter(e => isEnrollmentOverdue(e)).length
}
}, [courses, enrollments, certificates])
// Filtered courses
const filteredCourses = useMemo(() => {
let filtered = [...courses]
if (selectedCategory !== 'all') {
filtered = filtered.filter(c => c.category === selectedCategory)
}
return filtered.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
}, [courses, selectedCategory])
// Filtered enrollments
const filteredEnrollments = useMemo(() => {
let filtered = [...enrollments]
if (selectedStatus !== 'all') {
filtered = filtered.filter(e => e.status === selectedStatus)
}
// Sort: overdue first, then by deadline
return filtered.sort((a, b) => {
const aOverdue = isEnrollmentOverdue(a) ? -1 : 0
const bOverdue = isEnrollmentOverdue(b) ? -1 : 0
if (aOverdue !== bOverdue) return aOverdue - bOverdue
return getDaysUntilDeadline(a.deadline) - getDaysUntilDeadline(b.deadline)
})
}, [enrollments, selectedStatus])
// Enrollment counts per course
const enrollmentCountByCourseId = useMemo(() => {
const counts: Record = {}
enrollments.forEach(e => {
counts[e.courseId] = (counts[e.courseId] || 0) + 1
})
return counts
}, [enrollments])
// Course name lookup
const courseNameById = useMemo(() => {
const map: Record = {}
courses.forEach(c => { map[c.id] = c.title })
return map
}, [courses])
const tabs: Tab[] = [
{ id: 'overview', label: 'Uebersicht' },
{ id: 'courses', label: 'Kurse', count: tabCounts.courses, countColor: 'bg-blue-100 text-blue-600' },
{ id: 'enrollments', label: 'Einschreibungen', count: tabCounts.enrollments, countColor: 'bg-yellow-100 text-yellow-600' },
{ id: 'certificates', label: 'Zertifikate', count: tabCounts.certificates, countColor: 'bg-green-100 text-green-600' },
{ id: 'settings', label: 'Einstellungen' }
]
const stepInfo = STEP_EXPLANATIONS['academy']
const loadData = async () => {
setIsLoading(true)
try {
const [data, certs] = await Promise.allSettled([
fetchSDKAcademyList(),
fetchCertificates(),
])
if (data.status === 'fulfilled') {
setCourses(data.value.courses)
setEnrollments(data.value.enrollments)
setStatistics(data.value.statistics)
} else {
console.error('Failed to load Academy data:', data.reason)
}
if (certs.status === 'fulfilled') {
setCertificates(certs.value)
}
} finally {
setIsLoading(false)
}
}
const handleCompleteEnrollment = async (id: string) => {
try {
await completeEnrollment(id)
await loadData()
} catch (e) {
console.error('Failed to complete enrollment:', e)
}
}
const handleDeleteEnrollment = async (id: string) => {
if (!window.confirm('Einschreibung wirklich loeschen?')) return
try {
await deleteEnrollment(id)
await loadData()
} catch (e) {
console.error('Failed to delete enrollment:', e)
}
}
const handleGenerateAll = async () => {
setIsGenerating(true)
setGenerateResult(null)
try {
const result = await generateAllCourses()
setGenerateResult({ generated: result.generated, skipped: result.skipped, errors: result.errors || [] })
// Reload data to show new courses
await loadData()
} catch (error) {
console.error('Failed to generate courses:', error)
setGenerateResult({ generated: 0, skipped: 0, errors: [error instanceof Error ? error.message : 'Fehler bei der Generierung'] })
} finally {
setIsGenerating(false)
}
}
const clearFilters = () => {
setSelectedCategory('all')
setSelectedStatus('all')
}
return (
{/* Step Header */}
{isGenerating ? (
) : (
)}
{isGenerating ? 'Generiere...' : 'Alle Kurse generieren'}
Kurs erstellen
{/* Generation Result */}
{generateResult && (
0 ? 'bg-yellow-50 border-yellow-200' : 'bg-green-50 border-green-200'}`}>
{generateResult.generated} Kurse generiert
{generateResult.skipped} uebersprungen
{generateResult.errors.length > 0 && (
{generateResult.errors.length} Fehler
)}
{generateResult.errors.length > 0 && (
{generateResult.errors.map((err, i) =>
{err}
)}
)}
)}
{/* Tab Navigation */}
{/* Loading State */}
{isLoading ? (
) : activeTab === 'settings' ? (
/* Settings Tab — localStorage-basiert */
{ setSettingsSaved(true); setTimeout(() => setSettingsSaved(false), 2000) }} saved={settingsSaved} />
) : activeTab === 'certificates' ? (
/* Certificates Tab */
{/* Stats */}
{(() => {
const now = new Date()
const total = certificates.length
const valid = certificates.filter(c => new Date(c.validUntil) > now).length
const expired = certificates.filter(c => new Date(c.validUntil) <= now).length
return (
<>
>
)
})()}
{/* Search */}
setCertSearch(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
{/* Table */}
{certificates.length === 0 ? (
Noch keine Zertifikate ausgestellt
Zertifikate werden automatisch nach Kursabschluss generiert.
) : (
Mitarbeiter
Kurs
Ausgestellt am
Gueltig bis
Status
Aktionen
{certificates
.filter(c =>
!certSearch ||
c.userName.toLowerCase().includes(certSearch.toLowerCase()) ||
c.courseName.toLowerCase().includes(certSearch.toLowerCase())
)
.map(cert => )
}
)}
) : (
<>
{/* Statistics (Overview Tab) */}
{activeTab === 'overview' && statistics && (
0 ? 'red' : 'green'}
/>
)}
{/* Overdue Alert */}
{tabCounts.overdue > 0 && (
Achtung: {tabCounts.overdue} ueberfaellige Schulung(en)
Mitarbeiter haben Pflichtschulungen nicht fristgerecht abgeschlossen. Handeln Sie umgehend.
{
setActiveTab('enrollments')
setSelectedStatus('all')
}}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium"
>
Anzeigen
)}
{/* Info Box (Overview Tab) */}
{activeTab === 'overview' && (
Schulungspflicht nach Art. 39 DSGVO
Gemaess Art. 39 Abs. 1 lit. b DSGVO gehoert die Sensibilisierung und Schulung
der an den Verarbeitungsvorgaengen beteiligten Mitarbeiter zu den Aufgaben des
Datenschutzbeauftragten. Nachweisbare Compliance-Schulungen sind Pflicht und
sollten mindestens jaehrlich aufgefrischt werden.
)}
{/* Filters */}
{/* Courses Tab */}
{(activeTab === 'overview' || activeTab === 'courses') && (
{activeTab === 'courses' && (
Kurse ({filteredCourses.length})
)}
{filteredCourses.map(course => (
))}
)}
{/* Enrollments Tab */}
{activeTab === 'enrollments' && (
Einschreibungen ({filteredEnrollments.length})
{filteredEnrollments.map(enrollment => (
))}
)}
{/* Empty States */}
{activeTab === 'courses' && filteredCourses.length === 0 && (
Keine Kurse gefunden
{selectedCategory !== 'all'
? 'Passen Sie die Filter an oder'
: 'Es sind noch keine Kurse vorhanden.'
}
{selectedCategory !== 'all' ? (
Filter zuruecksetzen
) : (
Ersten Kurs erstellen
)}
)}
{activeTab === 'enrollments' && filteredEnrollments.length === 0 && (
Keine Einschreibungen gefunden
{selectedStatus !== 'all'
? 'Passen Sie die Filter an.'
: 'Es sind noch keine Mitarbeiter in Kurse eingeschrieben.'
}
{selectedStatus !== 'all' && (
Filter zuruecksetzen
)}
)}
>
)}
{/* Enrollment Edit Modal */}
{editingEnrollment && (
setEditingEnrollment(null)}
onSaved={() => { setEditingEnrollment(null); loadData() }}
/>
)}
{/* Course Edit Modal */}
{editingCourse && (
setEditingCourse(null)}
onSaved={() => { setEditingCourse(null); loadData() }}
/>
)}
)
}
// =============================================================================
// CERTIFICATE ROW COMPONENT
// =============================================================================
function CertificateRow({ cert }: { cert: Certificate }) {
const now = new Date()
const validUntil = new Date(cert.validUntil)
const daysLeft = Math.ceil((validUntil.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
const isExpired = daysLeft <= 0
const isExpiringSoon = daysLeft > 0 && daysLeft <= 30
return (
{cert.userName}
{cert.courseName}
{new Date(cert.issuedAt).toLocaleDateString('de-DE')}
{validUntil.toLocaleDateString('de-DE')}
{isExpired ? (
Abgelaufen
) : isExpiringSoon ? (
Laeuft bald ab
) : (
Gueltig
)}
{cert.pdfUrl ? (
PDF Download
) : (
Nicht verfuegbar
)}
)
}
// =============================================================================
// ENROLLMENT EDIT MODAL
// =============================================================================
function EnrollmentEditModal({ enrollment, onClose, onSaved }: {
enrollment: Enrollment
onClose: () => void
onSaved: () => void
}) {
const [deadline, setDeadline] = useState(enrollment.deadline ? enrollment.deadline.split('T')[0] : '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const handleSave = async () => {
setSaving(true)
setError(null)
try {
await updateEnrollment(enrollment.id, { deadline: new Date(deadline).toISOString() })
onSaved()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
} finally {
setSaving(false)
}
}
return (
Teilnehmer: {enrollment.userName}
Deadline
setDeadline(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
{error &&
{error}
}
Abbrechen
{saving ? 'Speichern...' : 'Speichern'}
)
}
// =============================================================================
// COURSE EDIT MODAL
// =============================================================================
function CourseEditModal({ course, onClose, onSaved }: {
course: Course
onClose: () => void
onSaved: () => void
}) {
const [title, setTitle] = useState(course.title)
const [description, setDescription] = useState(course.description)
const [category, setCategory] = useState(course.category)
const [durationMinutes, setDurationMinutes] = useState(course.durationMinutes)
const [passingScore, setPassingScore] = useState(course.passingScore ?? 70)
const [status, setStatus] = useState<'draft' | 'published'>(course.status ?? 'draft')
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const handleSave = async () => {
setSaving(true)
setError(null)
try {
await updateCourse(course.id, { title, description, category, durationMinutes, passingScore, status })
onSaved()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
} finally {
setSaving(false)
}
}
return (
Titel
setTitle(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
Beschreibung
Kategorie
setCategory(e.target.value as CourseCategory)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
{Object.entries(COURSE_CATEGORY_INFO).map(([key, info]) => (
{info.label}
))}
Status
setStatus(e.target.value as 'draft' | 'published')}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
>
Entwurf
Veroeffentlicht
{error &&
{error}
}
Abbrechen
{saving ? 'Speichern...' : 'Aenderungen speichern'}
)
}
// =============================================================================
// SETTINGS TAB
// =============================================================================
function SettingsTab({ onSaved, saved }: { onSaved: () => void; saved: boolean }) {
const SETTINGS_KEY = 'bp_academy_settings'
const loadSettings = () => {
try {
const raw = localStorage.getItem(SETTINGS_KEY)
if (raw) return JSON.parse(raw)
} catch { /* ignore */ }
return {}
}
const defaults = { emailReminders: true, reminderDays: 7, defaultPassingScore: 70, defaultValidityDays: 365 }
const saved_settings = loadSettings()
const [emailReminders, setEmailReminders] = useState(saved_settings.emailReminders ?? defaults.emailReminders)
const [reminderDays, setReminderDays] = useState(saved_settings.reminderDays ?? defaults.reminderDays)
const [defaultPassingScore, setDefaultPassingScore] = useState(saved_settings.defaultPassingScore ?? defaults.defaultPassingScore)
const [defaultValidityDays, setDefaultValidityDays] = useState(saved_settings.defaultValidityDays ?? defaults.defaultValidityDays)
const handleSave = () => {
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ emailReminders, reminderDays, defaultPassingScore, defaultValidityDays }))
onSaved()
}
return (
{/* Notifications */}
Benachrichtigungen
E-Mail-Erinnerung bei ueberfaelligen Kursen
Mitarbeiter per E-Mail an ausstehende Schulungen erinnern
setEmailReminders(!emailReminders)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${emailReminders ? 'bg-purple-600' : 'bg-gray-200'}`}
>
Tage vor Ablauf erinnern
setReminderDays(Number(e.target.value))}
className="w-32 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
{/* Course Defaults */}
Standard-Einstellungen fuer neue Kurse
{/* Info */}
Zertifikate werden automatisch nach erfolgreichem Kursabschluss generiert. Die Gueltigkeitsdauer gilt ab dem Ausstellungsdatum.
{saved ? 'Gespeichert ✓' : 'Einstellungen speichern'}
)
}