feat: Academy & Training Module auf 100% — vollständige Implementierung
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 38s
CI / test-python-backend-compliance (push) Successful in 36s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 19s

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 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-03 14:24:13 +01:00
parent 232997deb6
commit d4845adea7
5 changed files with 1203 additions and 119 deletions

View File

@@ -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 (
<Link href={`/sdk/academy/${course.id}`}>
<div className={`
bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer
border-gray-200 hover:border-purple-300
`}>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
{/* Header Badges */}
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
{categoryInfo.label}
</span>
<div className="relative group">
<Link href={`/sdk/academy/${course.id}`}>
<div className="bg-white rounded-xl border-2 p-6 hover:shadow-md transition-all cursor-pointer border-gray-200 hover:border-purple-300">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span className={`px-2 py-1 text-xs rounded-full ${categoryInfo.bgColor} ${categoryInfo.color}`}>
{categoryInfo.label}
</span>
{course.status === 'published' && (
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">Veroeffentlicht</span>
)}
</div>
<h3 className="text-lg font-semibold text-gray-900 truncate">{course.title}</h3>
<p className="text-sm text-gray-500 mt-1 line-clamp-2">{course.description}</p>
<div className="mt-3 flex items-center gap-4 text-sm text-gray-500">
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
{course.lessons.length} Lektionen
</span>
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{course.durationMinutes} Min.
</span>
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{enrollmentCount} Teilnehmer
</span>
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Bestehensgrenze: {course.passingScore}%
</span>
</div>
</div>
{/* Course Title */}
<h3 className="text-lg font-semibold text-gray-900 truncate">
{course.title}
</h3>
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
{course.description}
</p>
{/* Course Meta */}
<div className="mt-3 flex items-center gap-4 text-sm text-gray-500">
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
{course.lessons.length} Lektionen
</span>
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{course.durationMinutes} Min.
</span>
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{enrollmentCount} Teilnehmer
</span>
<div className="text-right ml-4 text-gray-500">
<div className="text-sm font-medium">
{course.requiredForRoles.includes('all') ? 'Pflicht fuer alle' : `${course.requiredForRoles.length} Rollen`}
</div>
<div className="text-xs mt-0.5">
{new Date(course.updatedAt).toLocaleDateString('de-DE')}
</div>
</div>
</div>
{/* Right Side - Roles */}
<div className="text-right ml-4 text-gray-500">
<div className="text-sm font-medium">
{course.requiredForRoles.includes('all') ? 'Pflicht fuer alle' : `${course.requiredForRoles.length} Rollen`}
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="text-sm text-gray-500">
Erstellt: {new Date(course.createdAt).toLocaleDateString('de-DE')}
</div>
<div className="text-xs mt-0.5">
{new Date(course.updatedAt).toLocaleDateString('de-DE')}
</div>
</div>
</div>
{/* Footer */}
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="text-sm text-gray-500">
Erstellt: {new Date(course.createdAt).toLocaleDateString('de-DE')}
</div>
<div className="flex items-center gap-2">
<span className="px-3 py-1 text-sm text-purple-600 hover:bg-purple-50 rounded-lg transition-colors">
Details
</span>
</div>
</div>
</div>
</Link>
</Link>
{onEdit && (
<button
onClick={(e) => { 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"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
)}
</div>
)
}
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
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between">
<div className="text-sm text-gray-500">
Gestartet: {new Date(enrollment.startedAt).toLocaleDateString('de-DE')}
{enrollment.completedAt && (
<span className="ml-3 text-green-600">
Abgeschlossen: {new Date(enrollment.completedAt).toLocaleDateString('de-DE')}
</span>
)}
</div>
<div className="flex items-center gap-2">
{enrollment.status === 'in_progress' && onComplete && (
<button
onClick={() => onComplete(enrollment.id)}
className="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
>
Abschliessen
</button>
)}
{onEdit && (
<button
onClick={() => onEdit(enrollment)}
className="px-3 py-1 text-xs bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
Bearbeiten
</button>
)}
{onDelete && (
<button
onClick={() => 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
</button>
)}
</div>
{enrollment.completedAt && (
<div className="text-sm text-green-600">
Abgeschlossen: {new Date(enrollment.completedAt).toLocaleDateString('de-DE')}
</div>
)}
</div>
</div>
)
@@ -357,14 +397,21 @@ export default function AcademyPage() {
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(() => {
@@ -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() {
</svg>
</div>
) : activeTab === 'settings' ? (
/* Settings Tab */
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Einstellungen</h3>
<p className="mt-2 text-gray-500">
Academy-Einstellungen, E-Mail-Benachrichtigungen und Kurs-Vorlagen
werden in einer spaeteren Version verfuegbar sein.
</p>
</div>
/* Settings Tab — localStorage-basiert */
<SettingsTab onSaved={() => { setSettingsSaved(true); setTimeout(() => setSettingsSaved(false), 2000) }} saved={settingsSaved} />
) : activeTab === 'certificates' ? (
/* Certificates Tab Placeholder */
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
</svg>
/* Certificates Tab */
<div className="space-y-4">
{/* Stats */}
<div className="grid grid-cols-3 gap-4">
{(() => {
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 (
<>
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center">
<div className="text-2xl font-bold text-gray-900">{total}</div>
<div className="text-sm text-gray-500">Gesamt</div>
</div>
<div className="bg-white rounded-xl border border-green-200 p-4 text-center">
<div className="text-2xl font-bold text-green-600">{valid}</div>
<div className="text-sm text-gray-500">Gueltig</div>
</div>
<div className="bg-white rounded-xl border border-red-200 p-4 text-center">
<div className="text-2xl font-bold text-red-600">{expired}</div>
<div className="text-sm text-gray-500">Abgelaufen</div>
</div>
</>
)
})()}
</div>
<h3 className="text-lg font-semibold text-gray-900">Zertifikate</h3>
<p className="mt-2 text-gray-500">
Zertifikate werden automatisch nach erfolgreichem Kursabschluss generiert.
Die Zertifikatsverwaltung wird in einer spaeteren Version verfuegbar sein.
</p>
{tabCounts.certificates > 0 && (
<p className="mt-2 text-sm text-purple-600 font-medium">
{tabCounts.certificates} Zertifikat(e) vorhanden
</p>
{/* Search */}
<div>
<input
type="text"
placeholder="Nach Mitarbeiter oder Kurs suchen..."
value={certSearch}
onChange={e => 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"
/>
</div>
{/* Table */}
{certificates.length === 0 ? (
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
<div className="w-16 h-16 mx-auto bg-gray-100 rounded-full flex items-center justify-center mb-4">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900">Noch keine Zertifikate ausgestellt</h3>
<p className="mt-2 text-gray-500">Zertifikate werden automatisch nach Kursabschluss generiert.</p>
</div>
) : (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-gray-700">Mitarbeiter</th>
<th className="text-left px-4 py-3 font-medium text-gray-700">Kurs</th>
<th className="text-left px-4 py-3 font-medium text-gray-700">Ausgestellt am</th>
<th className="text-left px-4 py-3 font-medium text-gray-700">Gueltig bis</th>
<th className="text-center px-4 py-3 font-medium text-gray-700">Status</th>
<th className="text-center px-4 py-3 font-medium text-gray-700">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{certificates
.filter(c =>
!certSearch ||
c.userName.toLowerCase().includes(certSearch.toLowerCase()) ||
c.courseName.toLowerCase().includes(certSearch.toLowerCase())
)
.map(cert => <CertificateRow key={cert.id} cert={cert} />)
}
</tbody>
</table>
</div>
)}
</div>
) : (
@@ -673,6 +794,7 @@ export default function AcademyPage() {
key={course.id}
course={course}
enrollmentCount={enrollmentCountByCourseId[course.id] || 0}
onEdit={setEditingCourse}
/>
))}
</div>
@@ -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}
/>
))}
</div>
@@ -754,6 +879,379 @@ export default function AcademyPage() {
)}
</>
)}
{/* 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>
)
}
// =============================================================================
// 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 (
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-gray-900">{cert.userName}</td>
<td className="px-4 py-3 text-gray-600">{cert.courseName}</td>
<td className="px-4 py-3 text-gray-500">{new Date(cert.issuedAt).toLocaleDateString('de-DE')}</td>
<td className="px-4 py-3 text-gray-500">{validUntil.toLocaleDateString('de-DE')}</td>
<td className="px-4 py-3 text-center">
{isExpired ? (
<span className="px-2 py-1 text-xs rounded-full bg-red-100 text-red-700">Abgelaufen</span>
) : isExpiringSoon ? (
<span className="px-2 py-1 text-xs rounded-full bg-orange-100 text-orange-700">Laeuft bald ab</span>
) : (
<span className="px-2 py-1 text-xs rounded-full bg-green-100 text-green-700">Gueltig</span>
)}
</td>
<td className="px-4 py-3 text-center">
{cert.pdfUrl ? (
<a
href={cert.pdfUrl}
download
target="_blank"
rel="noopener noreferrer"
className="px-3 py-1 text-xs bg-purple-600 text-white rounded hover:bg-purple-700 transition-colors"
>
PDF Download
</a>
) : (
<span className="px-3 py-1 text-xs bg-gray-100 text-gray-400 rounded cursor-not-allowed">
Nicht verfuegbar
</span>
)}
</td>
</tr>
)
}
// =============================================================================
// 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<string | null>(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 (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Einschreibung bearbeiten</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-6 space-y-4">
<div>
<p className="text-sm text-gray-500">Teilnehmer: <span className="font-medium text-gray-900">{enrollment.userName}</span></p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Deadline</label>
<input
type="date"
value={deadline}
onChange={e => 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"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
<button
onClick={handleSave}
disabled={saving || !deadline}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
</div>
)
}
// =============================================================================
// 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<CourseCategory>(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<string | null>(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 (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Kurs bearbeiten</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Titel</label>
<input
type="text"
value={title}
onChange={e => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
rows={3}
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"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
<select
value={category}
onChange={e => 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]) => (
<option key={key} value={key}>{info.label}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<select
value={status}
onChange={e => 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"
>
<option value="draft">Entwurf</option>
<option value="published">Veroeffentlicht</option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Dauer (Minuten)</label>
<input
type="number"
min={1}
value={durationMinutes}
onChange={e => setDurationMinutes(Number(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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Bestehensgrenze (%)</label>
<input
type="number"
min={0}
max={100}
value={passingScore}
onChange={e => setPassingScore(Number(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"
/>
</div>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">
Abbrechen
</button>
<button
onClick={handleSave}
disabled={saving || !title}
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50 transition-colors"
>
{saving ? 'Speichern...' : 'Aenderungen speichern'}
</button>
</div>
</div>
</div>
)
}
// =============================================================================
// 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<boolean>(saved_settings.emailReminders ?? defaults.emailReminders)
const [reminderDays, setReminderDays] = useState<number>(saved_settings.reminderDays ?? defaults.reminderDays)
const [defaultPassingScore, setDefaultPassingScore] = useState<number>(saved_settings.defaultPassingScore ?? defaults.defaultPassingScore)
const [defaultValidityDays, setDefaultValidityDays] = useState<number>(saved_settings.defaultValidityDays ?? defaults.defaultValidityDays)
const handleSave = () => {
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ emailReminders, reminderDays, defaultPassingScore, defaultValidityDays }))
onSaved()
}
return (
<div className="space-y-6 max-w-2xl">
{/* Notifications */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">Benachrichtigungen</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-gray-700">E-Mail-Erinnerung bei ueberfaelligen Kursen</div>
<div className="text-xs text-gray-500">Mitarbeiter per E-Mail an ausstehende Schulungen erinnern</div>
</div>
<button
onClick={() => setEmailReminders(!emailReminders)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${emailReminders ? 'bg-purple-600' : 'bg-gray-200'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${emailReminders ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Tage vor Ablauf erinnern</label>
<input
type="number"
min={1}
max={90}
value={reminderDays}
onChange={e => 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"
/>
</div>
</div>
</div>
{/* Course Defaults */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<h3 className="text-base font-semibold text-gray-900 mb-4">Standard-Einstellungen fuer neue Kurse</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Standard-Bestehensgrenze (%)</label>
<input
type="number"
min={0}
max={100}
value={defaultPassingScore}
onChange={e => setDefaultPassingScore(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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Gueltigkeitsdauer (Tage)</label>
<input
type="number"
min={1}
value={defaultValidityDays}
onChange={e => setDefaultValidityDays(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"
/>
</div>
</div>
</div>
{/* Info */}
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-blue-700">
Zertifikate werden automatisch nach erfolgreichem Kursabschluss generiert. Die Gueltigkeitsdauer gilt ab dem Ausstellungsdatum.
</p>
</div>
</div>
<button
onClick={handleSave}
className={`px-6 py-2 rounded-lg text-sm font-medium transition-colors ${
saved ? 'bg-green-600 text-white' : 'bg-purple-600 text-white hover:bg-purple-700'
}`}
>
{saved ? 'Gespeichert ✓' : 'Einstellungen speichern'}
</button>
</div>
)
}

View File

@@ -6,10 +6,13 @@ import {
getAuditLog, generateContent, generateQuiz,
publishContent, checkEscalation, getContent,
generateAllContent, generateAllQuizzes,
createModule, updateModule, deleteModule,
deleteMatrixEntry, setMatrixEntry,
startAssignment, completeAssignment, updateAssignment,
} from '@/lib/sdk/training/api'
import type {
TrainingModule, TrainingAssignment,
MatrixResponse, TrainingStats, DeadlineInfo, AuditLogEntry, ModuleContent, TrainingMedia, VideoScript,
MatrixResponse, TrainingStats, DeadlineInfo, AuditLogEntry, ModuleContent, TrainingMedia,
} from '@/lib/sdk/training/types'
import {
REGULATION_LABELS, REGULATION_COLORS, FREQUENCY_LABELS,
@@ -42,10 +45,23 @@ export default function TrainingPage() {
const [statusFilter, setStatusFilter] = useState<string>('')
const [regulationFilter, setRegulationFilter] = useState<string>('')
// Modal/Drawer states
const [showModuleCreate, setShowModuleCreate] = useState(false)
const [selectedModule, setSelectedModule] = useState<TrainingModule | null>(null)
const [matrixAddRole, setMatrixAddRole] = useState<string | null>(null)
const [selectedAssignment, setSelectedAssignment] = useState<TrainingAssignment | null>(null)
const [escalationResult, setEscalationResult] = useState<{ total_checked: number; escalated: number } | null>(null)
useEffect(() => {
loadData()
}, [])
useEffect(() => {
if (selectedModuleId) {
loadModuleMedia(selectedModuleId)
}
}, [selectedModuleId])
async function loadData() {
setLoading(true)
setError(null)
@@ -111,13 +127,23 @@ export default function TrainingPage() {
async function handleCheckEscalation() {
try {
const result = await checkEscalation()
alert(`Eskalation geprueft: ${result.total_checked} geprueft, ${result.escalated} eskaliert`)
setEscalationResult({ total_checked: result.total_checked, escalated: result.escalated })
await loadData()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler bei der Eskalationspruefung')
}
}
async function handleDeleteMatrixEntry(roleCode: string, moduleId: string) {
if (!window.confirm('Modulzuordnung entfernen?')) return
try {
await deleteMatrixEntry(roleCode, moduleId)
await loadData()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Entfernen')
}
}
async function handleLoadContent(moduleId: string) {
try {
const content = await getContent(moduleId)
@@ -241,6 +267,17 @@ export default function TrainingPage() {
{/* Tab Content */}
{activeTab === 'overview' && stats && (
<div className="space-y-6">
{escalationResult && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-center justify-between">
<div>
<span className="font-medium text-blue-900">Eskalation abgeschlossen: </span>
<span className="text-blue-700">
{escalationResult.total_checked} Zuweisungen geprueft, {escalationResult.escalated} eskaliert
</span>
</div>
<button onClick={() => setEscalationResult(null)} className="text-blue-400 hover:text-blue-600 text-lg font-bold">×</button>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<KPICard label="Module" value={stats.total_modules} />
<KPICard label="Zuweisungen" value={stats.total_assignments} />
@@ -302,18 +339,28 @@ export default function TrainingPage() {
{activeTab === 'modules' && (
<div className="space-y-4">
<div className="flex gap-3">
<div className="flex gap-3 items-center">
<select value={regulationFilter} onChange={e => setRegulationFilter(e.target.value)} className="px-3 py-1.5 text-sm border rounded-lg bg-white">
<option value="">Alle Bereiche</option>
{Object.entries(REGULATION_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
<button
onClick={() => setShowModuleCreate(true)}
className="ml-auto px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
>
+ Neues Modul
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredModules.map(m => (
<div key={m.id} className="bg-white border rounded-lg p-4 hover:shadow-sm transition-shadow">
<div
key={m.id}
onClick={() => setSelectedModule(m)}
className="bg-white border rounded-lg p-4 hover:shadow-md cursor-pointer transition-shadow"
>
<div className="flex items-start justify-between">
<div>
<span className={`inline-block px-2 py-0.5 rounded text-xs font-medium ${REGULATION_COLORS[m.regulation_area]?.bg || 'bg-gray-100'} ${REGULATION_COLORS[m.regulation_area]?.text || 'text-gray-700'}`}>
@@ -322,9 +369,14 @@ export default function TrainingPage() {
<h3 className="mt-2 font-medium text-gray-900">{m.title}</h3>
<p className="text-xs text-gray-500 mt-0.5">{m.module_code}</p>
</div>
{m.nis2_relevant && (
<span className="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-700 rounded">NIS2</span>
)}
<div className="flex flex-col items-end gap-1">
{m.nis2_relevant && (
<span className="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-700 rounded">NIS2</span>
)}
{!m.is_active && (
<span className="text-xs px-1.5 py-0.5 bg-gray-100 text-gray-500 rounded">Inaktiv</span>
)}
</div>
</div>
{m.description && <p className="text-sm text-gray-600 mt-2 line-clamp-2">{m.description}</p>}
<div className="flex items-center gap-3 mt-3 text-xs text-gray-500">
@@ -361,13 +413,28 @@ export default function TrainingPage() {
<span className="text-gray-500 ml-1 text-xs">{ROLE_LABELS[role]}</span>
</td>
<td className="p-2 border">
<div className="flex flex-wrap gap-1">
<div className="flex flex-wrap gap-2 items-center">
{entries.map(e => (
<span key={e.id || e.module_id} className={`inline-block px-2 py-0.5 rounded text-xs ${e.is_mandatory ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`} title={`${e.module_title} (${e.is_mandatory ? 'Pflicht' : 'Optional'})`}>
{e.module_code}
<span
key={e.id || e.module_id}
className={`inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full ${e.is_mandatory ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'}`}
title={`${e.module_title} (${e.is_mandatory ? 'Pflicht' : 'Optional'})`}
>
{e.is_mandatory ? '🔴' : '🔵'} {e.module_code}
<button
onClick={() => handleDeleteMatrixEntry(role, e.module_id)}
className="ml-1 text-gray-400 hover:text-red-500 font-bold leading-none"
title="Zuordnung entfernen"
>×</button>
</span>
))}
{entries.length === 0 && <span className="text-gray-400 text-xs">Keine Module</span>}
<button
onClick={() => setMatrixAddRole(role)}
className="px-2 py-1 text-xs text-blue-600 border border-blue-300 rounded-full hover:bg-blue-50 transition-colors"
>
+ Hinzufuegen
</button>
</div>
</td>
<td className="p-2 border text-center">{entries.length}</td>
@@ -407,7 +474,11 @@ export default function TrainingPage() {
</thead>
<tbody>
{filteredAssignments.map(a => (
<tr key={a.id} className="border-b hover:bg-gray-50">
<tr
key={a.id}
onClick={() => setSelectedAssignment(a)}
className="border-b hover:bg-gray-50 cursor-pointer"
>
<td className="p-2 border">
<div className="font-medium">{a.module_title || a.module_code}</div>
<div className="text-xs text-gray-500">{a.module_code}</div>
@@ -583,6 +654,455 @@ export default function TrainingPage() {
{auditLog.length === 0 && <p className="text-center text-gray-500 py-8">Keine Audit-Eintraege</p>}
</div>
)}
{/* Modals & Drawers */}
{showModuleCreate && (
<ModuleCreateModal
onClose={() => setShowModuleCreate(false)}
onSaved={() => { setShowModuleCreate(false); loadData() }}
/>
)}
{selectedModule && (
<ModuleEditDrawer
module={selectedModule}
onClose={() => setSelectedModule(null)}
onSaved={() => { setSelectedModule(null); loadData() }}
/>
)}
{matrixAddRole && (
<MatrixAddModal
roleCode={matrixAddRole}
modules={modules}
onClose={() => setMatrixAddRole(null)}
onSaved={() => { setMatrixAddRole(null); loadData() }}
/>
)}
{selectedAssignment && (
<AssignmentDetailDrawer
assignment={selectedAssignment}
onClose={() => setSelectedAssignment(null)}
onSaved={() => { setSelectedAssignment(null); loadData() }}
/>
)}
</div>
)
}
// =============================================================================
// MODULE CREATE MODAL
// =============================================================================
function ModuleCreateModal({ onClose, onSaved }: { onClose: () => void; onSaved: () => void }) {
const [moduleCode, setModuleCode] = useState('')
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [regulationArea, setRegulationArea] = useState('dsgvo')
const [frequencyType, setFrequencyType] = useState('annual')
const [durationMinutes, setDurationMinutes] = useState(45)
const [passThreshold, setPassThreshold] = useState(70)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSave = async () => {
if (!moduleCode || !title) return
setSaving(true)
setError(null)
try {
await createModule({ module_code: moduleCode, title, description, regulation_area: regulationArea, frequency_type: frequencyType, duration_minutes: durationMinutes, pass_threshold: passThreshold })
onSaved()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Erstellen')
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Neues Trainingsmodul</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Modul-Code *</label>
<input type="text" value={moduleCode} onChange={e => setModuleCode(e.target.value.toUpperCase())} placeholder="DSGVO-BASICS" className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Regelungsbereich</label>
<select value={regulationArea} onChange={e => setRegulationArea(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
{Object.entries(REGULATION_LABELS).map(([k, l]) => <option key={k} value={k}>{l}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Titel *</label>
<input type="text" value={title} onChange={e => setTitle(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea value={description} onChange={e => setDescription(e.target.value)} rows={2} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Frequenz</label>
<select value={frequencyType} onChange={e => setFrequencyType(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
{Object.entries(FREQUENCY_LABELS).map(([k, l]) => <option key={k} value={k}>{l}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Dauer (Min.)</label>
<input type="number" min={1} value={durationMinutes} onChange={e => setDurationMinutes(Number(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Bestehensgrenze (%)</label>
<input type="number" min={0} max={100} value={passThreshold} onChange={e => setPassThreshold(Number(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
</div>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">Abbrechen</button>
<button onClick={handleSave} disabled={saving || !moduleCode || !title} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
{saving ? 'Erstelle...' : 'Modul erstellen'}
</button>
</div>
</div>
</div>
)
}
// =============================================================================
// MODULE EDIT DRAWER
// =============================================================================
function ModuleEditDrawer({ module, onClose, onSaved }: { module: TrainingModule; onClose: () => void; onSaved: () => void }) {
const [title, setTitle] = useState(module.title)
const [description, setDescription] = useState(module.description || '')
const [durationMinutes, setDurationMinutes] = useState(module.duration_minutes)
const [passThreshold, setPassThreshold] = useState(module.pass_threshold)
const [isActive, setIsActive] = useState(module.is_active)
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSave = async () => {
setSaving(true)
setError(null)
try {
await updateModule(module.id, { title, description, duration_minutes: durationMinutes, pass_threshold: passThreshold, is_active: isActive })
onSaved()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
} finally {
setSaving(false)
}
}
const handleDelete = async () => {
if (!window.confirm(`Modul "${module.title}" wirklich loeschen?`)) return
setDeleting(true)
try {
await deleteModule(module.id)
onSaved()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Loeschen')
setDeleting(false)
}
}
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-start justify-end">
<div className="h-full w-full max-w-lg bg-white shadow-xl flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<div>
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${REGULATION_COLORS[module.regulation_area]?.bg || 'bg-gray-100'} ${REGULATION_COLORS[module.regulation_area]?.text || 'text-gray-700'}`}>
{REGULATION_LABELS[module.regulation_area] || module.regulation_area}
</span>
{module.nis2_relevant && <span className="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-700 rounded">NIS2</span>}
</div>
<h2 className="text-lg font-semibold text-gray-900 mt-1">{module.module_code}</h2>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Titel</label>
<input type="text" value={title} onChange={e => setTitle(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Beschreibung</label>
<textarea value={description} onChange={e => setDescription(e.target.value)} rows={3} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Dauer (Minuten)</label>
<input type="number" min={1} value={durationMinutes} onChange={e => setDurationMinutes(Number(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Bestehensgrenze (%)</label>
<input type="number" min={0} max={100} value={passThreshold} onChange={e => setPassThreshold(Number(e.target.value))} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
</div>
</div>
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<span className="text-sm font-medium text-gray-700">Modul aktiv</span>
<button
onClick={() => setIsActive(!isActive)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isActive ? 'bg-blue-600' : 'bg-gray-200'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${isActive ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
<div className="p-6 border-t border-gray-200 space-y-3">
<button onClick={handleSave} disabled={saving} className="w-full px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
{saving ? 'Speichern...' : 'Aenderungen speichern'}
</button>
<button onClick={handleDelete} disabled={deleting} className="w-full px-4 py-2 text-sm bg-red-50 text-red-600 border border-red-200 rounded-lg hover:bg-red-100 disabled:opacity-50 transition-colors">
{deleting ? 'Loeschen...' : 'Modul loeschen'}
</button>
</div>
</div>
</div>
)
}
// =============================================================================
// MATRIX ADD MODAL
// =============================================================================
function MatrixAddModal({ roleCode, modules, onClose, onSaved }: {
roleCode: string
modules: TrainingModule[]
onClose: () => void
onSaved: () => void
}) {
const activeModules = modules.filter(m => m.is_active).sort((a, b) => a.module_code.localeCompare(b.module_code))
const [moduleId, setModuleId] = useState(activeModules[0]?.id || '')
const [isMandatory, setIsMandatory] = useState(true)
const [priority, setPriority] = useState(1)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSave = async () => {
if (!moduleId) return
setSaving(true)
setError(null)
try {
await setMatrixEntry({ role_code: roleCode, module_id: moduleId, is_mandatory: isMandatory, priority })
onSaved()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Hinzufuegen')
} finally {
setSaving(false)
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">Modul zu Rolle hinzufuegen</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-6 space-y-4">
<p className="text-sm text-gray-500">Rolle: <span className="font-medium text-gray-900">{roleCode}</span></p>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Modul</label>
<select value={moduleId} onChange={e => setModuleId(e.target.value)} className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
{activeModules.map(m => <option key={m.id} value={m.id}>{m.module_code} {m.title}</option>)}
</select>
</div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-gray-700">Pflichtmodul</label>
<button onClick={() => setIsMandatory(!isMandatory)} className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isMandatory ? 'bg-blue-600' : 'bg-gray-200'}`}>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${isMandatory ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Prioritaet</label>
<input type="number" min={1} value={priority} onChange={e => setPriority(Number(e.target.value))} className="w-24 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
<div className="flex items-center justify-end gap-3 p-6 border-t border-gray-200">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors">Abbrechen</button>
<button onClick={handleSave} disabled={saving || !moduleId} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
{saving ? 'Hinzufuegen...' : 'Hinzufuegen'}
</button>
</div>
</div>
</div>
)
}
// =============================================================================
// ASSIGNMENT DETAIL DRAWER
// =============================================================================
function AssignmentDetailDrawer({ assignment, onClose, onSaved }: {
assignment: TrainingAssignment
onClose: () => void
onSaved: () => void
}) {
const [deadline, setDeadline] = useState(assignment.deadline ? assignment.deadline.split('T')[0] : '')
const [savingDeadline, setSavingDeadline] = useState(false)
const [actionLoading, setActionLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleAction = async (action: () => Promise<unknown>) => {
setActionLoading(true)
setError(null)
try {
await action()
onSaved()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler')
setActionLoading(false)
}
}
const handleSaveDeadline = async () => {
setSavingDeadline(true)
setError(null)
try {
await updateAssignment(assignment.id, { deadline: new Date(deadline).toISOString() })
onSaved()
} catch (e) {
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
setSavingDeadline(false)
}
}
const statusActions: Record<string, { label: string; action: () => Promise<unknown> } | null> = {
pending: { label: 'Starten', action: () => startAssignment(assignment.id) },
in_progress: { label: 'Als abgeschlossen markieren', action: () => completeAssignment(assignment.id) },
overdue: { label: 'Als erledigt markieren', action: () => completeAssignment(assignment.id) },
completed: null,
expired: null,
}
const currentAction = statusActions[assignment.status] || null
return (
<div className="fixed inset-0 bg-black/50 z-50 flex items-start justify-end">
<div className="h-full w-full max-w-lg bg-white shadow-xl flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<div>
<h2 className="text-lg font-semibold text-gray-900">{assignment.module_title || assignment.module_code}</h2>
<p className="text-sm text-gray-500 mt-0.5">{assignment.module_code}</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-5">
{/* Employee */}
<div className="bg-gray-50 rounded-lg p-4">
<div className="font-medium text-gray-900">{assignment.user_name}</div>
<div className="text-sm text-gray-500">{assignment.user_email}</div>
{assignment.role_code && <div className="text-xs text-gray-400 mt-1">Rolle: {assignment.role_code}</div>}
</div>
{/* Timestamps */}
<div className="space-y-1 text-sm">
<div className="flex justify-between"><span className="text-gray-500">Erstellt:</span><span>{new Date(assignment.created_at).toLocaleString('de-DE')}</span></div>
{assignment.started_at && <div className="flex justify-between"><span className="text-gray-500">Gestartet:</span><span>{new Date(assignment.started_at).toLocaleString('de-DE')}</span></div>}
{assignment.completed_at && <div className="flex justify-between"><span className="text-gray-500">Abgeschlossen:</span><span className="text-green-600">{new Date(assignment.completed_at).toLocaleString('de-DE')}</span></div>}
</div>
{/* Progress */}
<div>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-500">Fortschritt</span>
<span className="font-medium">{assignment.progress_percent}%</span>
</div>
<div className="w-full h-3 bg-gray-200 rounded-full overflow-hidden">
<div className="bg-blue-500 h-full rounded-full transition-all" style={{ width: `${assignment.progress_percent}%` }} />
</div>
</div>
{/* Quiz Score */}
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Quiz-Score</span>
{assignment.quiz_score != null ? (
<span className={`px-2 py-1 rounded text-sm font-medium ${assignment.quiz_passed ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{assignment.quiz_score.toFixed(0)}% {assignment.quiz_passed ? '(Bestanden)' : '(Nicht bestanden)'}
</span>
) : (
<span className="px-2 py-1 rounded text-sm bg-gray-100 text-gray-500">Noch kein Quiz</span>
)}
</div>
{/* Escalation */}
{assignment.escalation_level > 0 && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Eskalationslevel</span>
<span className="px-2 py-1 rounded text-sm font-medium bg-orange-100 text-orange-700">
Level {assignment.escalation_level}
</span>
</div>
)}
{/* Certificate */}
{assignment.certificate_id && (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-500">Zertifikat</span>
<span className="text-sm text-blue-600">Zertifikat vorhanden</span>
</div>
)}
{/* Status Action */}
{currentAction && (
<button
onClick={() => handleAction(currentAction.action)}
disabled={actionLoading}
className="w-full px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{actionLoading ? 'Bitte warten...' : currentAction.label}
</button>
)}
{/* Deadline Edit */}
<div className="border-t border-gray-200 pt-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Deadline bearbeiten</label>
<div className="flex gap-2">
<input
type="date"
value={deadline}
onChange={e => setDeadline(e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<button
onClick={handleSaveDeadline}
disabled={savingDeadline || !deadline}
className="px-4 py-2 text-sm bg-gray-800 text-white rounded-lg hover:bg-gray-900 disabled:opacity-50 transition-colors"
>
{savingDeadline ? 'Speichern...' : 'Deadline speichern'}
</button>
</div>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
</div>
</div>
)
}

View File

@@ -112,7 +112,7 @@ async function fetchWithTimeout<T>(
* Alle Kurse abrufen
*/
export async function fetchCourses(): Promise<Course[]> {
const res = await fetchWithTimeout<{ courses: Course[]; total: number }>(
const res = await fetchWithTimeout<{ courses: BackendCourse[]; total: number }>(
`${ACADEMY_API_BASE}/courses`
)
return mapCoursesFromBackend(res.courses || [])
@@ -137,6 +137,8 @@ interface BackendCourse {
duration_minutes: number
required_for_roles: string[]
is_active: boolean
passing_score?: number
status?: string
lessons?: BackendLesson[]
created_at: string
updated_at: string
@@ -169,6 +171,9 @@ function mapCourseFromBackend(bc: BackendCourse): Course {
description: bc.description || '',
category: bc.category,
durationMinutes: bc.duration_minutes || 0,
passingScore: bc.passing_score ?? 70,
isActive: bc.is_active ?? true,
status: (bc.status as 'draft' | 'published') ?? 'draft',
requiredForRoles: bc.required_for_roles || [],
lessons: (bc.lessons || []).map(l => ({
id: l.id,
@@ -316,6 +321,39 @@ export async function generateCertificate(enrollmentId: string): Promise<Certifi
)
}
/**
* Alle Zertifikate abrufen
*/
export async function fetchCertificates(): Promise<Certificate[]> {
const res = await fetchWithTimeout<{ certificates: Certificate[]; total: number }>(
`${ACADEMY_API_BASE}/certificates`
)
return res.certificates || []
}
/**
* Einschreibung loeschen
*/
export async function deleteEnrollment(id: string): Promise<void> {
await fetchWithTimeout<void>(
`${ACADEMY_API_BASE}/enrollments/${id}`,
{ method: 'DELETE' }
)
}
/**
* Einschreibung aktualisieren (z.B. Deadline)
*/
export async function updateEnrollment(id: string, data: { deadline?: string }): Promise<Enrollment> {
return fetchWithTimeout<Enrollment>(
`${ACADEMY_API_BASE}/enrollments/${id}`,
{
method: 'PUT',
body: JSON.stringify(data),
}
)
}
// =============================================================================
// QUIZ
// =============================================================================
@@ -366,6 +404,8 @@ export async function fetchAcademyStatistics(): Promise<AcademyStatistics> {
completion_rate: number
overdue_count: number
avg_completion_days: number
by_category?: Record<string, number>
by_status?: Record<string, number>
}>(`${ACADEMY_API_BASE}/stats`)
return {
@@ -373,8 +413,8 @@ export async function fetchAcademyStatistics(): Promise<AcademyStatistics> {
totalEnrollments: res.total_enrollments || 0,
completionRate: res.completion_rate || 0,
overdueCount: res.overdue_count || 0,
byCategory: {} as Record<CourseCategory, number>,
byStatus: {} as Record<EnrollmentStatus, number>,
byCategory: (res.by_category || {}) as Record<CourseCategory, number>,
byStatus: (res.by_status || {}) as Record<EnrollmentStatus, number>,
}
}
@@ -484,6 +524,9 @@ export function createMockCourses(): Course[] {
description: 'Umfassende Einfuehrung in die Datenschutz-Grundverordnung. Dieses Pflichttraining vermittelt die wichtigsten Grundsaetze des Datenschutzes, Betroffenenrechte und die korrekte Handhabung personenbezogener Daten im Arbeitsalltag.',
category: 'dsgvo_basics',
durationMinutes: 90,
passingScore: 80,
isActive: true,
status: 'published',
requiredForRoles: ['all'],
createdAt: new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000).toISOString(),
@@ -543,6 +586,9 @@ export function createMockCourses(): Course[] {
description: 'Sensibilisierung fuer IT-Sicherheitsrisiken und Best Practices im Umgang mit Phishing, Passwoertern, Social Engineering und sicherer Kommunikation.',
category: 'it_security',
durationMinutes: 60,
passingScore: 75,
isActive: true,
status: 'published',
requiredForRoles: ['all'],
createdAt: new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString(),
@@ -592,6 +638,9 @@ export function createMockCourses(): Course[] {
description: 'Grundlagen kuenstlicher Intelligenz, EU AI Act, verantwortungsvoller Einsatz von KI-Werkzeugen und Risiken bei der Nutzung von Large Language Models (LLMs) im Unternehmen.',
category: 'ai_literacy',
durationMinutes: 75,
passingScore: 70,
isActive: true,
status: 'draft',
requiredForRoles: ['admin', 'data_protection_officer'],
createdAt: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000).toISOString(),

View File

@@ -117,6 +117,9 @@ export interface Course {
category: CourseCategory
lessons: Lesson[]
durationMinutes: number
passingScore: number
isActive: boolean
status: 'draft' | 'published'
requiredForRoles: string[]
createdAt: string
updatedAt: string
@@ -205,6 +208,7 @@ export interface CourseCreateRequest {
description: string
category: CourseCategory
durationMinutes: number
passingScore?: number
requiredForRoles?: string[]
}
@@ -213,6 +217,8 @@ export interface CourseUpdateRequest {
description?: string
category?: CourseCategory
durationMinutes?: number
passingScore?: number
status?: 'draft' | 'published'
requiredForRoles?: string[]
}

View File

@@ -89,6 +89,10 @@ export async function updateModule(id: string, data: Record<string, unknown>): P
})
}
export async function deleteModule(id: string): Promise<void> {
return apiFetch(`/modules/${id}`, { method: 'DELETE' })
}
// =============================================================================
// MATRIX
// =============================================================================
@@ -177,6 +181,13 @@ export async function completeAssignment(id: string): Promise<{ status: string }
return apiFetch(`/assignments/${id}/complete`, { method: 'POST' })
}
export async function updateAssignment(id: string, data: { deadline?: string }): Promise<TrainingAssignment> {
return apiFetch<TrainingAssignment>(`/assignments/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
// =============================================================================
// QUIZ
// =============================================================================