feat(admin): add user vocab course management functionality
All checks were successful
Deploy to production / deploy (push) Successful in 2m59s

- Implemented `getUserVocabCourses` and `getVocabCourseForAdmin` methods in `AdminController` to allow admins to retrieve enrolled vocab courses for users and specific course details, respectively.
- Updated `adminRouter` to include new routes for accessing user vocab courses and course details.
- Enhanced `AdminService` with methods to list user-enrolled vocab courses and retrieve course information with lessons, ensuring proper access control.
- Improved `VocabService` to support the new functionalities, including attaching language names to course data.
- Updated UI components in `UsersView` to reflect changes, including error handling and loading states for course retrieval, along with localization updates for new features.
This commit is contained in:
Torsten Schulz (local)
2026-04-02 09:21:52 +02:00
parent b3c8e8e210
commit 2272db7f91
9 changed files with 217 additions and 67 deletions

View File

@@ -23,8 +23,8 @@
},
"vocabLessonReset": {
"title": "Kurso sa pinulongan: pag-uswag sa leksiyon",
"intro": "Tangtanga ang pag-uswag, mga resulta sa ehersisyo ug natipig nga kahimtang sa usa ka leksiyon lamang (dili ang tibuok kurso). Makita ra ang mga kurso nga makita sa imong admin account (publiko o imoha).",
"loadCourses": "Ikarga ang mga kurso",
"intro": "Tangtanga ang pag-uswag, mga resulta sa ehersisyo ug natipig nga kahimtang sa usa ka leksiyon lamang (dili ang tibuok kurso). Makita ra ang mga kurso nga na-enroll niini nga tiggamit.",
"loadCourses": "Ikarga ang na-enroll nga mga kurso",
"selectCourse": "Kurso",
"selectLesson": "Leksiyon",
"reset": "I-reset ang leksiyon niining user",
@@ -32,7 +32,8 @@
"success": "Na-reset na ang pag-uswag sa leksiyon.",
"error": "Dili ma-reset.",
"pickUserFirst": "Una pagpili ug user.",
"noCourses": "Walay nakarga nga kurso o walay makita nga kurso.",
"noEnrolledCourses": "Kini nga tiggamit wala na-enroll sa bisan unsang kurso sa pinulongan.",
"loadCoursesError": "Dili makarga ang lista sa mga kurso.",
"loadingLessons": "Nagkarga sa mga leksiyon …"
},
"rights": {

View File

@@ -32,8 +32,8 @@
},
"vocabLessonReset": {
"title": "Sprachkurs: Lektionsfortschritt",
"intro": "Fortschritt, Übungsergebnisse und gespeicherter Lektionszustand für eine einzelne Lektion löschen (nicht der ganze Kurs). Es werden nur Kurse gelistet, die du als Admin sehen kannst (öffentlich oder eigene).",
"loadCourses": "Kurse laden",
"intro": "Fortschritt, Übungsergebnisse und gespeicherter Lektionszustand für eine einzelne Lektion löschen (nicht der ganze Kurs). Es werden nur Sprachkurse gelistet, in die dieser Benutzer eingeschrieben ist.",
"loadCourses": "Eingeschriebene Kurse laden",
"selectCourse": "Kurs",
"selectLesson": "Lektion",
"reset": "Lektion für diesen Nutzer zurücksetzen",
@@ -41,7 +41,8 @@
"success": "Lektionsfortschritt wurde zurückgesetzt.",
"error": "Zurücksetzen fehlgeschlagen.",
"pickUserFirst": "Zuerst einen Benutzer auswählen.",
"noCourses": "Keine Kurse geladen oder keine sichtbaren Kurse.",
"noEnrolledCourses": "Dieser Benutzer ist in keinem Sprachkurs eingeschrieben.",
"loadCoursesError": "Die Kursliste konnte nicht geladen werden.",
"loadingLessons": "Lektionen werden geladen …"
},
"adultVerification": {

View File

@@ -32,8 +32,8 @@
},
"vocabLessonReset": {
"title": "Language course: lesson progress",
"intro": "Delete progress, exercise results and saved lesson state for a single lesson (not the whole course). Only courses you can see as this admin account are listed (public or your own).",
"loadCourses": "Load courses",
"intro": "Delete progress, exercise results and saved lesson state for a single lesson (not the whole course). Only language courses this user is enrolled in are listed.",
"loadCourses": "Load enrolled courses",
"selectCourse": "Course",
"selectLesson": "Lesson",
"reset": "Reset lesson for this user",
@@ -41,7 +41,8 @@
"success": "Lesson progress was reset.",
"error": "Reset failed.",
"pickUserFirst": "Select a user first.",
"noCourses": "No courses loaded or no visible courses.",
"noEnrolledCourses": "This user is not enrolled in any language course.",
"loadCoursesError": "Could not load the course list.",
"loadingLessons": "Loading lessons…"
},
"adultVerification": {

View File

@@ -32,8 +32,8 @@
},
"vocabLessonReset": {
"title": "Curso de idiomas: progreso de lección",
"intro": "Elimina el progreso, los resultados de ejercicios y el estado guardado de una sola lección (no todo el curso). Solo se listan cursos visibles para tu cuenta de administración (públicos o propios).",
"loadCourses": "Cargar cursos",
"intro": "Elimina el progreso, los resultados de ejercicios y el estado guardado de una sola lección (no todo el curso). Solo aparecen los cursos de idiomas en los que está inscrito este usuario.",
"loadCourses": "Cargar cursos inscritos",
"selectCourse": "Curso",
"selectLesson": "Lección",
"reset": "Restablecer lección para este usuario",
@@ -41,7 +41,8 @@
"success": "Se restableció el progreso de la lección.",
"error": "No se pudo restablecer.",
"pickUserFirst": "Primero elige un usuario.",
"noCourses": "No hay cursos cargados o no hay cursos visibles.",
"noEnrolledCourses": "Este usuario no está inscrito en ningún curso de idiomas.",
"loadCoursesError": "No se pudo cargar la lista de cursos.",
"loadingLessons": "Cargando lecciones…"
},
"adultVerification": {

View File

@@ -50,12 +50,15 @@
<span>{{ $t('admin.vocabLessonReset.selectCourse') }}</span>
<select v-model="selectedVocabCourseId" @change="onVocabCourseChange">
<option value="">{{ $t('admin.vocabLessonReset.selectCourse') }}</option>
<option v-for="c in vocabCourses" :key="c.id" :value="String(c.id)">{{ c.title }}</option>
<option v-for="c in vocabCoursesForSelect" :key="c.id" :value="String(c.id)">{{ c.selectLabel }}</option>
</select>
</label>
<p v-else-if="vocabCoursesAttempted && !loadingVocabCourses && vocabLoadError" class="vocab-reset__hint">
{{ $t('admin.vocabLessonReset.loadCoursesError') }}
</p>
<p v-else-if="vocabCoursesAttempted && !loadingVocabCourses" class="vocab-reset__hint">
{{ $t('admin.vocabLessonReset.noCourses') }}
{{ $t('admin.vocabLessonReset.noEnrolledCourses') }}
</p>
<label v-if="vocabCourseLessons.length" class="edit__field">
@@ -101,13 +104,34 @@ export default {
selectedVocabLessonId: '',
loadingVocabCourses: false,
loadingVocabCourseDetail: false,
vocabResetSubmitting: false
vocabResetSubmitting: false,
vocabLoadError: false
};
},
computed: {
/** Gleicher Titel bei mehreren Kurs-IDs → Kurs-ID anhängen (z. B. geklonte Kurse). */
vocabCoursesForSelect() {
const list = this.vocabCourses;
const counts = {};
list.forEach((c) => {
const t = (c.title || '').trim() || '—';
counts[t] = (counts[t] || 0) + 1;
});
return list.map((c) => {
const t = (c.title || '').trim() || '—';
const dup = counts[t] > 1;
return {
...c,
selectLabel: dup ? `${c.title} (#${c.id})` : (c.title || `#${c.id}`)
};
});
}
},
methods: {
clearVocabResetUi() {
this.vocabCourses = [];
this.vocabCoursesAttempted = false;
this.vocabLoadError = false;
this.selectedVocabCourseId = '';
this.vocabCourseLessons = [];
this.selectedVocabLessonId = '';
@@ -137,8 +161,9 @@ export default {
}
this.loadingVocabCourses = true;
this.vocabCoursesAttempted = true;
this.vocabLoadError = false;
try {
const { data } = await apiClient.get('/api/vocab/courses');
const { data } = await apiClient.get(`/api/admin/users/${this.selected.id}/vocab-courses`);
this.vocabCourses = Array.isArray(data) ? data : [];
this.selectedVocabCourseId = '';
this.vocabCourseLessons = [];
@@ -146,6 +171,7 @@ export default {
} catch (e) {
console.error('[UsersView] vocab courses:', e);
this.vocabCourses = [];
this.vocabLoadError = true;
this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonReset.error'));
} finally {
this.loadingVocabCourses = false;
@@ -159,7 +185,7 @@ export default {
}
this.loadingVocabCourseDetail = true;
try {
const { data } = await apiClient.get(`/api/vocab/courses/${this.selectedVocabCourseId}`);
const { data } = await apiClient.get(`/api/admin/vocab/courses/${this.selectedVocabCourseId}`);
const lessons = data?.lessons || [];
this.vocabCourseLessons = [...lessons].sort((a, b) => {
if (a.weekNumber !== b.weekNumber) {