Files
breakpilot-compliance/admin-compliance/app/sdk/academy/page.tsx
Sharang Parnerkar ff9f5e849c refactor(admin): split academy page.tsx into colocated components
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>
2026-04-11 22:55:49 +02:00

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>
)
}