feat(admin): add user vocab course management functionality
All checks were successful
Deploy to production / deploy (push) Successful in 2m59s
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:
@@ -34,6 +34,8 @@ class AdminController {
|
|||||||
this.getUsers = this.getUsers.bind(this);
|
this.getUsers = this.getUsers.bind(this);
|
||||||
this.updateUser = this.updateUser.bind(this);
|
this.updateUser = this.updateUser.bind(this);
|
||||||
this.resetUserVocabLessonProgress = this.resetUserVocabLessonProgress.bind(this);
|
this.resetUserVocabLessonProgress = this.resetUserVocabLessonProgress.bind(this);
|
||||||
|
this.getUserVocabCourses = this.getUserVocabCourses.bind(this);
|
||||||
|
this.getVocabCourseForAdmin = this.getVocabCourseForAdmin.bind(this);
|
||||||
this.getAdultVerificationRequests = this.getAdultVerificationRequests.bind(this);
|
this.getAdultVerificationRequests = this.getAdultVerificationRequests.bind(this);
|
||||||
this.setAdultVerificationStatus = this.setAdultVerificationStatus.bind(this);
|
this.setAdultVerificationStatus = this.setAdultVerificationStatus.bind(this);
|
||||||
this.getAdultVerificationDocument = this.getAdultVerificationDocument.bind(this);
|
this.getAdultVerificationDocument = this.getAdultVerificationDocument.bind(this);
|
||||||
@@ -149,6 +151,30 @@ class AdminController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserVocabCourses(req, res) {
|
||||||
|
try {
|
||||||
|
const { userid: requester } = req.headers;
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await AdminService.adminListUserEnrolledVocabCourses(requester, id);
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.message === 'noaccess' ? 403 : (err.message === 'notfound' ? 404 : 500);
|
||||||
|
res.status(status).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVocabCourseForAdmin(req, res) {
|
||||||
|
try {
|
||||||
|
const { userid: requester } = req.headers;
|
||||||
|
const { courseId } = req.params;
|
||||||
|
const result = await AdminService.adminGetVocabCourseWithLessons(requester, courseId);
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.message === 'noaccess' ? 403 : (err.message === 'coursenotfound' ? 404 : 500);
|
||||||
|
res.status(status).json({ error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getAdultVerificationRequests(req, res) {
|
async getAdultVerificationRequests(req, res) {
|
||||||
try {
|
try {
|
||||||
const { userid: requester } = req.headers;
|
const { userid: requester } = req.headers;
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ router.put('/users/:id/adult-verification', authenticate, adminController.setAdu
|
|||||||
router.get('/users/erotic-moderation', authenticate, adminController.getEroticModerationReports);
|
router.get('/users/erotic-moderation', authenticate, adminController.getEroticModerationReports);
|
||||||
router.get('/users/erotic-moderation/preview/:type/:targetId', authenticate, adminController.getEroticModerationPreview);
|
router.get('/users/erotic-moderation/preview/:type/:targetId', authenticate, adminController.getEroticModerationPreview);
|
||||||
router.put('/users/erotic-moderation/:id', authenticate, adminController.applyEroticModerationAction);
|
router.put('/users/erotic-moderation/:id', authenticate, adminController.applyEroticModerationAction);
|
||||||
|
router.get('/users/:id/vocab-courses', authenticate, adminController.getUserVocabCourses);
|
||||||
router.post('/users/:id/vocab-lesson-progress/reset', authenticate, adminController.resetUserVocabLessonProgress);
|
router.post('/users/:id/vocab-lesson-progress/reset', authenticate, adminController.resetUserVocabLessonProgress);
|
||||||
|
router.get('/vocab/courses/:courseId', authenticate, adminController.getVocabCourseForAdmin);
|
||||||
router.get('/users/:id', authenticate, adminController.getUser);
|
router.get('/users/:id', authenticate, adminController.getUser);
|
||||||
router.put('/users/:id', authenticate, adminController.updateUser);
|
router.put('/users/:id', authenticate, adminController.updateUser);
|
||||||
|
|
||||||
|
|||||||
@@ -1973,6 +1973,36 @@ class AdminService {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async adminListUserEnrolledVocabCourses(requesterHashedId, targetHashedId) {
|
||||||
|
if (!(await this.hasUserAccess(requesterHashedId, 'useradministration'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
const vocab = new VocabService();
|
||||||
|
try {
|
||||||
|
return await vocab.listEnrolledVocabCoursesForUser(targetHashedId);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 404) {
|
||||||
|
throw new Error('notfound');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminGetVocabCourseWithLessons(requesterHashedId, courseId) {
|
||||||
|
if (!(await this.hasUserAccess(requesterHashedId, 'useradministration'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
const vocab = new VocabService();
|
||||||
|
try {
|
||||||
|
return await vocab.adminGetCourseWithLessonsForStaff(Number(courseId));
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 404) {
|
||||||
|
throw new Error('coursenotfound');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new AdminService();
|
export default new AdminService();
|
||||||
|
|||||||
@@ -312,6 +312,44 @@ export default class VocabService {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _attachLanguageNamesToCourseRows(coursesData) {
|
||||||
|
if (!coursesData.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const languageIds = [...new Set(coursesData.map((c) => c.languageId))];
|
||||||
|
if (languageIds.length > 0) {
|
||||||
|
const languages = await sequelize.query(
|
||||||
|
`SELECT id, name FROM community.vocab_language WHERE id IN (:languageIds)`,
|
||||||
|
{
|
||||||
|
replacements: { languageIds },
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (Array.isArray(languages)) {
|
||||||
|
const languageMap = new Map(languages.map((l) => [l.id, l.name]));
|
||||||
|
coursesData.forEach((c) => {
|
||||||
|
c.languageName = languageMap.get(c.languageId) || null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const nativeLanguageIds = [...new Set(coursesData.map((c) => c.nativeLanguageId).filter((id) => id !== null))];
|
||||||
|
if (nativeLanguageIds.length > 0) {
|
||||||
|
const nativeLanguages = await sequelize.query(
|
||||||
|
`SELECT id, name FROM community.vocab_language WHERE id IN (:nativeLanguageIds)`,
|
||||||
|
{
|
||||||
|
replacements: { nativeLanguageIds },
|
||||||
|
type: sequelize.QueryTypes.SELECT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (Array.isArray(nativeLanguages)) {
|
||||||
|
const nativeLanguageMap = new Map(nativeLanguages.map((l) => [l.id, l.name]));
|
||||||
|
coursesData.forEach((c) => {
|
||||||
|
c.nativeLanguageName = c.nativeLanguageId ? nativeLanguageMap.get(c.nativeLanguageId) || null : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async _getUserLlmConfig(userId) {
|
async _getUserLlmConfig(userId) {
|
||||||
const [settingsType, apiKeyType] = await Promise.all([
|
const [settingsType, apiKeyType] = await Promise.all([
|
||||||
UserParamType.findOne({ where: { description: 'llm_settings' } }),
|
UserParamType.findOne({ where: { description: 'llm_settings' } }),
|
||||||
@@ -1743,57 +1781,8 @@ export default class VocabService {
|
|||||||
order: [['createdAt', 'DESC']]
|
order: [['createdAt', 'DESC']]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Debug-Logging (kann später entfernt werden)
|
|
||||||
console.log(`[getCourses] Gefunden: ${courses.length} Kurse`, {
|
|
||||||
userId: user.id,
|
|
||||||
languageId,
|
|
||||||
nativeLanguageId,
|
|
||||||
search,
|
|
||||||
whereBefore: JSON.stringify(where, null, 2),
|
|
||||||
includePublic: includePublicBool,
|
|
||||||
includeOwn: includeOwnBool,
|
|
||||||
andConditionsLength: andConditions.length,
|
|
||||||
directWherePropsBefore: Object.keys(where).filter(key => key !== Op.and && key !== Op.or),
|
|
||||||
whereAfter: JSON.stringify(where, null, 2)
|
|
||||||
});
|
|
||||||
|
|
||||||
const coursesData = courses.map(c => c.get({ plain: true }));
|
const coursesData = courses.map(c => c.get({ plain: true }));
|
||||||
|
await this._attachLanguageNamesToCourseRows(coursesData);
|
||||||
// Lade Sprachnamen für alle Kurse
|
|
||||||
const languageIds = [...new Set(coursesData.map(c => c.languageId))];
|
|
||||||
if (languageIds.length > 0) {
|
|
||||||
const languages = await sequelize.query(
|
|
||||||
`SELECT id, name FROM community.vocab_language WHERE id IN (:languageIds)`,
|
|
||||||
{
|
|
||||||
replacements: { languageIds },
|
|
||||||
type: sequelize.QueryTypes.SELECT
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (Array.isArray(languages)) {
|
|
||||||
const languageMap = new Map(languages.map(l => [l.id, l.name]));
|
|
||||||
coursesData.forEach(c => {
|
|
||||||
c.languageName = languageMap.get(c.languageId) || null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lade Muttersprachen-Namen für alle Kurse
|
|
||||||
const nativeLanguageIds = [...new Set(coursesData.map(c => c.nativeLanguageId).filter(id => id !== null))];
|
|
||||||
if (nativeLanguageIds.length > 0) {
|
|
||||||
const nativeLanguages = await sequelize.query(
|
|
||||||
`SELECT id, name FROM community.vocab_language WHERE id IN (:nativeLanguageIds)`,
|
|
||||||
{
|
|
||||||
replacements: { nativeLanguageIds },
|
|
||||||
type: sequelize.QueryTypes.SELECT
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (Array.isArray(nativeLanguages)) {
|
|
||||||
const nativeLanguageMap = new Map(nativeLanguages.map(l => [l.id, l.name]));
|
|
||||||
coursesData.forEach(c => {
|
|
||||||
c.nativeLanguageName = c.nativeLanguageId ? nativeLanguageMap.get(c.nativeLanguageId) || null : null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return coursesData;
|
return coursesData;
|
||||||
}
|
}
|
||||||
@@ -1875,6 +1864,45 @@ export default class VocabService {
|
|||||||
return courseData;
|
return courseData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Admin/Support: Kurs inkl. Lektionen ohne Sichtbarkeitsprüfung (nur serverseitig für Staff-Routen). */
|
||||||
|
async adminGetCourseWithLessonsForStaff(courseId) {
|
||||||
|
const course = await VocabCourse.findByPk(Number(courseId), {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: VocabCourseLesson,
|
||||||
|
as: 'lessons',
|
||||||
|
order: [['lessonNumber', 'ASC']]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!course) {
|
||||||
|
const err = new Error('Course not found');
|
||||||
|
err.status = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const courseData = course.get({ plain: true });
|
||||||
|
courseData.lessons = courseData.lessons || [];
|
||||||
|
|
||||||
|
courseData.lessons.sort((a, b) => {
|
||||||
|
if (a.weekNumber !== b.weekNumber) {
|
||||||
|
return (a.weekNumber || 999) - (b.weekNumber || 999);
|
||||||
|
}
|
||||||
|
if (a.dayNumber !== b.dayNumber) {
|
||||||
|
return (a.dayNumber || 999) - (b.dayNumber || 999);
|
||||||
|
}
|
||||||
|
return a.lessonNumber - b.lessonNumber;
|
||||||
|
});
|
||||||
|
|
||||||
|
courseData.lessons = courseData.lessons.map((lesson) => ({
|
||||||
|
...lesson,
|
||||||
|
pedagogy: this._buildLessonPedagogy(lesson)
|
||||||
|
}));
|
||||||
|
|
||||||
|
return courseData;
|
||||||
|
}
|
||||||
|
|
||||||
async updateCourse(hashedUserId, courseId, { title, description, languageId, nativeLanguageId, difficultyLevel, isPublic }) {
|
async updateCourse(hashedUserId, courseId, { title, description, languageId, nativeLanguageId, difficultyLevel, isPublic }) {
|
||||||
const user = await this._getUserByHashedId(hashedUserId);
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
const course = await VocabCourse.findByPk(courseId);
|
const course = await VocabCourse.findByPk(courseId);
|
||||||
@@ -2357,6 +2385,40 @@ export default class VocabService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kurse, in die der Nutzer (per Hash) eingeschrieben ist — jede courseId nur einmal,
|
||||||
|
* bei mehrfachen Einschreibungen zählt die jeweils neueste Zeile.
|
||||||
|
*/
|
||||||
|
async listEnrolledVocabCoursesForUser(targetHashedUserId) {
|
||||||
|
const user = await this._getUserByHashedId(targetHashedUserId);
|
||||||
|
|
||||||
|
const enrollments = await VocabCourseEnrollment.findAll({
|
||||||
|
where: { userId: user.id },
|
||||||
|
include: [{ model: VocabCourse, as: 'course', required: true }],
|
||||||
|
order: [['enrolledAt', 'DESC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
const byCourseId = new Map();
|
||||||
|
for (const e of enrollments) {
|
||||||
|
const row = e.course;
|
||||||
|
if (!row) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const plain = row.get({ plain: true });
|
||||||
|
if (byCourseId.has(plain.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
byCourseId.set(plain.id, {
|
||||||
|
...plain,
|
||||||
|
enrolledAt: e.enrolledAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const coursesData = [...byCourseId.values()];
|
||||||
|
await this._attachLanguageNamesToCourseRows(coursesData);
|
||||||
|
return coursesData;
|
||||||
|
}
|
||||||
|
|
||||||
async getCourseProgress(hashedUserId, courseId) {
|
async getCourseProgress(hashedUserId, courseId) {
|
||||||
const user = await this._getUserByHashedId(hashedUserId);
|
const user = await this._getUserByHashedId(hashedUserId);
|
||||||
|
|
||||||
|
|||||||
@@ -23,8 +23,8 @@
|
|||||||
},
|
},
|
||||||
"vocabLessonReset": {
|
"vocabLessonReset": {
|
||||||
"title": "Kurso sa pinulongan: pag-uswag sa leksiyon",
|
"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).",
|
"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 mga kurso",
|
"loadCourses": "Ikarga ang na-enroll nga mga kurso",
|
||||||
"selectCourse": "Kurso",
|
"selectCourse": "Kurso",
|
||||||
"selectLesson": "Leksiyon",
|
"selectLesson": "Leksiyon",
|
||||||
"reset": "I-reset ang leksiyon niining user",
|
"reset": "I-reset ang leksiyon niining user",
|
||||||
@@ -32,7 +32,8 @@
|
|||||||
"success": "Na-reset na ang pag-uswag sa leksiyon.",
|
"success": "Na-reset na ang pag-uswag sa leksiyon.",
|
||||||
"error": "Dili ma-reset.",
|
"error": "Dili ma-reset.",
|
||||||
"pickUserFirst": "Una pagpili ug user.",
|
"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 …"
|
"loadingLessons": "Nagkarga sa mga leksiyon …"
|
||||||
},
|
},
|
||||||
"rights": {
|
"rights": {
|
||||||
|
|||||||
@@ -32,8 +32,8 @@
|
|||||||
},
|
},
|
||||||
"vocabLessonReset": {
|
"vocabLessonReset": {
|
||||||
"title": "Sprachkurs: Lektionsfortschritt",
|
"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).",
|
"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": "Kurse laden",
|
"loadCourses": "Eingeschriebene Kurse laden",
|
||||||
"selectCourse": "Kurs",
|
"selectCourse": "Kurs",
|
||||||
"selectLesson": "Lektion",
|
"selectLesson": "Lektion",
|
||||||
"reset": "Lektion für diesen Nutzer zurücksetzen",
|
"reset": "Lektion für diesen Nutzer zurücksetzen",
|
||||||
@@ -41,7 +41,8 @@
|
|||||||
"success": "Lektionsfortschritt wurde zurückgesetzt.",
|
"success": "Lektionsfortschritt wurde zurückgesetzt.",
|
||||||
"error": "Zurücksetzen fehlgeschlagen.",
|
"error": "Zurücksetzen fehlgeschlagen.",
|
||||||
"pickUserFirst": "Zuerst einen Benutzer auswählen.",
|
"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 …"
|
"loadingLessons": "Lektionen werden geladen …"
|
||||||
},
|
},
|
||||||
"adultVerification": {
|
"adultVerification": {
|
||||||
|
|||||||
@@ -32,8 +32,8 @@
|
|||||||
},
|
},
|
||||||
"vocabLessonReset": {
|
"vocabLessonReset": {
|
||||||
"title": "Language course: lesson progress",
|
"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).",
|
"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 courses",
|
"loadCourses": "Load enrolled courses",
|
||||||
"selectCourse": "Course",
|
"selectCourse": "Course",
|
||||||
"selectLesson": "Lesson",
|
"selectLesson": "Lesson",
|
||||||
"reset": "Reset lesson for this user",
|
"reset": "Reset lesson for this user",
|
||||||
@@ -41,7 +41,8 @@
|
|||||||
"success": "Lesson progress was reset.",
|
"success": "Lesson progress was reset.",
|
||||||
"error": "Reset failed.",
|
"error": "Reset failed.",
|
||||||
"pickUserFirst": "Select a user first.",
|
"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…"
|
"loadingLessons": "Loading lessons…"
|
||||||
},
|
},
|
||||||
"adultVerification": {
|
"adultVerification": {
|
||||||
|
|||||||
@@ -32,8 +32,8 @@
|
|||||||
},
|
},
|
||||||
"vocabLessonReset": {
|
"vocabLessonReset": {
|
||||||
"title": "Curso de idiomas: progreso de lección",
|
"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).",
|
"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",
|
"loadCourses": "Cargar cursos inscritos",
|
||||||
"selectCourse": "Curso",
|
"selectCourse": "Curso",
|
||||||
"selectLesson": "Lección",
|
"selectLesson": "Lección",
|
||||||
"reset": "Restablecer lección para este usuario",
|
"reset": "Restablecer lección para este usuario",
|
||||||
@@ -41,7 +41,8 @@
|
|||||||
"success": "Se restableció el progreso de la lección.",
|
"success": "Se restableció el progreso de la lección.",
|
||||||
"error": "No se pudo restablecer.",
|
"error": "No se pudo restablecer.",
|
||||||
"pickUserFirst": "Primero elige un usuario.",
|
"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…"
|
"loadingLessons": "Cargando lecciones…"
|
||||||
},
|
},
|
||||||
"adultVerification": {
|
"adultVerification": {
|
||||||
|
|||||||
@@ -50,12 +50,15 @@
|
|||||||
<span>{{ $t('admin.vocabLessonReset.selectCourse') }}</span>
|
<span>{{ $t('admin.vocabLessonReset.selectCourse') }}</span>
|
||||||
<select v-model="selectedVocabCourseId" @change="onVocabCourseChange">
|
<select v-model="selectedVocabCourseId" @change="onVocabCourseChange">
|
||||||
<option value="">{{ $t('admin.vocabLessonReset.selectCourse') }}</option>
|
<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>
|
</select>
|
||||||
</label>
|
</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">
|
<p v-else-if="vocabCoursesAttempted && !loadingVocabCourses" class="vocab-reset__hint">
|
||||||
{{ $t('admin.vocabLessonReset.noCourses') }}
|
{{ $t('admin.vocabLessonReset.noEnrolledCourses') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label v-if="vocabCourseLessons.length" class="edit__field">
|
<label v-if="vocabCourseLessons.length" class="edit__field">
|
||||||
@@ -101,13 +104,34 @@ export default {
|
|||||||
selectedVocabLessonId: '',
|
selectedVocabLessonId: '',
|
||||||
loadingVocabCourses: false,
|
loadingVocabCourses: false,
|
||||||
loadingVocabCourseDetail: 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: {
|
methods: {
|
||||||
clearVocabResetUi() {
|
clearVocabResetUi() {
|
||||||
this.vocabCourses = [];
|
this.vocabCourses = [];
|
||||||
this.vocabCoursesAttempted = false;
|
this.vocabCoursesAttempted = false;
|
||||||
|
this.vocabLoadError = false;
|
||||||
this.selectedVocabCourseId = '';
|
this.selectedVocabCourseId = '';
|
||||||
this.vocabCourseLessons = [];
|
this.vocabCourseLessons = [];
|
||||||
this.selectedVocabLessonId = '';
|
this.selectedVocabLessonId = '';
|
||||||
@@ -137,8 +161,9 @@ export default {
|
|||||||
}
|
}
|
||||||
this.loadingVocabCourses = true;
|
this.loadingVocabCourses = true;
|
||||||
this.vocabCoursesAttempted = true;
|
this.vocabCoursesAttempted = true;
|
||||||
|
this.vocabLoadError = false;
|
||||||
try {
|
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.vocabCourses = Array.isArray(data) ? data : [];
|
||||||
this.selectedVocabCourseId = '';
|
this.selectedVocabCourseId = '';
|
||||||
this.vocabCourseLessons = [];
|
this.vocabCourseLessons = [];
|
||||||
@@ -146,6 +171,7 @@ export default {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[UsersView] vocab courses:', e);
|
console.error('[UsersView] vocab courses:', e);
|
||||||
this.vocabCourses = [];
|
this.vocabCourses = [];
|
||||||
|
this.vocabLoadError = true;
|
||||||
this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonReset.error'));
|
this.$root?.$refs?.messageDialog?.open?.(this.$t('admin.vocabLessonReset.error'));
|
||||||
} finally {
|
} finally {
|
||||||
this.loadingVocabCourses = false;
|
this.loadingVocabCourses = false;
|
||||||
@@ -159,7 +185,7 @@ export default {
|
|||||||
}
|
}
|
||||||
this.loadingVocabCourseDetail = true;
|
this.loadingVocabCourseDetail = true;
|
||||||
try {
|
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 || [];
|
const lessons = data?.lessons || [];
|
||||||
this.vocabCourseLessons = [...lessons].sort((a, b) => {
|
this.vocabCourseLessons = [...lessons].sort((a, b) => {
|
||||||
if (a.weekNumber !== b.weekNumber) {
|
if (a.weekNumber !== b.weekNumber) {
|
||||||
|
|||||||
Reference in New Issue
Block a user