diff --git a/apps/dashboard/src/lib/components/Course/components/Analytics/AnalyticsCard.svelte b/apps/dashboard/src/lib/components/Course/components/Analytics/AnalyticsCard.svelte new file mode 100644 index 00000000..fe5c93a7 --- /dev/null +++ b/apps/dashboard/src/lib/components/Course/components/Analytics/AnalyticsCard.svelte @@ -0,0 +1,38 @@ + + +
+
+
+ +
+
+

{title}

+

+ {value} +

+ {#if subtitle} +

+ {subtitle} +

+ {/if} +
+
+
diff --git a/apps/dashboard/src/lib/components/Course/components/Analytics/AnalyticsGraph.svelte b/apps/dashboard/src/lib/components/Course/components/Analytics/AnalyticsGraph.svelte new file mode 100644 index 00000000..baa3ba95 --- /dev/null +++ b/apps/dashboard/src/lib/components/Course/components/Analytics/AnalyticsGraph.svelte @@ -0,0 +1,175 @@ + + +{#if browser && BarChartSimple} +
+ +
+

+ Student Progress Distribution +

+ {#if courseAnalytics?.students && courseAnalytics.students.length > 0} + + {:else} +
+ No progress data available +
+ {/if} +
+ + +
+

Grade Distribution

+ {#if courseAnalytics?.students && courseAnalytics.students.length > 0} + + {:else} +
+ No grade data available +
+ {/if} +
+
+{:else} + +
+
+

+ Student Progress Distribution +

+
+
Loading chart...
+
+
+
+

Grade Distribution

+
+
Loading chart...
+
+
+
+{/if} diff --git a/apps/dashboard/src/lib/components/Course/components/Analytics/AnalyticsSkeleton.svelte b/apps/dashboard/src/lib/components/Course/components/Analytics/AnalyticsSkeleton.svelte new file mode 100644 index 00000000..8dada9e4 --- /dev/null +++ b/apps/dashboard/src/lib/components/Course/components/Analytics/AnalyticsSkeleton.svelte @@ -0,0 +1,67 @@ + + +
+
+ +
+
+ {#each Array(4) as _} +
+
+ +
+ + + +
+
+
+ {/each} +
+
+ + +
+
+ +
+ + +
+
+
+ + +
+
+ +
+
+
+ {#each Array(5) as _} +
+ +
+ + +
+ + + + + +
+ {/each} +
+
+
+
+
diff --git a/apps/dashboard/src/lib/components/Course/components/Analytics/EmptyState.svelte b/apps/dashboard/src/lib/components/Course/components/Analytics/EmptyState.svelte new file mode 100644 index 00000000..8ea7e9a8 --- /dev/null +++ b/apps/dashboard/src/lib/components/Course/components/Analytics/EmptyState.svelte @@ -0,0 +1,22 @@ + + +
+
+ +
+

+ {title} +

+

+ {description} +

+
diff --git a/apps/dashboard/src/lib/components/Course/components/Analytics/StudentTable.svelte b/apps/dashboard/src/lib/components/Course/components/Analytics/StudentTable.svelte new file mode 100644 index 00000000..5cdc5415 --- /dev/null +++ b/apps/dashboard/src/lib/components/Course/components/Analytics/StudentTable.svelte @@ -0,0 +1,183 @@ + + +{#if students.length === 0} + +{:else} + +
+
+ + + + + + + + + + + + + + {#each paginatedStudents as student} + + + + + + + + + + + {/each} + +
+ {$t('analytics.student_name')} + + {$t('analytics.lessons_completed')} + + {$t('analytics.exercises_submitted')} + + {$t('analytics.average_grade')} + + {$t('analytics.last_seen')} + + {$t('analytics.actions')} +
+
+ +
+

+ {student.profile.fullname} +

+

+ {student.profile.email} +

+
+
+
+
+ + {student.lessonsCompleted}/{student.totalLessons} + +
+
+
+
+
+
+
+ + {student.exercisesSubmitted} out of {student.totalExercises} + + + = 80 + ? 'green' + : student.averageGrade >= 60 + ? 'blue' + : 'red'} + size="sm" + > + {student.averageGrade}% + + + + {student.lastSeen} + + + +
+
+
+ + + {#if students.length > pageSize} +
+
+ {$t('analytics.showing_students', { + start: startItem, + end: endItem, + total: students.length + })} +
+ +
+ {/if} +{/if} diff --git a/apps/dashboard/src/lib/components/Course/components/Analytics/icons/ExercisesIcon.svelte b/apps/dashboard/src/lib/components/Course/components/Analytics/icons/ExercisesIcon.svelte new file mode 100644 index 00000000..c0585126 --- /dev/null +++ b/apps/dashboard/src/lib/components/Course/components/Analytics/icons/ExercisesIcon.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/apps/dashboard/src/lib/components/Course/components/Analytics/icons/LessonsIcon.svelte b/apps/dashboard/src/lib/components/Course/components/Analytics/icons/LessonsIcon.svelte new file mode 100644 index 00000000..0ec031b5 --- /dev/null +++ b/apps/dashboard/src/lib/components/Course/components/Analytics/icons/LessonsIcon.svelte @@ -0,0 +1,19 @@ + + + + + + diff --git a/apps/dashboard/src/lib/components/Course/components/Analytics/icons/ProgressIcon.svelte b/apps/dashboard/src/lib/components/Course/components/Analytics/icons/ProgressIcon.svelte new file mode 100644 index 00000000..b1c5607d --- /dev/null +++ b/apps/dashboard/src/lib/components/Course/components/Analytics/icons/ProgressIcon.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/apps/dashboard/src/lib/components/Course/components/Analytics/icons/StudentsIcon.svelte b/apps/dashboard/src/lib/components/Course/components/Analytics/icons/StudentsIcon.svelte new file mode 100644 index 00000000..52aa4d57 --- /dev/null +++ b/apps/dashboard/src/lib/components/Course/components/Analytics/icons/StudentsIcon.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/apps/dashboard/src/lib/components/Course/components/Navigation/NavIcons.svelte b/apps/dashboard/src/lib/components/Course/components/Navigation/NavIcons.svelte index 34e06f0e..e0bfe73e 100644 --- a/apps/dashboard/src/lib/components/Course/components/Navigation/NavIcons.svelte +++ b/apps/dashboard/src/lib/components/Course/components/Navigation/NavIcons.svelte @@ -9,6 +9,7 @@ import IbmWatsonKnowledgeCatalog from 'carbon-icons-svelte/lib/IbmWatsonKnowledgeCatalog.svelte'; import Settings from 'carbon-icons-svelte/lib/Settings.svelte'; import Bullhorn from 'carbon-icons-svelte/lib/Bullhorn.svelte'; + import Analytics from 'carbon-icons-svelte/lib/Analytics.svelte'; import { NAV_IDS } from './constants'; export let name = ''; @@ -28,6 +29,8 @@ {:else if name === NAV_IDS.PEOPLE} +{:else if name === NAV_IDS.ANALYTICS} + {:else if name === NAV_IDS.LANDING_PAGE} {:else if name === NAV_IDS.CERTIFICATES} diff --git a/apps/dashboard/src/lib/components/Course/components/Navigation/constants.ts b/apps/dashboard/src/lib/components/Course/components/Navigation/constants.ts index 1063c74c..b82a5faf 100644 --- a/apps/dashboard/src/lib/components/Course/components/Navigation/constants.ts +++ b/apps/dashboard/src/lib/components/Course/components/Navigation/constants.ts @@ -7,6 +7,7 @@ export const NAV_IDS = { CERTIFICATES: 'CERTIFICATES', LANDING_PAGE: 'LANDING_PAGE', PEOPLE: 'PEOPLE', + ANALYTICS: 'ANALYTICS', SETTINGS: 'SETTINGS', SECTION: 'SECTION' }; diff --git a/apps/dashboard/src/lib/components/Course/components/Navigation/index.svelte b/apps/dashboard/src/lib/components/Course/components/Navigation/index.svelte index 82a2bf84..7a3bb526 100644 --- a/apps/dashboard/src/lib/components/Course/components/Navigation/index.svelte +++ b/apps/dashboard/src/lib/components/Course/components/Navigation/index.svelte @@ -158,6 +158,16 @@ return isStudent ? $currentOrg.customization.course.newsfeed : true; } }, + { + id: NAV_IDS.ANALYTICS, + label: $t('course.navItems.nav_analytics'), + to: getNavItemRoute($course.id, 'analytics'), + isPaidFeature: false, + hideSortIcon: true, + show() { + return !isStudent; + } + }, { id: NAV_IDS.LESSONS, label: $t('course.navItems.nav_content'), @@ -262,14 +272,14 @@ class={` ${ $sideBar.hidden - ? '-translate-x-[100%] absolute z-[40]' - : 'translate-x-0 absolute md:relative z-[40]' + ? 'absolute z-[40] -translate-x-[100%]' + : 'absolute z-[40] translate-x-0 md:relative' } - transition w-[90vw] md:w-[300px] lg:w-[350px] bg-gray-100 dark:bg-black h-[calc(100vh-48px)] + h-[calc(100vh-48px)] w-[90vw] bg-gray-100 transition md:w-[300px] lg:w-[350px] dark:bg-black ${ resize ? 'border-r-8 border-r-blue-500' : 'dark:border-r-neutral-600' - } overflow-y-auto border border-l-0 border-t-0 border-b-0 border-r-1`} + } border-r-1 overflow-y-auto border border-b-0 border-l-0 border-t-0`} style={$sideBar.hidden === true ? 'width:0' : 'width:300px'} bind:this={sidebarRef} > @@ -297,7 +307,7 @@ {#if $course.version === COURSE_VERSION.V1} {#each $lessons as item, index}
@@ -319,7 +329,7 @@ size="sm" shape="rounded-full" /> - {item.title} + {item.title} {#if !item.is_unlocked} @@ -348,7 +358,7 @@ > {#each section.lessons as item}
- {item.title} + {item.title} {#if !item.is_unlocked} diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json index be506c9b..3298271e 100644 --- a/apps/dashboard/src/lib/utils/translations/de.json +++ b/apps/dashboard/src/lib/utils/translations/de.json @@ -861,6 +861,9 @@ "cancel": "Stornieren", "post": "Post" } + }, + "analytics": { + "title": "Analyse" } }, "navItems": { @@ -872,7 +875,8 @@ "nav_certificates": "Zertifikate", "nav_landing_page": "Landingpage", "nav_settings": "Einstellungen", - "nav_content": "Inhalt" + "nav_content": "Inhalt", + "nav_analytics": "Analyse" }, "not_permitted": { "header": "Keine Erlaubnis", @@ -1552,11 +1556,27 @@ "assignment_completion": "Abschluss der Aufgabe", "assignment_completion_description": "Prozentsatz der abgeschlossenen Aufgaben", "average_grade": "Durchschnittsnote", - "average_grade_description": "Durchschnittsnote aller abgeschlossenen Aufgaben" + "average_grade_description": "Durchschnittsnote aller abgeschlossenen Aufgaben", + "students": "Studenten", + "lessons": "Unterricht", + "tutors": "Tutoren", + "avg_grade": "AVG -Note", + "completion_rate": "Abschlussrate", + "student_progress_overview": "Übersicht über den Fortschritt der Schüler", + "student_name": "Schülername", + "lessons_completed": "Lektionen abgeschlossen", + "exercises_submitted": "Übungen eingereicht", + "actions": "Aktionen", + "view_details": "Details anzeigen", + "no_students_found": "Keine Schüler gefunden", + "no_students_description": "In diesem Kurs sind noch keine Studenten eingeschrieben. Die Schüler werden hier erscheinen, sobald sie sich dem Kurs anschließen.", + "showing_students": "Zeigen {Start} bis {End} von {Total} -Studenten", + "no_analytics_data": "Keine Analysedaten", + "no_analytics_description": "Analysedaten für diesen Kurs können nicht geladen werden. Bitte versuchen Sie es später erneut oder wenden Sie sich an Support, wenn das Problem bestehen bleibt." }, "common": { "unsaved_changes": { "message": "Sie haben nicht gerettete Änderungen. Bist du sicher, dass du gehen willst?" } } -} +} \ No newline at end of file diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index 262d7070..e7ae78eb 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -535,6 +535,7 @@ "nav_submissions": "Submissions", "nav_marks": "Marks", "nav_people": "People", + "nav_analytics": "Analytics", "nav_certificates": "Certificates", "nav_landing_page": "Landing Page", "nav_settings": "Settings" @@ -1147,6 +1148,10 @@ "courses": "courses" }, + "analytics": { + "title": "Analytics" + }, + "settings": { "heading": "Settings", "cover_image": "Cover Image", @@ -1257,8 +1262,24 @@ "overall_course_progress_user_description": "Based on lesson completion", "assignment_completion": "Assignment Completion", "assignment_completion_description": "Percentage of completed assignments", + "average_grade_description": "Average grade of all completed assignments", + "students": "Students", + "lessons": "Lessons", + "tutors": "tutors", + "avg_grade": "Avg grade", + "completion_rate": "Completion rate", + "student_progress_overview": "Student Progress Overview", + "student_name": "Student Name", + "lessons_completed": "Lessons Completed", + "exercises_submitted": "Exercises Submitted", "average_grade": "Average Grade", - "average_grade_description": "Average grade of all completed assignments" + "actions": "Actions", + "view_details": "View Details", + "no_students_found": "No Students Found", + "no_students_description": "There are no students enrolled in this course yet. Students will appear here once they join the course.", + "showing_students": "Showing {start} to {end} of {total} students", + "no_analytics_data": "No Analytics Data", + "no_analytics_description": "Unable to load analytics data for this course. Please try again later or contact support if the issue persists." }, "upgrade": { diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json index 50ced1f0..ed788d2a 100644 --- a/apps/dashboard/src/lib/utils/translations/es.json +++ b/apps/dashboard/src/lib/utils/translations/es.json @@ -861,6 +861,9 @@ "cancel": "Cancelar", "post": "Correo" } + }, + "analytics": { + "title": "Analítica" } }, "navItems": { @@ -872,7 +875,8 @@ "nav_certificates": "Certificados", "nav_landing_page": "Página de destino", "nav_settings": "Ajustes", - "nav_content": "Contenido" + "nav_content": "Contenido", + "nav_analytics": "Analítica" }, "not_permitted": { "header": "Sin autorización", @@ -1552,11 +1556,27 @@ "assignment_completion": "Finalización de la tarea", "assignment_completion_description": "Porcentaje de tareas completadas", "average_grade": "Calificación promedio", - "average_grade_description": "Calificación promedio de todas las tareas completadas" + "average_grade_description": "Calificación promedio de todas las tareas completadas", + "students": "Estudiantes", + "lessons": "Lecciones", + "tutors": "tutores", + "avg_grade": "AVG Grado", + "completion_rate": "Tasa de finalización", + "student_progress_overview": "Descripción general del progreso del estudiante", + "student_name": "Nombre de estudiante", + "lessons_completed": "Lecciones completadas", + "exercises_submitted": "Ejercicios presentados", + "actions": "Comportamiento", + "view_details": "Ver detalles", + "no_students_found": "No se encontraron estudiantes", + "no_students_description": "Todavía no hay estudiantes matriculados en este curso. Los estudiantes aparecerán aquí una vez que se unan al curso.", + "showing_students": "Mostrando {inicio} a {final} de {Total} Estudiantes", + "no_analytics_data": "Sin datos de análisis", + "no_analytics_description": "No se puede cargar datos de análisis para este curso. Vuelva a intentarlo más tarde o comuníquese con el soporte si el problema persiste." }, "common": { "unsaved_changes": { "message": "Tienes cambios no salvos. ¿Estás seguro de que quieres irte?" } } -} +} \ No newline at end of file diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json index 9565a1a5..77766598 100644 --- a/apps/dashboard/src/lib/utils/translations/fr.json +++ b/apps/dashboard/src/lib/utils/translations/fr.json @@ -861,6 +861,9 @@ "cancel": "Annuler", "post": "Poste" } + }, + "analytics": { + "title": "Analytique" } }, "navItems": { @@ -872,7 +875,8 @@ "nav_certificates": "Certificats", "nav_landing_page": "Page de destination", "nav_settings": "Paramètres", - "nav_content": "Contenu" + "nav_content": "Contenu", + "nav_analytics": "Analytique" }, "not_permitted": { "header": "Aucune autorisation", @@ -1552,7 +1556,23 @@ "assignment_completion": "Achèvement de la mission", "assignment_completion_description": "Pourcentage de missions terminées", "average_grade": "Note moyenne", - "average_grade_description": "Note moyenne de tous les devoirs terminés" + "average_grade_description": "Note moyenne de tous les devoirs terminés", + "students": "Étudiants", + "lessons": "Leçons", + "tutors": "tuteurs", + "avg_grade": "Grade AVG", + "completion_rate": "Taux d'achèvement", + "student_progress_overview": "Aperçu des progrès des étudiants", + "student_name": "Nom de l'étudiant", + "lessons_completed": "Leçons terminées", + "exercises_submitted": "Exercices soumis", + "actions": "Actes", + "view_details": "Afficher les détails", + "no_students_found": "Aucun étudiant trouvé", + "no_students_description": "Il n'y a pas encore d'étudiants inscrits à ce cours. Les étudiants apparaîtront ici une fois qu'ils auront participé au cours.", + "showing_students": "Affichage {démarrer} à {fin} des étudiants {totaux}", + "no_analytics_data": "Aucune donnée d'analyse", + "no_analytics_description": "Impossible de charger des données d'analyse pour ce cours. Veuillez réessayer plus tard ou contacter le support si le problème persiste." }, "common": { "unsaved_changes": { diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json index 30039776..eff32c8c 100644 --- a/apps/dashboard/src/lib/utils/translations/hi.json +++ b/apps/dashboard/src/lib/utils/translations/hi.json @@ -861,6 +861,9 @@ "cancel": "रद्द करना", "post": "डाक" } + }, + "analytics": { + "title": "एनालिटिक्स" } }, "navItems": { @@ -872,7 +875,8 @@ "nav_certificates": "प्रमाण पत्र", "nav_landing_page": "लैंडिंग पृष्ठ", "nav_settings": "समायोजन", - "nav_content": "सामग्री" + "nav_content": "सामग्री", + "nav_analytics": "एनालिटिक्स" }, "not_permitted": { "header": "अनुमति नहीं", @@ -1552,11 +1556,27 @@ "assignment_completion": "असाइनमेंट पूरा करना", "assignment_completion_description": "पूर्ण किये गये कार्यों का प्रतिशत", "average_grade": "औसत क्षेणी", - "average_grade_description": "सभी पूर्ण किए गए असाइनमेंट का औसत ग्रेड" + "average_grade_description": "सभी पूर्ण किए गए असाइनमेंट का औसत ग्रेड", + "students": "छात्र", + "lessons": "पाठ", + "tutors": "ट्यूटर्स", + "avg_grade": "AVG ग्रेड", + "completion_rate": "पूर्णता दर", + "student_progress_overview": "छात्र प्रगति अवलोकन", + "student_name": "छात्र नाम", + "lessons_completed": "सबक पूरा हुआ", + "exercises_submitted": "प्रस्तुत अभ्यास", + "actions": "कार्रवाई", + "view_details": "विवरण देखें", + "no_students_found": "कोई छात्र नहीं मिला", + "no_students_description": "इस पाठ्यक्रम में अभी तक कोई छात्र नामांकित नहीं हैं। पाठ्यक्रम में शामिल होने के बाद छात्र यहां दिखाई देंगे।", + "showing_students": "{TOTAL} {TOTAL} छात्रों के {अंत} को दिखा रहा है", + "no_analytics_data": "कोई एनालिटिक्स डेटा नहीं", + "no_analytics_description": "इस पाठ्यक्रम के लिए एनालिटिक्स डेटा लोड करने में असमर्थ। यदि समस्या बनी रहती है, तो कृपया बाद में पुनः प्रयास करें या समर्थन से संपर्क करें।" }, "common": { "unsaved_changes": { "message": "आपके पास अनसुना परिवर्तन हैं। क्या आपकी वाकई छोड़ने की इच्छा है?" } } -} +} \ No newline at end of file diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json index d30dbbc3..584774e5 100644 --- a/apps/dashboard/src/lib/utils/translations/pl.json +++ b/apps/dashboard/src/lib/utils/translations/pl.json @@ -331,7 +331,23 @@ "assignment_completion": "Zakończenie przypisania", "assignment_completion_description": "Procent wypełnionych zadań", "average_grade": "Średnia ocena", - "average_grade_description": "Średnia ocena wszystkich zakończonych zadań" + "average_grade_description": "Średnia ocena wszystkich zakończonych zadań", + "students": "Studenci", + "lessons": "Lekcje", + "tutors": "Tutorzy", + "avg_grade": "Klasa AVG", + "completion_rate": "Wskaźnik zakończenia", + "student_progress_overview": "Przegląd postępu uczniów", + "student_name": "Nazwa ucznia", + "lessons_completed": "Ukończone lekcje", + "exercises_submitted": "Przedstawione ćwiczenia", + "actions": "Działania", + "view_details": "Zobacz szczegóły", + "no_students_found": "Nie znaleziono uczniów", + "no_students_description": "Na ten kurs nie zapisano jeszcze studentów. Studenci pojawią się tutaj, gdy dołączą do kursu.", + "showing_students": "Pokazuje {start} do {end} studentów {total}", + "no_analytics_data": "Brak danych analitycznych", + "no_analytics_description": "Nie można załadować danych analitycznych dla tego kursu. Spróbuj ponownie później lub skontaktuj się z wsparciem, jeśli problem będzie się utrzymywał." }, "audience": { "title": "Publiczność", @@ -1000,6 +1016,9 @@ "cancel": "Anulować", "post": "Post" } + }, + "analytics": { + "title": "Analityka" } }, "navItems": { @@ -1011,7 +1030,8 @@ "nav_people": "Ludzie", "nav_certificates": "Certyfikaty", "nav_landing_page": "Strona docelowa", - "nav_settings": "Ustawienia" + "nav_settings": "Ustawienia", + "nav_analytics": "Analityka" } }, "courses": { @@ -1523,5 +1543,10 @@ "resend_in": "Ponownie weryfikacja w", "seconds": "towary drugiej jakości", "snackbar_error": "Błąd podczas wysyłania kodu" + }, + "common": { + "unsaved_changes": { + "message": "Masz niezbawione zmiany. Czy na pewno chcesz odejść?" + } } -} +} \ No newline at end of file diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json index fd519d36..1be3b7b0 100644 --- a/apps/dashboard/src/lib/utils/translations/pt.json +++ b/apps/dashboard/src/lib/utils/translations/pt.json @@ -861,6 +861,9 @@ "cancel": "Cancelar", "post": "Publicar" } + }, + "analytics": { + "title": "Análise" } }, "navItems": { @@ -872,7 +875,8 @@ "nav_certificates": "Certificados", "nav_landing_page": "Página inicial", "nav_settings": "Configurações", - "nav_content": "Contente" + "nav_content": "Contente", + "nav_analytics": "Análise" }, "not_permitted": { "header": "Sem permissão", @@ -1552,11 +1556,27 @@ "assignment_completion": "Conclusão da tarefa", "assignment_completion_description": "Porcentagem de tarefas concluídas", "average_grade": "Nota Média", - "average_grade_description": "Nota média de todas as tarefas concluídas" + "average_grade_description": "Nota média de todas as tarefas concluídas", + "students": "Alunos", + "lessons": "Lições", + "tutors": "tutores", + "avg_grade": "Grau AVG", + "completion_rate": "Taxa de conclusão", + "student_progress_overview": "Visão geral do progresso do aluno", + "student_name": "Nome do aluno", + "lessons_completed": "Lições concluídas", + "exercises_submitted": "Exercícios enviados", + "actions": "Ações", + "view_details": "Ver detalhes", + "no_students_found": "Nenhum aluno encontrado", + "no_students_description": "Ainda não há alunos matriculados neste curso. Os alunos aparecerão aqui quando ingressarem no curso.", + "showing_students": "Mostrando {start} para {end} de {total} alunos", + "no_analytics_data": "Sem dados de análise", + "no_analytics_description": "Não é possível carregar dados de análise para este curso. Tente novamente mais tarde ou entre em contato com o suporte se o problema persistir." }, "common": { "unsaved_changes": { "message": "Você tem mudanças não salvas. Tem certeza de que deseja sair?" } } -} +} \ No newline at end of file diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json index ea5ed953..c0a13375 100644 --- a/apps/dashboard/src/lib/utils/translations/ru.json +++ b/apps/dashboard/src/lib/utils/translations/ru.json @@ -861,6 +861,9 @@ "cancel": "Отмена", "post": "Почта" } + }, + "analytics": { + "title": "Аналитика" } }, "navItems": { @@ -872,7 +875,8 @@ "nav_certificates": "Сертификаты", "nav_landing_page": "Целевая страница", "nav_settings": "Настройки", - "nav_content": "Содержание" + "nav_content": "Содержание", + "nav_analytics": "Аналитика" }, "not_permitted": { "header": "Нет разрешения", @@ -1552,7 +1556,23 @@ "assignment_completion": "Выполнение задания", "assignment_completion_description": "Процент выполненных заданий", "average_grade": "Средняя оценка", - "average_grade_description": "Средняя оценка за все выполненные задания" + "average_grade_description": "Средняя оценка за все выполненные задания", + "students": "Студенты", + "lessons": "Уроки", + "tutors": "репетиторы", + "avg_grade": "AVG Grade", + "completion_rate": "Коэффициент завершения", + "student_progress_overview": "Студенческий прогресс обзор", + "student_name": "Студенческое имя", + "lessons_completed": "Уроки завершены", + "exercises_submitted": "Упражнения представлены", + "actions": "Действия", + "view_details": "Просмотреть детали", + "no_students_found": "Никаких студентов не нашли", + "no_students_description": "В этом курсе еще нет студентов. Студенты появятся здесь, как только они присоединятся к курсу.", + "showing_students": "Показ {start} to {end} {otto} Студенты", + "no_analytics_data": "Нет аналитических данных", + "no_analytics_description": "Невозможно загрузить данные аналитики для этого курса. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой, если проблема сохранится." }, "common": { "unsaved_changes": { diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json index 8587c999..fdde8929 100644 --- a/apps/dashboard/src/lib/utils/translations/vi.json +++ b/apps/dashboard/src/lib/utils/translations/vi.json @@ -861,6 +861,9 @@ "cancel": "Hủy bỏ", "post": "Bưu kiện" } + }, + "analytics": { + "title": "Phân tích" } }, "navItems": { @@ -872,7 +875,8 @@ "nav_certificates": "Chứng chỉ", "nav_landing_page": "Trang đích", "nav_settings": "Cài đặt", - "nav_content": "Nội dung" + "nav_content": "Nội dung", + "nav_analytics": "Phân tích" }, "not_permitted": { "header": "Không cho phép", @@ -1552,11 +1556,27 @@ "assignment_completion": "Hoàn thành bài tập", "assignment_completion_description": "Tỷ lệ bài tập đã hoàn thành", "average_grade": "Điểm trung bình", - "average_grade_description": "Điểm trung bình của tất cả các bài tập đã hoàn thành" + "average_grade_description": "Điểm trung bình của tất cả các bài tập đã hoàn thành", + "students": "Sinh viên", + "lessons": "Bài học", + "tutors": "gia sư", + "avg_grade": "Lớp AVG", + "completion_rate": "Tỷ lệ hoàn thành", + "student_progress_overview": "Tổng quan về tiến trình học sinh", + "student_name": "Tên sinh viên", + "lessons_completed": "Bài học hoàn thành", + "exercises_submitted": "Bài tập được gửi", + "actions": "Hành động", + "view_details": "Xem chi tiết", + "no_students_found": "Không có học sinh tìm thấy", + "no_students_description": "Không có sinh viên đăng ký vào khóa học này. Học sinh sẽ xuất hiện ở đây khi họ tham gia khóa học.", + "showing_students": "Hiển thị {start} đến {end} của {Total} sinh viên", + "no_analytics_data": "Không có dữ liệu phân tích", + "no_analytics_description": "Không thể tải dữ liệu phân tích cho khóa học này. Vui lòng thử lại sau hoặc liên hệ với hỗ trợ nếu vấn đề vẫn còn." }, "common": { "unsaved_changes": { "message": "Bạn có những thay đổi chưa được lưu. Bạn có chắc bạn muốn rời đi?" } } -} +} \ No newline at end of file diff --git a/apps/dashboard/src/lib/utils/types/analytics.ts b/apps/dashboard/src/lib/utils/types/analytics.ts index ac638c2c..c530f0b2 100644 --- a/apps/dashboard/src/lib/utils/types/analytics.ts +++ b/apps/dashboard/src/lib/utils/types/analytics.ts @@ -1,3 +1,64 @@ +export interface CourseAnalytics { + totalTutors: number; + totalStudents: number; + totalLessons: number; + totalExercises: number; + lessonCompletionRate: number; + exerciseCompletionRate: number; + averageGrade: number; + students: StudentOverview[]; +} + +export interface StudentOverview { + id: string; + profile: { + fullname: string; + email: string; + avatar_url: string; + }; + lessonsCompleted: number; + totalLessons: number; + exercisesSubmitted: number; + totalExercises: number; + averageGrade: number; + lastSeen: string; + progressPercentage: number; +} + +export interface StudentDetailModal { + student: { + profile: { + id: string; + fullname: string; + email: string; + avatar_url: string; + }; + overallStats: { + totalCourses: number; + totalLessonsCompleted: number; + totalExercisesSubmitted: number; + overallAverageGrade: number; + lastSeen: string; + }; + courses: StudentCourseAnalytics[]; + }; +} + +export interface StudentCourseAnalytics { + courseId: string; + courseTitle: string; + courseLogo: string; + role: 'student' | 'tutor' | 'admin'; + lessonsCompleted: number; + totalLessons: number; + exercisesSubmitted: number; + totalExercises: number; + averageGrade: number; + progressPercentage: number; + lastActivity: string; + enrollmentDate: string; +} + interface User { id: string; fullName: string; diff --git a/apps/dashboard/src/routes/api/analytics/course/+server.ts b/apps/dashboard/src/routes/api/analytics/course/+server.ts new file mode 100644 index 00000000..251ffc2c --- /dev/null +++ b/apps/dashboard/src/routes/api/analytics/course/+server.ts @@ -0,0 +1,281 @@ +import type { CourseAnalytics, StudentOverview } from '$lib/utils/types/analytics'; + +import type { UserExercisesStats } from '$lib/utils/types/analytics'; +import { fetchProfileCourseProgress } from '$lib/utils/services/courses'; +import { getServerSupabase } from '$lib/utils/functions/supabase.server'; +import { json } from '@sveltejs/kit'; + +const supabase = getServerSupabase(); + +const CACHE_DURATION = 60 * 5; // 5 minutes + +export async function POST({ setHeaders, request }) { + const { courseId } = await request.json(); + + if (!courseId) { + return json({ success: false, message: 'Request is missing required fields' }, { status: 400 }); + } + + setHeaders({ + 'cache-control': `max-age=${CACHE_DURATION}`, + 'content-type': 'application/json' + }); + + try { + const analytics = await getCourseAnalytics(courseId); + return json(analytics, { status: 200 }); + } catch (error) { + console.error('Course analytics error:', error); + return json({ error: 'Something went wrong' }, { status: 500 }); + } +} + +async function getCourseAnalytics(courseId: string): Promise { + const analytics: CourseAnalytics = { + totalTutors: 0, + totalStudents: 0, + totalLessons: 0, + totalExercises: 0, + lessonCompletionRate: 0, + exerciseCompletionRate: 0, + averageGrade: 0, + students: [] + }; + + // Fetch course basic info and group members + const { data: courseData, error: courseError } = await supabase + .from('course') + .select( + ` + id, + title, + group( + members:groupmember( + id, + role_id, + profile_id, + profile( + id, + fullname, + email, + avatar_url + ) + ) + ), + lessons:lesson( + id, + title, + exercise(id) + ) + ` + ) + .eq('id', courseId) + .single(); + + if (courseError) { + throw new Error('Failed to fetch course data'); + } + + // Count tutors and students + const members = (courseData.group as any)?.members || []; + analytics.totalTutors = members.filter( + (member) => member.role_id === 1 || member.role_id === 2 + ).length; + analytics.totalStudents = members.filter((member) => member.role_id === 3).length; + + // Count lessons and exercises + analytics.totalLessons = courseData.lessons?.length || 0; + analytics.totalExercises = + courseData.lessons?.reduce((total, lesson) => total + (lesson.exercise?.length || 0), 0) || 0; + + // Get student analytics + const studentAnalytics = await Promise.all( + members + .filter((member) => member.role_id === 3) + .map((member) => getStudentOverview(courseId, member)) + ); + + analytics.students = studentAnalytics.filter( + (student): student is StudentOverview => student !== null + ); + + // Calculate aggregated metrics + if (analytics.students.length > 0) { + analytics.lessonCompletionRate = Math.round( + analytics.students.reduce((sum, student) => sum + student.progressPercentage, 0) / + analytics.students.length + ); + + analytics.exerciseCompletionRate = Math.round( + analytics.students.reduce((sum, student) => { + const completionRate = + student.totalExercises > 0 + ? (student.exercisesSubmitted / student.totalExercises) * 100 + : 0; + return sum + completionRate; + }, 0) / analytics.students.length + ); + + analytics.averageGrade = Math.round( + analytics.students.reduce((sum, student) => sum + student.averageGrade, 0) / + analytics.students.length + ); + } + + return analytics; +} + +async function getLastLogin(userId: string): Promise { + try { + const { data, error } = await supabase + .from('analytics_login_events') + .select('logged_in_at') + .eq('user_id', userId) + .order('logged_in_at', { ascending: false }) + .limit(1); + + if (error) throw error; + + return data?.[0]?.logged_in_at; + } catch (error) { + console.error('Error fetching last login:', error); + return undefined; + } +} + +async function getStudentOverview(courseId: string, member: any): Promise { + try { + // Use the same service function that the working analytics/user API uses + const { data: courseProgressData, error: progressError } = await fetchProfileCourseProgress( + courseId, + member.profile_id + ); + + if (progressError) { + console.error('Error fetching course progress:', progressError); + return null; + } + + const courseProgress = courseProgressData?.[0]; + if (!courseProgress) { + console.error('No course progress data found'); + return null; + } + + // Fetch exercise stats using the same method as the people page + const userExercisesStats = await fetchUserExercisesStats(courseId, member.profile_id); + + // Calculate exercise completion using the same logic as people page + const completedExercises = + userExercisesStats?.filter((exercise) => exercise.isCompleted)?.length || 0; + const totalExercises = userExercisesStats?.length || 0; + + // Get last login using the same approach as analytics/user API + const lastLoginDate = await getLastLogin(member.profile_id); + + // Format last seen + let lastSeen = 'Never'; + if (lastLoginDate) { + const lastLogin = new Date(lastLoginDate); + const now = new Date(); + const diffInHours = Math.floor((now.getTime() - lastLogin.getTime()) / (1000 * 60 * 60)); + + if (diffInHours < 1) { + lastSeen = 'Just now'; + } else if (diffInHours < 24) { + lastSeen = `${diffInHours} hours ago`; + } else { + const diffInDays = Math.floor(diffInHours / 24); + lastSeen = `${diffInDays} days ago`; + } + } + + // Calculate progress percentage + const lessonsCompleted = courseProgress.lessons_completed || 0; + const totalLessons = courseProgress.lessons_count || 0; + const progressPercentage = + totalLessons > 0 ? Math.round((lessonsCompleted / totalLessons) * 100) : 0; + + // Calculate average grade using the same logic as the people page + const totalEarnedPoints = + userExercisesStats?.reduce((sum, exercise) => sum + exercise.score, 0) || 0; + const totalPoints = + userExercisesStats?.reduce((sum, exercise) => sum + exercise.totalPoints, 0) || 0; + const averageGrade = totalPoints > 0 ? Math.round((totalEarnedPoints / totalPoints) * 100) : 0; + + return { + id: member.profile_id, + profile: { + fullname: member.profile?.fullname || 'Unknown', + email: member.profile?.email || '', + avatar_url: member.profile?.avatar_url || '' + }, + lessonsCompleted, + totalLessons, + exercisesSubmitted: completedExercises, + totalExercises: totalExercises, + averageGrade, + lastSeen, + progressPercentage + }; + } catch (error) { + console.error('Error getting student overview:', error); + return null; + } +} + +async function fetchUserExercisesStats( + courseId: string, + userId: string +): Promise { + try { + const { data: courseData, error: queryError } = await supabase + .from('course') + .select( + ` + lesson!inner ( + title,exercise!inner ( + id,title,lesson_id,created_at,question (points), + submission!left ( + id,total,status_id,submitted_by,groupmember!inner (id,profile_id) + ) + ) + ) + ` + ) + .eq('lesson.exercise.submission.groupmember.profile_id', userId) + .eq('id', courseId); + + if (queryError) { + console.error('Error fetching exercise data:', queryError); + return; + } + + if (!courseData || courseData.length === 0) { + return; + } + + const userExercisesStats = courseData[0].lesson.flatMap( + (lesson) => + lesson.exercise?.map((exercise) => { + const totalPoints = exercise.question?.reduce((sum, q) => sum + (q.points || 0), 0) || 0; + const userSubmission = exercise.submission?.[0]; + + return { + id: exercise.id, + lessonId: exercise.lesson_id, + lessonTitle: lesson.title, + title: exercise.title, + status: userSubmission?.status_id, + score: userSubmission?.total || 0, + totalPoints, + isCompleted: !!userSubmission + }; + }) || [] + ); + + return userExercisesStats; + } catch (error) { + console.error('Unexpected error:', error); + } +} diff --git a/apps/dashboard/src/routes/courses/[id]/analytics/+page.svelte b/apps/dashboard/src/routes/courses/[id]/analytics/+page.svelte new file mode 100644 index 00000000..593e6d5f --- /dev/null +++ b/apps/dashboard/src/routes/courses/[id]/analytics/+page.svelte @@ -0,0 +1,140 @@ + + + + + + {#if isLoading} + + {:else if courseAnalytics} +
+ +
+ +
+
+ + + + + + + + + + + +
+
+ + +
+ +
+ + +
+
+

+ {$t('analytics.student_progress_overview')} +

+
+
+ +
+
+
+
+ {:else} + + {/if} +
+
diff --git a/apps/dashboard/src/routes/courses/[id]/analytics/+page.ts b/apps/dashboard/src/routes/courses/[id]/analytics/+page.ts new file mode 100644 index 00000000..6daab497 --- /dev/null +++ b/apps/dashboard/src/routes/courses/[id]/analytics/+page.ts @@ -0,0 +1,7 @@ +export const load = ({ params }) => { + const { id } = params; + + return { + courseId: id + }; +}; diff --git a/apps/dashboard/src/routes/courses/[id]/people/+layout.svelte b/apps/dashboard/src/routes/courses/[id]/people/+layout.svelte index 42b7747d..2f8dfc72 100644 --- a/apps/dashboard/src/routes/courses/[id]/people/+layout.svelte +++ b/apps/dashboard/src/routes/courses/[id]/people/+layout.svelte @@ -11,10 +11,21 @@ export let data; + // Get back URL from query parameters + $: backUrl = $page.url.searchParams.get('back'); + const handleClick = () => { const url = $page.url.href + '?add=true'; goto(url); }; + + const handleBackNavigation = () => { + if (backUrl) { + goto(backUrl); + } else { + goto(`/courses/${data.courseId}/people`); + } + }; @@ -22,12 +33,7 @@ {#if data.personId} - { - goto(`/courses/${data.courseId}/people`); - }} - > + diff --git a/apps/dashboard/src/routes/courses/[id]/people/[personId]/+page.svelte b/apps/dashboard/src/routes/courses/[id]/people/[personId]/+page.svelte index c63994ba..33bc4c95 100644 --- a/apps/dashboard/src/routes/courses/[id]/people/[personId]/+page.svelte +++ b/apps/dashboard/src/routes/courses/[id]/people/[personId]/+page.svelte @@ -105,7 +105,7 @@
-
+

{$t('analytics.exercises')}

diff --git a/supabase/migrations/20250917235052_course_analytics.sql b/supabase/migrations/20250917235052_course_analytics.sql new file mode 100644 index 00000000..00cae1ad --- /dev/null +++ b/supabase/migrations/20250917235052_course_analytics.sql @@ -0,0 +1,31 @@ +set check_function_bodies = off; + +CREATE OR REPLACE FUNCTION public.get_course_progress(course_id_arg uuid, profile_id_arg uuid) + RETURNS TABLE(lessons_count bigint, lessons_completed bigint, exercises_count bigint, exercises_completed bigint) + LANGUAGE plpgsql +AS $function$ +BEGIN +RETURN QUERY +select + count(distinct lesson.id) as lessons_count, + count(distinct lesson_completion.id) as lessons_completed, + count(distinct exercise.id) as exercises_count, + count(distinct submission.id) as exercises_completed +from + course + join "group" on "group".id = course.group_id + join groupmember on groupmember.group_id = course.group_id + join profile on profile.id = groupmember.profile_id + left join lesson on lesson.course_id = course.id + left join lesson_completion on lesson_completion.lesson_id = lesson.id + and lesson_completion.is_complete = true + and lesson_completion.profile_id = profile.id + left join exercise on exercise.lesson_id = lesson.id + left join submission on submission.exercise_id = exercise.id + and submission.submitted_by = groupmember.id +where + course.id = course_id_arg + and profile.id = profile_id_arg; +END; +$function$ +;