From e8a10e502450e8a8ebe3bbde980764a7f6bbb308 Mon Sep 17 00:00:00 2001 From: Missez Date: Tue, 3 Feb 2026 17:13:30 +0700 Subject: [PATCH] feat: student page and create email verification page. --- frontend_management/layouts/blank.vue | 9 + .../[chapterId]/lessons/[lessonId]/quiz.vue | 110 ++++- .../[chapterId]/lessons/[lessonId]/video.vue | 95 ++++ .../pages/instructor/courses/[id]/index.vue | 435 +++++++++++++++++- frontend_management/pages/verify-email.vue | 137 ++++++ .../services/instructor.service.ts | 116 +++++ 6 files changed, 894 insertions(+), 8 deletions(-) create mode 100644 frontend_management/layouts/blank.vue create mode 100644 frontend_management/pages/verify-email.vue diff --git a/frontend_management/layouts/blank.vue b/frontend_management/layouts/blank.vue new file mode 100644 index 00000000..4812e737 --- /dev/null +++ b/frontend_management/layouts/blank.vue @@ -0,0 +1,9 @@ + + + diff --git a/frontend_management/pages/instructor/courses/[id]/chapters/[chapterId]/lessons/[lessonId]/quiz.vue b/frontend_management/pages/instructor/courses/[id]/chapters/[chapterId]/lessons/[lessonId]/quiz.vue index 72c21c7c..52401d55 100644 --- a/frontend_management/pages/instructor/courses/[id]/chapters/[chapterId]/lessons/[lessonId]/quiz.vue +++ b/frontend_management/pages/instructor/courses/[id]/chapters/[chapterId]/lessons/[lessonId]/quiz.vue @@ -117,6 +117,10 @@ v-model="quizSettings.show_answers_after_completion" label="แสดงเฉลยหลังทำเสร็จ" /> +
+ + + +

การตั้งค่าลำดับบทเรียน

+
+ + + + +
+ +
+
+
+
+ @@ -186,7 +231,7 @@ size="18px" /> - {{ choice.text.th || `ตัวเลือก ${cIndex + 1}` }} + {{ choice.text.th || `ตัวเลือก ${Number(cIndex) + 1}` }}
@@ -306,12 +351,30 @@ const form = ref({ content: { th: '', en: '' } }); +// Prerequisite settings +const prerequisiteSettings = ref({ + prerequisite_lesson_ids: [] as number[] +}); +const savingPrerequisite = ref(false); + +interface LessonOption { + id: number; + label: string; + chapterTitle: string; +} +const allLessonsInCourse = ref([]); + +const availableLessons = computed(() => { + return allLessonsInCourse.value.filter(l => l.id !== lessonId); +}); + const quizSettings = ref({ passing_score: 60, time_limit: 0, shuffle_questions: false, shuffle_choices: false, - show_answers_after_completion: true + show_answers_after_completion: true, + is_skippable: false }); const savingSettings = ref(false); @@ -326,7 +389,8 @@ const saveQuizSettings = async () => { time_limit: quizSettings.value.time_limit, shuffle_questions: quizSettings.value.shuffle_questions, shuffle_choices: quizSettings.value.shuffle_choices, - show_answers_after_completion: quizSettings.value.show_answers_after_completion + show_answers_after_completion: quizSettings.value.show_answers_after_completion, + is_skippable: quizSettings.value.is_skippable }); $q.notify({ type: 'positive', message: response.message, position: 'top' }); } catch (error) { @@ -337,6 +401,24 @@ const saveQuizSettings = async () => { } }; +const savePrerequisiteSettings = async () => { + savingPrerequisite.value = true; + try { + const response = await instructorService.updateLesson(courseId, chapterId, lessonId, { + ...form.value, + prerequisite_lesson_ids: prerequisiteSettings.value.prerequisite_lesson_ids.length > 0 + ? prerequisiteSettings.value.prerequisite_lesson_ids + : null + }); + $q.notify({ type: 'positive', message: response.message || 'บันทึกการตั้งค่าสำเร็จ', position: 'top' }); + } catch (error) { + console.error('Failed to save prerequisite settings:', error); + $q.notify({ type: 'negative', message: (error as any).data?.error?.message || (error as any).data?.message || 'ไม่สามารถบันทึกได้', position: 'top' }); + } finally { + savingPrerequisite.value = false; + } +}; + // Drag and Drop handler (vuedraggable) const onDragEnd = async (event: { oldIndex: number; newIndex: number }) => { const { oldIndex, newIndex } = event; @@ -391,6 +473,25 @@ const fetchLesson = async () => { content: data.content ? { ...data.content } : { th: '', en: '' } }; + // Load prerequisite settings + prerequisiteSettings.value = { + prerequisite_lesson_ids: data.prerequisite_lesson_ids || [] + }; + + // Fetch all lessons in course for prerequisite selection + const courseData = await instructorService.getCourseById(courseId); + const lessonsOptions: LessonOption[] = []; + courseData.chapters.forEach(chapter => { + chapter.lessons.forEach(l => { + lessonsOptions.push({ + id: l.id, + label: l.title.th || l.title.en, + chapterTitle: chapter.title.th || chapter.title.en + }); + }); + }); + allLessonsInCourse.value = lessonsOptions; + // Load quiz settings if (data.quiz) { quizSettings.value = { @@ -398,7 +499,8 @@ const fetchLesson = async () => { time_limit: data.quiz.time_limit || 0, shuffle_questions: data.quiz.shuffle_questions || false, shuffle_choices: data.quiz.shuffle_choices || false, - show_answers_after_completion: data.quiz.show_answers_after_completion !== false + show_answers_after_completion: data.quiz.show_answers_after_completion !== false, + is_skippable: data.quiz.is_skippable || false }; // Load questions from API diff --git a/frontend_management/pages/instructor/courses/[id]/chapters/[chapterId]/lessons/[lessonId]/video.vue b/frontend_management/pages/instructor/courses/[id]/chapters/[chapterId]/lessons/[lessonId]/video.vue index 1f3c1655..b2515f36 100644 --- a/frontend_management/pages/instructor/courses/[id]/chapters/[chapterId]/lessons/[lessonId]/video.vue +++ b/frontend_management/pages/instructor/courses/[id]/chapters/[chapterId]/lessons/[lessonId]/video.vue @@ -148,6 +148,46 @@ + + + +

