From d4845adea7bc66921e77c3efc9af776ed847108b Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Tue, 3 Mar 2026 14:24:13 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Academy=20&=20Training=20Module=20auf?= =?UTF-8?q?=20100%=20=E2=80=94=20vollst=C3=A4ndige=20Implementierung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paket A — Type Fixes: - Course Interface: passingScore, isActive, status ergänzt - BackendCourse: passing_score, status Mapping - CourseUpdateRequest: passingScore, status ergänzt - fetchAcademyStatistics: by_category/by_status Felder gemappt Paket B — Academy Zertifikate-Tab: - fetchCertificates() API Funktion - Vollständiger Tab: Stats (Gesamt/Gültig/Abgelaufen), Suche, Tabelle mit Status-Badges, PDF-Download Paket C — Academy Enrollment + Course Edit + Settings: - deleteEnrollment(), updateEnrollment() API - EnrollmentCard: Abschließen/Bearbeiten/Löschen Buttons - EnrollmentEditModal: Deadline bearbeiten - CourseCard: Stift-Icon (group-hover) → CourseEditModal - CourseEditModal: Titel/Beschreibung/Kategorie/Dauer/Bestehensgrenze/Status - SettingsTab: localStorage-basiert mit Toggles und Zahlen-Inputs + grüne Bestätigung Paket D — Training Modul-CRUD: - deleteModule() API Funktion - "+ Neues Modul" Button im Modules Tab Header - ModuleCreateModal: module_code, title, description, regulation_area, frequency_type, duration, pass_threshold - ModuleEditDrawer (Right-Slide): Edit + Aktiv-Toggle + Löschen Paket E — Training Matrix-Editor: - Matrix-Tabelle interaktiv: × Button je Badge zum Entfernen - "+ Hinzufügen" Button je Rolle - MatrixAddModal: Modul wählen, Pflicht-Toggle, Priorität Paket F — Training Assignments + UX-Fixes: - updateAssignment() API Funktion - AssignmentDetailDrawer (Right-Slide): Details, Status-Aktionen, Deadline-Edit - handleCheckEscalation: alert() → escalationResult State + blauer Banner (schließbar) - Media auto-sync useEffect: selectedModuleId → loadModuleMedia Co-Authored-By: Claude Sonnet 4.6 --- .../app/(sdk)/sdk/academy/page.tsx | 708 +++++++++++++++--- .../app/(sdk)/sdk/training/page.tsx | 542 +++++++++++++- admin-compliance/lib/sdk/academy/api.ts | 55 +- admin-compliance/lib/sdk/academy/types.ts | 6 + admin-compliance/lib/sdk/training/api.ts | 11 + 5 files changed, 1203 insertions(+), 119 deletions(-) diff --git a/admin-compliance/app/(sdk)/sdk/academy/page.tsx b/admin-compliance/app/(sdk)/sdk/academy/page.tsx index 8a5b736..9668984 100644 --- a/admin-compliance/app/(sdk)/sdk/academy/page.tsx +++ b/admin-compliance/app/(sdk)/sdk/academy/page.tsx @@ -15,7 +15,11 @@ import { isEnrollmentOverdue, getDaysUntilDeadline } from '@/lib/sdk/academy/types' -import { fetchSDKAcademyList, generateAllCourses } from '@/lib/sdk/academy/api' +import { + fetchSDKAcademyList, generateAllCourses, fetchCertificates, + deleteEnrollment, updateEnrollment, updateCourse, completeEnrollment +} from '@/lib/sdk/academy/api' +import type { Certificate } from '@/lib/sdk/academy/types' // ============================================================================= // TYPES @@ -124,83 +128,93 @@ function StatCard({ ) } -function CourseCard({ course, enrollmentCount }: { course: Course; enrollmentCount: number }) { +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 ( - -
-
-
- {/* Header Badges */} -
- - {categoryInfo.label} - +
+ +
+
+
+
+ + {categoryInfo.label} + + {course.status === 'published' && ( + Veroeffentlicht + )} +
+

{course.title}

+

{course.description}

+
+ + + + + {course.lessons.length} Lektionen + + + + + + {course.durationMinutes} Min. + + + + + + {enrollmentCount} Teilnehmer + + + + + + Bestehensgrenze: {course.passingScore}% + +
- - {/* Course Title */} -

- {course.title} -

-

- {course.description} -

