Split 1257-LOC client page into _types.ts plus nine components under _components/ (TabNavigation/StatCard/FilterBar in shared, CourseCard, EnrollmentCard, CertificatesTab, EnrollmentEditModal, CourseEditModal, SettingsTab, and PageSections for header actions and empty states). Behavior preserved exactly; page.tsx is now a thin wiring shell. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useEffect, useMemo } from 'react'
|
|
import { useSDK } from '@/lib/sdk'
|
|
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
|
|
import {
|
|
Course,
|
|
CourseCategory,
|
|
Enrollment,
|
|
EnrollmentStatus,
|
|
AcademyStatistics,
|
|
isEnrollmentOverdue,
|
|
getDaysUntilDeadline
|
|
} from '@/lib/sdk/academy/types'
|
|
import {
|
|
fetchSDKAcademyList, generateAllCourses, fetchCertificates,
|
|
deleteEnrollment, completeEnrollment
|
|
} from '@/lib/sdk/academy/api'
|
|
import type { Certificate } from '@/lib/sdk/academy/types'
|
|
import { Tab, TabId } from './_types'
|
|
import { TabNavigation, StatCard, FilterBar } from './_components/shared'
|
|
import { CourseCard } from './_components/CourseCard'
|
|
import { EnrollmentCard } from './_components/EnrollmentCard'
|
|
import { CertificatesTab } from './_components/CertificatesTab'
|
|
import { EnrollmentEditModal } from './_components/EnrollmentEditModal'
|
|
import { CourseEditModal } from './_components/CourseEditModal'
|
|
import { SettingsTab } from './_components/SettingsTab'
|
|
import {
|
|
HeaderActions,
|
|
GenerationResultBar,
|
|
LoadingSpinner,
|
|
OverdueAlert,
|
|
InfoBox,
|
|
EmptyCourses,
|
|
EmptyEnrollments
|
|
} from './_components/PageSections'
|
|
|
|
// =============================================================================
|
|
// MAIN PAGE
|
|
// =============================================================================
|
|
|
|
export default function AcademyPage() {
|
|
const { state } = useSDK()
|
|
const [activeTab, setActiveTab] = useState<TabId>('overview')
|
|
const [courses, setCourses] = useState<Course[]>([])
|
|
const [enrollments, setEnrollments] = useState<Enrollment[]>([])
|
|
const [certificates, setCertificates] = useState<Certificate[]>([])
|
|
const [statistics, setStatistics] = useState<AcademyStatistics | null>(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<Enrollment | null>(null)
|
|
const [editingCourse, setEditingCourse] = useState<Course | null>(null)
|
|
const [settingsSaved, setSettingsSaved] = useState(false)
|
|
|
|
// Filters
|
|
const [selectedCategory, setSelectedCategory] = useState<CourseCategory | 'all'>('all')
|
|
const [selectedStatus, setSelectedStatus] = useState<EnrollmentStatus | 'all'>('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<string, number> = {}
|
|
enrollments.forEach(e => {
|
|
counts[e.courseId] = (counts[e.courseId] || 0) + 1
|
|
})
|
|
return counts
|
|
}, [enrollments])
|
|
|
|
// Course name lookup
|
|
const courseNameById = useMemo(() => {
|
|
const map: Record<string, string> = {}
|
|
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 (
|
|
<div className="space-y-6">
|
|
{/* Step Header */}
|
|
<StepHeader
|
|
stepId="academy"
|
|
title={stepInfo?.title || 'Compliance Academy'}
|
|
description={stepInfo?.description || 'E-Learning Plattform fuer Compliance-Schulungen'}
|
|
explanation={stepInfo?.explanation}
|
|
tips={stepInfo?.tips}
|
|
>
|
|
<HeaderActions isGenerating={isGenerating} onGenerateAll={handleGenerateAll} />
|
|
</StepHeader>
|
|
|
|
{/* Generation Result */}
|
|
{generateResult && <GenerationResultBar result={generateResult} />}
|
|
|
|
{/* Tab Navigation */}
|
|
<TabNavigation
|
|
tabs={tabs}
|
|
activeTab={activeTab}
|
|
onTabChange={setActiveTab}
|
|
/>
|
|
|
|
{/* Loading State */}
|
|
{isLoading ? (
|
|
<LoadingSpinner />
|
|
) : activeTab === 'settings' ? (
|
|
<SettingsTab onSaved={() => { setSettingsSaved(true); setTimeout(() => setSettingsSaved(false), 2000) }} saved={settingsSaved} />
|
|
) : activeTab === 'certificates' ? (
|
|
<CertificatesTab certificates={certificates} certSearch={certSearch} onSearchChange={setCertSearch} />
|
|
) : (
|
|
<>
|
|
{/* Statistics (Overview Tab) */}
|
|
{activeTab === 'overview' && statistics && (
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<StatCard
|
|
label="Kurse gesamt"
|
|
value={statistics.totalCourses}
|
|
color="gray"
|
|
/>
|
|
<StatCard
|
|
label="Aktive Teilnehmer"
|
|
value={(statistics.byStatus?.in_progress || 0) + (statistics.byStatus?.not_started || 0)}
|
|
color="blue"
|
|
/>
|
|
<StatCard
|
|
label="Abschlussrate"
|
|
value={`${statistics.completionRate}%`}
|
|
color="green"
|
|
/>
|
|
<StatCard
|
|
label="Ueberfaellig"
|
|
value={statistics.overdueCount}
|
|
color={statistics.overdueCount > 0 ? 'red' : 'green'}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Overdue Alert */}
|
|
{tabCounts.overdue > 0 && (
|
|
<OverdueAlert
|
|
count={tabCounts.overdue}
|
|
onShow={() => {
|
|
setActiveTab('enrollments')
|
|
setSelectedStatus('all')
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Info Box (Overview Tab) */}
|
|
{activeTab === 'overview' && <InfoBox />}
|
|
|
|
{/* Filters */}
|
|
<FilterBar
|
|
selectedCategory={selectedCategory}
|
|
selectedStatus={selectedStatus}
|
|
onCategoryChange={setSelectedCategory}
|
|
onStatusChange={setSelectedStatus}
|
|
onClear={clearFilters}
|
|
/>
|
|
|
|
{/* Courses Tab */}
|
|
{(activeTab === 'overview' || activeTab === 'courses') && (
|
|
<div className="space-y-4">
|
|
{activeTab === 'courses' && (
|
|
<h2 className="text-lg font-semibold text-gray-900">Kurse ({filteredCourses.length})</h2>
|
|
)}
|
|
{filteredCourses.map(course => (
|
|
<CourseCard
|
|
key={course.id}
|
|
course={course}
|
|
enrollmentCount={enrollmentCountByCourseId[course.id] || 0}
|
|
onEdit={setEditingCourse}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Enrollments Tab */}
|
|
{activeTab === 'enrollments' && (
|
|
<div className="space-y-4">
|
|
<h2 className="text-lg font-semibold text-gray-900">Einschreibungen ({filteredEnrollments.length})</h2>
|
|
{filteredEnrollments.map(enrollment => (
|
|
<EnrollmentCard
|
|
key={enrollment.id}
|
|
enrollment={enrollment}
|
|
courseName={courseNameById[enrollment.courseId] || 'Unbekannter Kurs'}
|
|
onEdit={setEditingEnrollment}
|
|
onComplete={handleCompleteEnrollment}
|
|
onDelete={handleDeleteEnrollment}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty States */}
|
|
{activeTab === 'courses' && filteredCourses.length === 0 && (
|
|
<EmptyCourses selectedCategory={selectedCategory} onClearFilters={clearFilters} />
|
|
)}
|
|
|
|
{activeTab === 'enrollments' && filteredEnrollments.length === 0 && (
|
|
<EmptyEnrollments selectedStatus={selectedStatus} onClearFilters={clearFilters} />
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{/* Enrollment Edit Modal */}
|
|
{editingEnrollment && (
|
|
<EnrollmentEditModal
|
|
enrollment={editingEnrollment}
|
|
onClose={() => setEditingEnrollment(null)}
|
|
onSaved={() => { setEditingEnrollment(null); loadData() }}
|
|
/>
|
|
)}
|
|
|
|
{/* Course Edit Modal */}
|
|
{editingCourse && (
|
|
<CourseEditModal
|
|
course={editingCourse}
|
|
onClose={() => setEditingCourse(null)}
|
|
onSaved={() => { setEditingCourse(null); loadData() }}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|