การตั้งค่าลำดับบทเรียน

+
+ + + + +
+ +
+
+
+ @@ -230,6 +270,24 @@ const form = ref({ content: { th: '', en: '' } }); +// Prerequisite settings +const prerequisiteSettings = ref({ + prerequisite_lesson_ids: [] as number[] +}); +const savingPrerequisite = ref(false); + +interface LessonOption { + id: number; + label: string; + chapterTitle: string; +} +const allLessonsInCourse = ref([]); + +const availableLessons = computed(() => { + // Filter out current lesson from available options + return allLessonsInCourse.value.filter(l => l.id !== lessonId); +}); + const selectedVideo = ref(null); const uploadingVideo = ref(false); const videoInput = ref(null); @@ -247,6 +305,25 @@ const fetchLesson = async () => { title: { ...data.title }, content: data.content ? { ...data.content } : { th: '', en: '' } }; + + // Load prerequisite settings + prerequisiteSettings.value = { + prerequisite_lesson_ids: data.prerequisite_lesson_ids || [] + }; + + // Fetch all lessons in course for prerequisite selection + const courseData = await instructorService.getCourseById(courseId); + const lessonsOptions: LessonOption[] = []; + courseData.chapters.forEach(chapter => { + chapter.lessons.forEach(l => { + lessonsOptions.push({ + id: l.id, + label: l.title.th || l.title.en, + chapterTitle: chapter.title.th || chapter.title.en + }); + }); + }); + allLessonsInCourse.value = lessonsOptions; } catch (error) { console.error('Failed to fetch lesson:', error); $q.notify({ type: 'negative', message: 'ไม่สามารถโหลดข้อมูลได้', position: 'top' }); @@ -255,6 +332,24 @@ const fetchLesson = async () => { } }; +const savePrerequisiteSettings = async () => { + savingPrerequisite.value = true; + try { + const response = await instructorService.updateLesson(courseId, chapterId, lessonId, { + ...form.value, + prerequisite_lesson_ids: prerequisiteSettings.value.prerequisite_lesson_ids.length > 0 + ? prerequisiteSettings.value.prerequisite_lesson_ids + : null + }); + $q.notify({ type: 'positive', message: response.message || 'บันทึกการตั้งค่าสำเร็จ', position: 'top' }); + } catch (error) { + console.error('Failed to save prerequisite settings:', error); + $q.notify({ type: 'negative', message: (error as any).data?.error?.message || (error as any).data?.message || 'ไม่สามารถบันทึกได้', position: 'top' }); + } finally { + savingPrerequisite.value = false; + } +}; + const saveLesson = async () => { saving.value = true; try { diff --git a/frontend_management/pages/instructor/courses/[id]/index.vue b/frontend_management/pages/instructor/courses/[id]/index.vue index bbd01ff2..ad012b7b 100644 --- a/frontend_management/pages/instructor/courses/[id]/index.vue +++ b/frontend_management/pages/instructor/courses/[id]/index.vue @@ -181,9 +181,142 @@ -
+ +
+ + +
{{ studentsPagination.total }}
+
ผู้เรียนทั้งหมด
+
+
+ + +
{{ completedStudentsCount }}
+
จบหลักสูตร
+
+
+
+ + + + +
+ + + + + + +
+
+
+ +
+ +
+ +
-

