Connect the existing training engine handlers (40+ endpoints) to the router in main.go. This was the critical blocker preventing the training content pipeline from being accessible. Also adds generateCourse, generateVideos, and getVideoStatus functions to the academy API client, plus the GenerateCourseRequest type. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
624 lines
19 KiB
TypeScript
624 lines
19 KiB
TypeScript
/**
|
|
* Academy API Client
|
|
*
|
|
* API client for the Compliance E-Learning Academy module
|
|
* Connects to the ai-compliance-sdk backend via Next.js proxy
|
|
*/
|
|
|
|
import {
|
|
Course,
|
|
CourseCategory,
|
|
CourseCreateRequest,
|
|
CourseUpdateRequest,
|
|
GenerateCourseRequest,
|
|
Enrollment,
|
|
EnrollmentStatus,
|
|
EnrollmentListResponse,
|
|
EnrollUserRequest,
|
|
UpdateProgressRequest,
|
|
Certificate,
|
|
AcademyStatistics,
|
|
SubmitQuizRequest,
|
|
SubmitQuizResponse,
|
|
isEnrollmentOverdue
|
|
} from './types'
|
|
|
|
// =============================================================================
|
|
// CONFIGURATION
|
|
// =============================================================================
|
|
|
|
const ACADEMY_API_BASE = '/api/sdk/v1/academy'
|
|
const API_TIMEOUT = 30000 // 30 seconds
|
|
|
|
// =============================================================================
|
|
// HELPER FUNCTIONS
|
|
// =============================================================================
|
|
|
|
function getTenantId(): string {
|
|
if (typeof window !== 'undefined') {
|
|
return localStorage.getItem('bp_tenant_id') || 'default-tenant'
|
|
}
|
|
return 'default-tenant'
|
|
}
|
|
|
|
function getAuthHeaders(): HeadersInit {
|
|
const headers: HeadersInit = {
|
|
'Content-Type': 'application/json',
|
|
'X-Tenant-ID': getTenantId()
|
|
}
|
|
|
|
if (typeof window !== 'undefined') {
|
|
const token = localStorage.getItem('authToken')
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`
|
|
}
|
|
const userId = localStorage.getItem('bp_user_id')
|
|
if (userId) {
|
|
headers['X-User-ID'] = userId
|
|
}
|
|
}
|
|
|
|
return headers
|
|
}
|
|
|
|
async function fetchWithTimeout<T>(
|
|
url: string,
|
|
options: RequestInit = {},
|
|
timeout: number = API_TIMEOUT
|
|
): Promise<T> {
|
|
const controller = new AbortController()
|
|
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
...options,
|
|
signal: controller.signal,
|
|
headers: {
|
|
...getAuthHeaders(),
|
|
...options.headers
|
|
}
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorBody = await response.text()
|
|
let errorMessage = `HTTP ${response.status}: ${response.statusText}`
|
|
try {
|
|
const errorJson = JSON.parse(errorBody)
|
|
errorMessage = errorJson.error || errorJson.message || errorMessage
|
|
} catch {
|
|
// Keep the HTTP status message
|
|
}
|
|
throw new Error(errorMessage)
|
|
}
|
|
|
|
// Handle empty responses
|
|
const contentType = response.headers.get('content-type')
|
|
if (contentType && contentType.includes('application/json')) {
|
|
return response.json()
|
|
}
|
|
|
|
return {} as T
|
|
} finally {
|
|
clearTimeout(timeoutId)
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// COURSE CRUD
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Alle Kurse abrufen
|
|
*/
|
|
export async function fetchCourses(): Promise<Course[]> {
|
|
return fetchWithTimeout<Course[]>(
|
|
`${ACADEMY_API_BASE}/courses`
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Einzelnen Kurs abrufen
|
|
*/
|
|
export async function fetchCourse(id: string): Promise<Course> {
|
|
return fetchWithTimeout<Course>(
|
|
`${ACADEMY_API_BASE}/courses/${id}`
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Neuen Kurs erstellen
|
|
*/
|
|
export async function createCourse(request: CourseCreateRequest): Promise<Course> {
|
|
return fetchWithTimeout<Course>(
|
|
`${ACADEMY_API_BASE}/courses`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify(request)
|
|
}
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Kurs aktualisieren
|
|
*/
|
|
export async function updateCourse(id: string, update: CourseUpdateRequest): Promise<Course> {
|
|
return fetchWithTimeout<Course>(
|
|
`${ACADEMY_API_BASE}/courses/${id}`,
|
|
{
|
|
method: 'PUT',
|
|
body: JSON.stringify(update)
|
|
}
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Kurs loeschen
|
|
*/
|
|
export async function deleteCourse(id: string): Promise<void> {
|
|
await fetchWithTimeout<void>(
|
|
`${ACADEMY_API_BASE}/courses/${id}`,
|
|
{
|
|
method: 'DELETE'
|
|
}
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// ENROLLMENTS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Einschreibungen abrufen (optional gefiltert nach Kurs-ID)
|
|
*/
|
|
export async function fetchEnrollments(courseId?: string): Promise<Enrollment[]> {
|
|
const params = new URLSearchParams()
|
|
if (courseId) {
|
|
params.set('courseId', courseId)
|
|
}
|
|
const queryString = params.toString()
|
|
const url = `${ACADEMY_API_BASE}/enrollments${queryString ? `?${queryString}` : ''}`
|
|
|
|
return fetchWithTimeout<Enrollment[]>(url)
|
|
}
|
|
|
|
/**
|
|
* Benutzer in einen Kurs einschreiben
|
|
*/
|
|
export async function enrollUser(request: EnrollUserRequest): Promise<Enrollment> {
|
|
return fetchWithTimeout<Enrollment>(
|
|
`${ACADEMY_API_BASE}/enrollments`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify(request)
|
|
}
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Fortschritt einer Einschreibung aktualisieren
|
|
*/
|
|
export async function updateProgress(enrollmentId: string, update: UpdateProgressRequest): Promise<Enrollment> {
|
|
return fetchWithTimeout<Enrollment>(
|
|
`${ACADEMY_API_BASE}/enrollments/${enrollmentId}/progress`,
|
|
{
|
|
method: 'PUT',
|
|
body: JSON.stringify(update)
|
|
}
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Einschreibung als abgeschlossen markieren
|
|
*/
|
|
export async function completeEnrollment(enrollmentId: string): Promise<Enrollment> {
|
|
return fetchWithTimeout<Enrollment>(
|
|
`${ACADEMY_API_BASE}/enrollments/${enrollmentId}/complete`,
|
|
{
|
|
method: 'POST'
|
|
}
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// CERTIFICATES
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Zertifikat abrufen
|
|
*/
|
|
export async function fetchCertificate(id: string): Promise<Certificate> {
|
|
return fetchWithTimeout<Certificate>(
|
|
`${ACADEMY_API_BASE}/certificates/${id}`
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Zertifikat generieren nach erfolgreichem Kursabschluss
|
|
*/
|
|
export async function generateCertificate(enrollmentId: string): Promise<Certificate> {
|
|
return fetchWithTimeout<Certificate>(
|
|
`${ACADEMY_API_BASE}/enrollments/${enrollmentId}/certificate`,
|
|
{
|
|
method: 'POST'
|
|
}
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// QUIZ
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Quiz-Antworten einreichen und auswerten
|
|
*/
|
|
export async function submitQuiz(lessonId: string, answers: SubmitQuizRequest): Promise<SubmitQuizResponse> {
|
|
return fetchWithTimeout<SubmitQuizResponse>(
|
|
`${ACADEMY_API_BASE}/lessons/${lessonId}/quiz`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify(answers)
|
|
}
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// STATISTICS
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Academy-Statistiken abrufen
|
|
*/
|
|
export async function fetchAcademyStatistics(): Promise<AcademyStatistics> {
|
|
return fetchWithTimeout<AcademyStatistics>(
|
|
`${ACADEMY_API_BASE}/statistics`
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// COURSE GENERATION (via Training Engine)
|
|
// =============================================================================
|
|
|
|
/**
|
|
* KI-gestuetzten Kurs generieren (nutzt intern Training Content Pipeline)
|
|
*/
|
|
export async function generateCourse(request: GenerateCourseRequest): Promise<Course> {
|
|
return fetchWithTimeout<Course>(
|
|
`${ACADEMY_API_BASE}/courses/generate`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify(request)
|
|
},
|
|
120000 // 2 min timeout for LLM generation
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Videos fuer alle Lektionen eines Kurses generieren
|
|
*/
|
|
export async function generateVideos(courseId: string): Promise<{ status: string; jobId?: string }> {
|
|
return fetchWithTimeout<{ status: string; jobId?: string }>(
|
|
`${ACADEMY_API_BASE}/courses/${courseId}/generate-videos`,
|
|
{
|
|
method: 'POST'
|
|
},
|
|
300000 // 5 min timeout for video generation
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Video-Generierungsstatus abrufen
|
|
*/
|
|
export async function getVideoStatus(courseId: string): Promise<{
|
|
status: string
|
|
total: number
|
|
completed: number
|
|
failed: number
|
|
videos: Array<{ lessonId: string; status: string; url?: string }>
|
|
}> {
|
|
return fetchWithTimeout(
|
|
`${ACADEMY_API_BASE}/courses/${courseId}/video-status`
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// SDK PROXY FUNCTION (wraps fetchCourses + fetchAcademyStatistics)
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Kurse und Statistiken laden - mit Fallback auf Mock-Daten
|
|
*/
|
|
export async function fetchSDKAcademyList(): Promise<{
|
|
courses: Course[]
|
|
enrollments: Enrollment[]
|
|
statistics: AcademyStatistics
|
|
}> {
|
|
try {
|
|
const [courses, enrollments, statistics] = await Promise.all([
|
|
fetchCourses(),
|
|
fetchEnrollments(),
|
|
fetchAcademyStatistics()
|
|
])
|
|
|
|
return { courses, enrollments, statistics }
|
|
} catch (error) {
|
|
console.error('Failed to load Academy data from backend, using mock data:', error)
|
|
|
|
// Fallback to mock data
|
|
const courses = createMockCourses()
|
|
const enrollments = createMockEnrollments()
|
|
const statistics = createMockStatistics(courses, enrollments)
|
|
|
|
return { courses, enrollments, statistics }
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// MOCK DATA (Fallback / Demo)
|
|
// =============================================================================
|
|
|
|
/**
|
|
* Demo-Kurse mit deutschen Titeln erstellen
|
|
*/
|
|
export function createMockCourses(): Course[] {
|
|
const now = new Date()
|
|
|
|
return [
|
|
{
|
|
id: 'course-001',
|
|
title: 'DSGVO-Grundlagen fuer Mitarbeiter',
|
|
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,
|
|
requiredForRoles: ['all'],
|
|
createdAt: new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000).toISOString(),
|
|
updatedAt: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000).toISOString(),
|
|
lessons: [
|
|
{
|
|
id: 'lesson-001-01',
|
|
courseId: 'course-001',
|
|
order: 1,
|
|
title: 'Was ist die DSGVO?',
|
|
type: 'text',
|
|
contentMarkdown: '# Was ist die DSGVO?\n\nDie Datenschutz-Grundverordnung (DSGVO) ist eine Verordnung der Europaeischen Union...',
|
|
durationMinutes: 15
|
|
},
|
|
{
|
|
id: 'lesson-001-02',
|
|
courseId: 'course-001',
|
|
order: 2,
|
|
title: 'Die 7 Grundsaetze der DSGVO',
|
|
type: 'video',
|
|
contentMarkdown: 'Videoerklaerung der Grundsaetze: Rechtmaessigkeit, Zweckbindung, Datenminimierung, Richtigkeit, Speicherbegrenzung, Integritaet und Vertraulichkeit, Rechenschaftspflicht.',
|
|
durationMinutes: 20,
|
|
videoUrl: '/videos/dsgvo-grundsaetze.mp4'
|
|
},
|
|
{
|
|
id: 'lesson-001-03',
|
|
courseId: 'course-001',
|
|
order: 3,
|
|
title: 'Betroffenenrechte (Art. 15-21)',
|
|
type: 'text',
|
|
contentMarkdown: '# Betroffenenrechte\n\nUebersicht der Betroffenenrechte: Auskunft, Berichtigung, Loeschung, Einschraenkung, Datenuebertragbarkeit, Widerspruch.',
|
|
durationMinutes: 20
|
|
},
|
|
{
|
|
id: 'lesson-001-04',
|
|
courseId: 'course-001',
|
|
order: 4,
|
|
title: 'Personenbezogene Daten im Arbeitsalltag',
|
|
type: 'video',
|
|
contentMarkdown: 'Praxisbeispiele fuer den korrekten Umgang mit personenbezogenen Daten am Arbeitsplatz.',
|
|
durationMinutes: 15,
|
|
videoUrl: '/videos/dsgvo-praxis.mp4'
|
|
},
|
|
{
|
|
id: 'lesson-001-05',
|
|
courseId: 'course-001',
|
|
order: 5,
|
|
title: 'Wissenstest: DSGVO-Grundlagen',
|
|
type: 'quiz',
|
|
contentMarkdown: 'Testen Sie Ihr Wissen zu den DSGVO-Grundlagen.',
|
|
durationMinutes: 20
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'course-002',
|
|
title: 'IT-Sicherheit & Cybersecurity Awareness',
|
|
description: 'Sensibilisierung fuer IT-Sicherheitsrisiken und Best Practices im Umgang mit Phishing, Passwoertern, Social Engineering und sicherer Kommunikation.',
|
|
category: 'it_security',
|
|
durationMinutes: 60,
|
|
requiredForRoles: ['all'],
|
|
createdAt: new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000).toISOString(),
|
|
updatedAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString(),
|
|
lessons: [
|
|
{
|
|
id: 'lesson-002-01',
|
|
courseId: 'course-002',
|
|
order: 1,
|
|
title: 'Phishing erkennen und vermeiden',
|
|
type: 'video',
|
|
contentMarkdown: 'Wie erkennt man Phishing-E-Mails und was tut man im Verdachtsfall?',
|
|
durationMinutes: 15,
|
|
videoUrl: '/videos/phishing-awareness.mp4'
|
|
},
|
|
{
|
|
id: 'lesson-002-02',
|
|
courseId: 'course-002',
|
|
order: 2,
|
|
title: 'Sichere Passwoerter und MFA',
|
|
type: 'text',
|
|
contentMarkdown: '# Sichere Passwoerter\n\nRichtlinien fuer starke Passwoerter, Passwort-Manager und Multi-Faktor-Authentifizierung.',
|
|
durationMinutes: 15
|
|
},
|
|
{
|
|
id: 'lesson-002-03',
|
|
courseId: 'course-002',
|
|
order: 3,
|
|
title: 'Social Engineering und Manipulation',
|
|
type: 'text',
|
|
contentMarkdown: '# Social Engineering\n\nMethoden von Angreifern zur Manipulation von Mitarbeitern und Schutzmassnahmen.',
|
|
durationMinutes: 15
|
|
},
|
|
{
|
|
id: 'lesson-002-04',
|
|
courseId: 'course-002',
|
|
order: 4,
|
|
title: 'Wissenstest: IT-Sicherheit',
|
|
type: 'quiz',
|
|
contentMarkdown: 'Testen Sie Ihr Wissen zur IT-Sicherheit.',
|
|
durationMinutes: 15
|
|
}
|
|
]
|
|
},
|
|
{
|
|
id: 'course-003',
|
|
title: 'AI Literacy - Sicherer Umgang mit KI',
|
|
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,
|
|
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(),
|
|
lessons: [
|
|
{
|
|
id: 'lesson-003-01',
|
|
courseId: 'course-003',
|
|
order: 1,
|
|
title: 'Was ist Kuenstliche Intelligenz?',
|
|
type: 'text',
|
|
contentMarkdown: '# Was ist KI?\n\nGrundlagen von Machine Learning, Deep Learning und Large Language Models in verstaendlicher Sprache.',
|
|
durationMinutes: 15
|
|
},
|
|
{
|
|
id: 'lesson-003-02',
|
|
courseId: 'course-003',
|
|
order: 2,
|
|
title: 'Der EU AI Act - Was bedeutet er fuer uns?',
|
|
type: 'video',
|
|
contentMarkdown: 'Ueberblick ueber den EU AI Act, Risikoklassen und Anforderungen fuer Unternehmen.',
|
|
durationMinutes: 20,
|
|
videoUrl: '/videos/eu-ai-act.mp4'
|
|
},
|
|
{
|
|
id: 'lesson-003-03',
|
|
courseId: 'course-003',
|
|
order: 3,
|
|
title: 'KI-Werkzeuge sicher nutzen',
|
|
type: 'text',
|
|
contentMarkdown: '# KI-Werkzeuge sicher nutzen\n\nRichtlinien fuer den Einsatz von ChatGPT, Copilot & Co.',
|
|
durationMinutes: 20
|
|
},
|
|
{
|
|
id: 'lesson-003-04',
|
|
courseId: 'course-003',
|
|
order: 4,
|
|
title: 'Wissenstest: AI Literacy',
|
|
type: 'quiz',
|
|
contentMarkdown: 'Testen Sie Ihr Wissen zum sicheren Umgang mit KI.',
|
|
durationMinutes: 20
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
/**
|
|
* Demo-Einschreibungen erstellen
|
|
*/
|
|
export function createMockEnrollments(): Enrollment[] {
|
|
const now = new Date()
|
|
|
|
return [
|
|
{
|
|
id: 'enr-001',
|
|
courseId: 'course-001',
|
|
userId: 'user-001',
|
|
userName: 'Maria Fischer',
|
|
userEmail: 'maria.fischer@example.de',
|
|
status: 'in_progress',
|
|
progress: 40,
|
|
startedAt: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000).toISOString(),
|
|
deadline: new Date(now.getTime() + 20 * 24 * 60 * 60 * 1000).toISOString()
|
|
},
|
|
{
|
|
id: 'enr-002',
|
|
courseId: 'course-002',
|
|
userId: 'user-002',
|
|
userName: 'Stefan Mueller',
|
|
userEmail: 'stefan.mueller@example.de',
|
|
status: 'completed',
|
|
progress: 100,
|
|
startedAt: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000).toISOString(),
|
|
completedAt: new Date(now.getTime() - 12 * 24 * 60 * 60 * 1000).toISOString(),
|
|
certificateId: 'cert-001',
|
|
deadline: new Date(now.getTime() + 10 * 24 * 60 * 60 * 1000).toISOString()
|
|
},
|
|
{
|
|
id: 'enr-003',
|
|
courseId: 'course-001',
|
|
userId: 'user-003',
|
|
userName: 'Laura Schneider',
|
|
userEmail: 'laura.schneider@example.de',
|
|
status: 'not_started',
|
|
progress: 0,
|
|
startedAt: new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
|
deadline: new Date(now.getTime() + 28 * 24 * 60 * 60 * 1000).toISOString()
|
|
},
|
|
{
|
|
id: 'enr-004',
|
|
courseId: 'course-003',
|
|
userId: 'user-004',
|
|
userName: 'Thomas Wagner',
|
|
userEmail: 'thomas.wagner@example.de',
|
|
status: 'expired',
|
|
progress: 25,
|
|
startedAt: new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000).toISOString(),
|
|
deadline: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000).toISOString()
|
|
},
|
|
{
|
|
id: 'enr-005',
|
|
courseId: 'course-002',
|
|
userId: 'user-005',
|
|
userName: 'Julia Becker',
|
|
userEmail: 'julia.becker@example.de',
|
|
status: 'in_progress',
|
|
progress: 50,
|
|
startedAt: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
deadline: new Date(now.getTime() + 5 * 24 * 60 * 60 * 1000).toISOString()
|
|
}
|
|
]
|
|
}
|
|
|
|
/**
|
|
* Demo-Statistiken aus Kursen und Einschreibungen berechnen
|
|
*/
|
|
export function createMockStatistics(courses?: Course[], enrollments?: Enrollment[]): AcademyStatistics {
|
|
const c = courses || createMockCourses()
|
|
const e = enrollments || createMockEnrollments()
|
|
|
|
const completedCount = e.filter(en => en.status === 'completed').length
|
|
const completionRate = e.length > 0 ? Math.round((completedCount / e.length) * 100) : 0
|
|
const overdueCount = e.filter(en => isEnrollmentOverdue(en)).length
|
|
|
|
return {
|
|
totalCourses: c.length,
|
|
totalEnrollments: e.length,
|
|
completionRate,
|
|
overdueCount,
|
|
byCategory: {
|
|
dsgvo_basics: c.filter(co => co.category === 'dsgvo_basics').length,
|
|
it_security: c.filter(co => co.category === 'it_security').length,
|
|
ai_literacy: c.filter(co => co.category === 'ai_literacy').length,
|
|
whistleblower_protection: c.filter(co => co.category === 'whistleblower_protection').length,
|
|
custom: c.filter(co => co.category === 'custom').length,
|
|
},
|
|
byStatus: {
|
|
not_started: e.filter(en => en.status === 'not_started').length,
|
|
in_progress: e.filter(en => en.status === 'in_progress').length,
|
|
completed: e.filter(en => en.status === 'completed').length,
|
|
expired: e.filter(en => en.status === 'expired').length,
|
|
}
|
|
}
|
|
}
|