- - {/* Course Meta */} -
- - - - - {course.lessons.length} Lektionen - - - - - - {course.durationMinutes} Min. - - - - - - {enrollmentCount} Teilnehmer - +
+
+ {course.requiredForRoles.includes('all') ? 'Pflicht fuer alle' : `${course.requiredForRoles.length} Rollen`} +
+
+ {new Date(course.updatedAt).toLocaleDateString('de-DE')} +
- - {/* Right Side - Roles */} -
-
- {course.requiredForRoles.includes('all') ? 'Pflicht fuer alle' : `${course.requiredForRoles.length} Rollen`} +
+
+ Erstellt: {new Date(course.createdAt).toLocaleDateString('de-DE')}
-
- {new Date(course.updatedAt).toLocaleDateString('de-DE')} -
-
-
- - {/* Footer */} -
-
- Erstellt: {new Date(course.createdAt).toLocaleDateString('de-DE')} -
-
Details
-
- + + {onEdit && ( + + )} +
) } -function EnrollmentCard({ enrollment, courseName }: { enrollment: Enrollment; courseName: string }) { +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) @@ -281,12 +295,38 @@ function EnrollmentCard({ enrollment, courseName }: { enrollment: Enrollment; co
Gestartet: {new Date(enrollment.startedAt).toLocaleDateString('de-DE')} + {enrollment.completedAt && ( + + Abgeschlossen: {new Date(enrollment.completedAt).toLocaleDateString('de-DE')} + + )} +
+
+ {enrollment.status === 'in_progress' && onComplete && ( + + )} + {onEdit && ( + + )} + {onDelete && ( + + )}
- {enrollment.completedAt && ( -
- Abgeschlossen: {new Date(enrollment.completedAt).toLocaleDateString('de-DE')} -
- )}
) @@ -357,14 +397,21 @@ export default function AcademyPage() { 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(() => { @@ -376,10 +423,10 @@ export default function AcademyPage() { return { courses: courses.length, enrollments: enrollments.filter(e => e.status !== 'completed').length, - certificates: enrollments.filter(e => e.certificateId).length, + certificates: certificates.length || enrollments.filter(e => e.certificateId).length, overdue: enrollments.filter(e => isEnrollmentOverdue(e)).length } - }, [courses, enrollments]) + }, [courses, enrollments, certificates]) // Filtered courses const filteredCourses = useMemo(() => { @@ -438,17 +485,44 @@ export default function AcademyPage() { const loadData = async () => { setIsLoading(true) try { - const data = await fetchSDKAcademyList() - setCourses(data.courses) - setEnrollments(data.enrollments) - setStatistics(data.statistics) - } catch (error) { - console.error('Failed to load Academy data:', error) + 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) @@ -544,37 +618,84 @@ export default function AcademyPage() {
) : activeTab === 'settings' ? ( - /* Settings Tab */ -
-
- - - - -
-

Einstellungen

-

- Academy-Einstellungen, E-Mail-Benachrichtigungen und Kurs-Vorlagen - werden in einer spaeteren Version verfuegbar sein. -

-
+ /* Settings Tab — localStorage-basiert */ + { setSettingsSaved(true); setTimeout(() => setSettingsSaved(false), 2000) }} saved={settingsSaved} /> ) : activeTab === 'certificates' ? ( - /* Certificates Tab Placeholder */ -
-
- - - + /* 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 ( + <> +
+
{total}
+
Gesamt
+
+
+
{valid}
+
Gueltig
+
+
+
{expired}
+
Abgelaufen
+
+ + ) + })()}
-

Zertifikate

-

- Zertifikate werden automatisch nach erfolgreichem Kursabschluss generiert. - Die Zertifikatsverwaltung wird in einer spaeteren Version verfuegbar sein. -

- {tabCounts.certificates > 0 && ( -

- {tabCounts.certificates} Zertifikat(e) vorhanden -

+ + {/* 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.

+
+ ) : ( +
+ + + + + + + + + + + + + {certificates + .filter(c => + !certSearch || + c.userName.toLowerCase().includes(certSearch.toLowerCase()) || + c.courseName.toLowerCase().includes(certSearch.toLowerCase()) + ) + .map(cert => ) + } + +
MitarbeiterKursAusgestellt amGueltig bisStatusAktionen
+
)}
) : ( @@ -673,6 +794,7 @@ export default function AcademyPage() { key={course.id} course={course} enrollmentCount={enrollmentCountByCourseId[course.id] || 0} + onEdit={setEditingCourse} /> ))}
@@ -687,6 +809,9 @@ export default function AcademyPage() { key={enrollment.id} enrollment={enrollment} courseName={courseNameById[enrollment.courseId] || 'Unbekannter Kurs'} + onEdit={setEditingEnrollment} + onComplete={handleCompleteEnrollment} + onDelete={handleDeleteEnrollment} /> ))}
@@ -754,6 +879,379 @@ export default function AcademyPage() { )} )} + + {/* 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 ( +
+
+
+

Einschreibung bearbeiten

+ +
+
+
+

Teilnehmer: {enrollment.userName}

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

} +
+
+ + +
+
+
+ ) +} + +// ============================================================================= +// 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 ( +
+
+
+

Kurs bearbeiten

+ +
+
+
+ + 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" + /> +
+
+ +