ยังไม่มีผู้เรียนในหลักสูตรนี้

+

{{ studentSearch || studentStatusFilter !== 'all' ? 'ไม่พบผู้เรียนที่ค้นหา' : 'ยังไม่มีผู้เรียนในหลักสูตรนี้' }}

+
+ +
+ + + + + + + + {{ student.username.charAt(0).toUpperCase() }} + + + + + + {{ student.first_name }} {{ student.last_name }} + + {{ student.email }} + + + + ความคืบหน้า +
+ + {{ student.progress_percentage }}% +
+
+ + + + {{ getStudentStatusLabel(student.status) }} + + + ลงทะเบียน {{ formatEnrollDate(student.enrolled_at) }} + + +
+
+
+ + +
+ +
@@ -510,6 +643,132 @@ + + + + + +
+
+ + + {{ studentDetail.student.username.charAt(0).toUpperCase() }} + +
+

{{ studentDetail.student.first_name }} {{ studentDetail.student.last_name }}

+

{{ studentDetail.student.email }}

+
+
+ +
+
+ + +
+ +
+ + + + + + +
+ ความคืบหน้าทั้งหมด +
+ + {{ getStudentStatusLabel(studentDetail.enrollment.status) }} + + {{ studentDetail.enrollment.progress_percentage }}% +
+
+ +
+ เรียนจบ {{ studentDetail.total_completed_lessons }} / {{ studentDetail.total_lessons }} บทเรียน + ลงทะเบียน {{ formatEnrollDate(studentDetail.enrollment.enrolled_at) }} +
+
+
+ + +
+ + + + + + + + + + + {{ lesson.lesson_title.th }} + + + {{ getLessonTypeLabel(lesson.lesson_type) }} + + + + + + เสร็จ {{ formatCompletedDate(lesson.completed_at) }} + + + + + + + + +
+
+
+
+
@@ -523,7 +782,9 @@ import { type AnnouncementResponse, type CreateAnnouncementRequest, type CourseInstructorResponse, - type SearchInstructorResult + type SearchInstructorResult, + type EnrolledStudentResponse, + type StudentDetailData } from '~/services/instructor.service'; definePageMeta({ @@ -567,6 +828,34 @@ const loadingSearch = ref(false); const addingInstructor = ref(false); const searchQuery = ref(''); +// Students data +const students = ref([]); +const loadingStudents = ref(false); +const studentsPagination = ref({ + page: 1, + limit: 10, + total: 0 +}); +const studentSearch = ref(''); +const studentStatusFilter = ref('all'); +const completedStudentsCount = ref(0); +const statusFilterOptions = [ + { label: 'สถานะทั้งหมด', value: 'all' }, + { label: 'กำลังเรียน', value: 'ENROLLED' }, + { label: 'เรียนจบแล้ว', value: 'COMPLETED' } +]; +const limitOptions = [ + { label: '5 รายการ', value: 5 }, + { label: '10 รายการ', value: 10 }, + { label: '20 รายการ', value: 20 }, + { label: '50 รายการ', value: 50 } +]; + +// Student Detail Modal +const showStudentDetailModal = ref(false); +const loadingStudentDetail = ref(false); +const studentDetail = ref(null); + // Attachment handling const fileInputRef = ref(null); const uploadingAttachment = ref(false); @@ -592,6 +881,29 @@ const isPrimaryInstructor = computed(() => { return myInstructorRecord?.is_primary === true; }); +// Filtered students (client-side filtering) +const filteredStudents = computed(() => { + let result = students.value; + + // Filter by search query + if (studentSearch.value) { + const query = studentSearch.value.toLowerCase(); + result = result.filter(s => + s.first_name.toLowerCase().includes(query) || + s.last_name.toLowerCase().includes(query) || + s.email.toLowerCase().includes(query) || + s.username.toLowerCase().includes(query) + ); + } + + // Filter by status + if (studentStatusFilter.value !== 'all') { + result = result.filter(s => s.status === studentStatusFilter.value); + } + + return result; +}); + // Methods const fetchCourse = async () => { loading.value = true; @@ -704,6 +1016,119 @@ const getSortedLessons = (chapter: ChapterResponse) => { return chapter.lessons.slice().sort((a, b) => a.sort_order - b.sort_order); }; +const fetchStudents = async (page: number = 1) => { + loadingStudents.value = true; + try { + const courseId = parseInt(route.params.id as string); + const response = await instructorService.getEnrolledStudents( + courseId, + page, + studentsPagination.value.limit, + studentSearch.value || undefined, + studentStatusFilter.value + ); + students.value = response.data; + studentsPagination.value = { + page: response.page, + limit: response.limit, + total: response.total + }; + + // Count completed students (from current response) + completedStudentsCount.value = students.value.filter(s => s.status === 'COMPLETED').length; + } catch (error) { + console.error('Failed to fetch students:', error); + $q.notify({ type: 'negative', message: 'ไม่สามารถโหลดข้อมูลผู้เรียนได้', position: 'top' }); + } finally { + loadingStudents.value = false; + } +}; + +const handleStudentSearch = () => { + studentsPagination.value.page = 1; + fetchStudents(1); +}; + +const handleLimitChange = () => { + studentsPagination.value.page = 1; + fetchStudents(1); +}; + +const openStudentDetail = async (studentId: number) => { + const courseId = parseInt(route.params.id as string); + showStudentDetailModal.value = true; + loadingStudentDetail.value = true; + studentDetail.value = null; + + try { + studentDetail.value = await instructorService.getStudentDetail(courseId, studentId); + } catch (error) { + console.error('Failed to fetch student detail:', error); + $q.notify({ type: 'negative', message: 'ไม่สามารถโหลดข้อมูลผู้เรียนได้', position: 'top' }); + showStudentDetailModal.value = false; + } finally { + loadingStudentDetail.value = false; + } +}; + +const getLessonTypeIcon = (type: string) => { + const icons: Record = { + VIDEO: 'play_circle', + DOCUMENT: 'description', + QUIZ: 'quiz', + ASSIGNMENT: 'assignment' + }; + return icons[type] || 'article'; +}; + +const getLessonTypeLabel = (type: string) => { + const labels: Record = { + VIDEO: 'วิดีโอ', + DOCUMENT: 'เอกสาร', + QUIZ: 'แบบทดสอบ', + ASSIGNMENT: 'แบบฝึกหัด' + }; + return labels[type] || type; +}; + +const formatVideoTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; +}; + +const formatCompletedDate = (dateStr: string | null) => { + if (!dateStr) return '-'; + const date = new Date(dateStr); + return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short' }); +}; + +const getStudentStatusColor = (status: string) => { + const colors: Record = { + ENROLLED: 'blue', + COMPLETED: 'green', + DROPPED: 'red' + }; + return colors[status] || 'grey'; +}; + +const getStudentStatusLabel = (status: string) => { + const labels: Record = { + ENROLLED: 'กำลังเรียน', + COMPLETED: 'เรียนจบแล้ว', + DROPPED: 'ยกเลิก' + }; + return labels[status] || status; +}; + +const formatEnrollDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('th-TH', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); +}; + const getLessonIcon = (type: string) => { const icons: Record = { VIDEO: 'play_circle', @@ -1112,12 +1537,14 @@ const formatFileSize = (bytes: number): string => { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; -// Watch for tab change to load announcements +// Watch for tab change to load data watch(activeTab, (newTab) => { if (newTab === 'announcements' && announcements.value.length === 0) { fetchAnnouncements(); } else if (newTab === 'instructors' && instructors.value.length === 0) { fetchInstructors(); + } else if (newTab === 'students' && students.value.length === 0) { + fetchStudents(); } }); diff --git a/frontend_management/pages/verify-email.vue b/frontend_management/pages/verify-email.vue new file mode 100644 index 00000000..f2363387 --- /dev/null +++ b/frontend_management/pages/verify-email.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/frontend_management/services/instructor.service.ts b/frontend_management/services/instructor.service.ts index b9fd1070..358f0488 100644 --- a/frontend_management/services/instructor.service.ts +++ b/frontend_management/services/instructor.service.ts @@ -80,6 +80,88 @@ export interface SearchInstructorsResponse { data: SearchInstructorResult[]; } +export interface EnrolledStudentResponse { + user_id: number; + username: string; + email: string; + first_name: string; + last_name: string; + avatar_url: string | null; + enrolled_at: string; + progress_percentage: number; + status: 'ENROLLED' | 'COMPLETED' | 'DROPPED'; +} + +export interface EnrolledStudentsListResponse { + code: number; + message: string; + data: EnrolledStudentResponse[]; + total: number; + page: number; + limit: number; +} + +export interface StudentSearchResult { + user_id: number; + first_name: string; + last_name: string; + email: string; +} + +export interface StudentSearchResponse { + code: number; + message: string; + data: StudentSearchResult[]; +} + +// Student Detail with Progress interfaces +export interface StudentDetailLesson { + lesson_id: number; + lesson_title: { th: string; en: string }; + lesson_type: string; + sort_order: number; + is_completed: boolean; + completed_at: string | null; + video_progress_seconds: number; + video_duration_seconds: number; + video_progress_percentage: number; + last_watched_at: string | null; +} + +export interface StudentDetailChapter { + chapter_id: number; + chapter_title: { th: string; en: string }; + sort_order: number; + lessons: StudentDetailLesson[]; + completed_lessons: number; + total_lessons: number; +} + +export interface StudentDetailData { + student: { + user_id: number; + username: string; + email: string; + first_name: string; + last_name: string; + avatar_url: string | null; + }; + enrollment: { + status: string; + progress_percentage: number; + enrolled_at: string; + }; + chapters: StudentDetailChapter[]; + total_completed_lessons: number; + total_lessons: number; +} + +export interface StudentDetailResponse { + code: number; + message: string; + data: StudentDetailData; +} + // Helper function to get auth token from cookie const getAuthToken = (): string => { const tokenCookie = useCookie('token'); @@ -212,6 +294,37 @@ export const instructorService = { return await authRequest>(`/api/instructors/courses/send-review/${courseId}`, { method: 'POST' }); }, + async getEnrolledStudents( + courseId: number, + page: number = 1, + limit: number = 10, + search?: string, + status?: string + ): Promise { + let url = `/api/instructors/courses/${courseId}/students?page=${page}&limit=${limit}`; + if (search) { + url += `&search=${encodeURIComponent(search)}`; + } + if (status && status !== 'all') { + url += `&status=${status}`; + } + return await authRequest(url); + }, + + async searchStudentsInCourse(courseId: number, query: string, limit: number = 5): Promise { + const response = await authRequest( + `/api/instructors/courses/${courseId}/students/search?query=${encodeURIComponent(query)}&limit=${limit}` + ); + return response.data; + }, + + async getStudentDetail(courseId: number, studentId: number): Promise { + const response = await authRequest( + `/api/instructors/courses/${courseId}/students/${studentId}` + ); + return response.data; + }, + async getChapters(courseId: number): Promise { // Get chapters from course detail endpoint const response = await authRequest<{ code: number; data: { chapters: ChapterResponse[] } }>( @@ -515,6 +628,7 @@ export interface QuizResponse { shuffle_questions: boolean; shuffle_choices: boolean; show_answers_after_completion: boolean; + is_skippable: boolean; created_at?: string; updated_at?: string; questions?: QuizQuestionResponse[]; @@ -528,6 +642,7 @@ export interface UpdateQuizSettingsRequest { shuffle_questions: boolean; shuffle_choices: boolean; show_answers_after_completion: boolean; + is_skippable: boolean; } export interface CreateChapterRequest { @@ -562,6 +677,7 @@ export interface CreateLessonRequest { export interface UpdateLessonRequest { title: { th: string; en: string }; content?: { th: string; en: string } | null; + prerequisite_lesson_ids?: number[] | null; } export interface AttachmentResponse {