feat: student page and create email verification page.
This commit is contained in:
parent
52d86400b3
commit
e8a10e5024
6 changed files with 894 additions and 8 deletions
|
|
@ -117,6 +117,10 @@
|
|||
v-model="quizSettings.show_answers_after_completion"
|
||||
label="แสดงเฉลยหลังทำเสร็จ"
|
||||
/>
|
||||
<q-toggle
|
||||
v-model="quizSettings.is_skippable"
|
||||
label="ทำแบบทดสอบข้ามได้"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-4 text-right">
|
||||
<q-btn
|
||||
|
|
@ -130,6 +134,47 @@
|
|||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- Prerequisite Settings -->
|
||||
<q-card class="mb-6">
|
||||
<q-card-section>
|
||||
<h3 class="text-lg font-semibold mb-4">การตั้งค่าลำดับบทเรียน</h3>
|
||||
<div class="space-y-4">
|
||||
<q-select
|
||||
v-model="prerequisiteSettings.prerequisite_lesson_ids"
|
||||
:options="availableLessons"
|
||||
option-value="id"
|
||||
option-label="label"
|
||||
emit-value
|
||||
map-options
|
||||
multiple
|
||||
use-chips
|
||||
filled
|
||||
label="บทเรียนที่ต้องเรียนก่อน"
|
||||
hint="เลือกบทเรียนที่ต้องเรียนก่อนหน้าจึงจะปลดล็อคบทเรียนนี้"
|
||||
>
|
||||
<template v-slot:option="{ opt, itemProps }">
|
||||
<q-item v-bind="itemProps">
|
||||
<q-item-section>
|
||||
<q-item-label>{{ opt.label }}</q-item-label>
|
||||
<q-item-label caption>{{ opt.chapterTitle }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
</q-select>
|
||||
|
||||
<div class="text-right">
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="บันทึกการตั้งค่า"
|
||||
icon="save"
|
||||
:loading="savingPrerequisite"
|
||||
@click="savePrerequisiteSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- Questions -->
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
|
|
@ -186,7 +231,7 @@
|
|||
size="18px"
|
||||
/>
|
||||
<span :class="{ 'text-green-600 font-medium': choice.is_correct }">
|
||||
{{ choice.text.th || `ตัวเลือก ${cIndex + 1}` }}
|
||||
{{ choice.text.th || `ตัวเลือก ${Number(cIndex) + 1}` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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<LessonOption[]>([]);
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -148,6 +148,46 @@
|
|||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<!-- Prerequisite Settings Section -->
|
||||
<q-separator />
|
||||
<q-card-section>
|
||||
<h3 class="text-lg font-semibold mb-4">การตั้งค่าลำดับบทเรียน</h3>
|
||||
<div class="space-y-4">
|
||||
<q-select
|
||||
v-model="prerequisiteSettings.prerequisite_lesson_ids"
|
||||
:options="availableLessons"
|
||||
option-value="id"
|
||||
option-label="label"
|
||||
emit-value
|
||||
map-options
|
||||
multiple
|
||||
use-chips
|
||||
filled
|
||||
label="บทเรียนที่ต้องเรียนก่อน"
|
||||
hint="เลือกบทเรียนที่ต้องเรียนก่อนหน้าจึงจะปลดล็อคบทเรียนนี้"
|
||||
>
|
||||
<template v-slot:option="{ opt, itemProps }">
|
||||
<q-item v-bind="itemProps">
|
||||
<q-item-section>
|
||||
<q-item-label>{{ opt.label }}</q-item-label>
|
||||
<q-item-label caption>{{ opt.chapterTitle }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
</q-select>
|
||||
|
||||
<div class="text-right">
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="บันทึกการตั้งค่า"
|
||||
icon="save"
|
||||
:loading="savingPrerequisite"
|
||||
@click="savePrerequisiteSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<!-- Attachments Section -->
|
||||
<q-separator />
|
||||
<q-card-section>
|
||||
|
|
@ -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<LessonOption[]>([]);
|
||||
|
||||
const availableLessons = computed(() => {
|
||||
// Filter out current lesson from available options
|
||||
return allLessonsInCourse.value.filter(l => l.id !== lessonId);
|
||||
});
|
||||
|
||||
const selectedVideo = ref<File | null>(null);
|
||||
const uploadingVideo = ref(false);
|
||||
const videoInput = ref<HTMLInputElement | null>(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 {
|
||||
|
|
|
|||
|
|
@ -181,9 +181,142 @@
|
|||
|
||||
<!-- Students Tab -->
|
||||
<q-tab-panel name="students" class="p-6">
|
||||
<div class="text-center py-10 text-gray-500">
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<q-card flat bordered class="rounded-lg">
|
||||
<q-card-section class="text-center py-6">
|
||||
<div class="text-4xl font-bold text-primary">{{ studentsPagination.total }}</div>
|
||||
<div class="text-gray-500 mt-1">ผู้เรียนทั้งหมด</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card flat bordered class="rounded-lg">
|
||||
<q-card-section class="text-center py-6">
|
||||
<div class="text-4xl font-bold text-green-600">{{ completedStudentsCount }}</div>
|
||||
<div class="text-gray-500 mt-1">จบหลักสูตร</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter -->
|
||||
<q-card flat bordered class="rounded-lg mb-6">
|
||||
<q-card-section>
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<q-input
|
||||
v-model="studentSearch"
|
||||
outlined
|
||||
dense
|
||||
placeholder="ค้นหาผู้เรียน..."
|
||||
class="flex-1"
|
||||
debounce="300"
|
||||
@update:model-value="handleStudentSearch"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="search" />
|
||||
</template>
|
||||
<template v-slot:append v-if="studentSearch">
|
||||
<q-icon name="close" class="cursor-pointer" @click="studentSearch = ''; handleStudentSearch()" />
|
||||
</template>
|
||||
</q-input>
|
||||
<q-select
|
||||
v-model="studentStatusFilter"
|
||||
outlined
|
||||
dense
|
||||
:options="statusFilterOptions"
|
||||
option-value="value"
|
||||
option-label="label"
|
||||
emit-value
|
||||
map-options
|
||||
class="w-full md:w-40"
|
||||
@update:model-value="handleStudentSearch"
|
||||
/>
|
||||
<q-select
|
||||
v-model="studentsPagination.limit"
|
||||
outlined
|
||||
dense
|
||||
:options="limitOptions"
|
||||
option-value="value"
|
||||
option-label="label"
|
||||
emit-value
|
||||
map-options
|
||||
class="w-full md:w-32"
|
||||
@update:model-value="handleLimitChange"
|
||||
/>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<div v-if="loadingStudents" class="flex justify-center py-10">
|
||||
<q-spinner color="primary" size="40px" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredStudents.length === 0" class="text-center py-10 text-gray-500">
|
||||
<q-icon name="people" size="60px" color="grey-4" class="mb-4" />
|
||||
<p>ยังไม่มีผู้เรียนในหลักสูตรนี้</p>
|
||||
<p>{{ studentSearch || studentStatusFilter !== 'all' ? 'ไม่พบผู้เรียนที่ค้นหา' : 'ยังไม่มีผู้เรียนในหลักสูตรนี้' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Students Table -->
|
||||
<q-card flat bordered class="rounded-lg">
|
||||
<q-list separator>
|
||||
<q-item
|
||||
v-for="student in filteredStudents"
|
||||
:key="student.user_id"
|
||||
class="py-4 cursor-pointer hover:bg-gray-50 transition-colors"
|
||||
clickable
|
||||
@click="openStudentDetail(student.user_id)"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-avatar size="50px" color="primary" text-color="white">
|
||||
<img v-if="student.avatar_url" :src="student.avatar_url" />
|
||||
<span v-else>{{ student.username.charAt(0).toUpperCase() }}</span>
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<q-item-label class="text-base font-medium">
|
||||
{{ student.first_name }} {{ student.last_name }}
|
||||
</q-item-label>
|
||||
<q-item-label caption>{{ student.email }}</q-item-label>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<q-item-label caption class="mb-1">ความคืบหน้า</q-item-label>
|
||||
<div class="flex items-center gap-2">
|
||||
<q-linear-progress
|
||||
:value="student.progress_percentage / 100"
|
||||
color="primary"
|
||||
track-color="grey-3"
|
||||
class="flex-1"
|
||||
rounded
|
||||
size="8px"
|
||||
/>
|
||||
<span class="text-sm text-gray-600 w-12 text-right">{{ student.progress_percentage }}%</span>
|
||||
</div>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section side>
|
||||
<q-badge :color="getStudentStatusColor(student.status)">
|
||||
{{ getStudentStatusLabel(student.status) }}
|
||||
</q-badge>
|
||||
<q-item-label caption class="mt-1">
|
||||
ลงทะเบียน {{ formatEnrollDate(student.enrolled_at) }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="studentsPagination.total > studentsPagination.limit" class="flex justify-center mt-4">
|
||||
<q-pagination
|
||||
v-model="studentsPagination.page"
|
||||
:max="Math.ceil(studentsPagination.total / studentsPagination.limit)"
|
||||
:max-pages="6"
|
||||
direction-links
|
||||
boundary-links
|
||||
@update:model-value="fetchStudents"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</q-tab-panel>
|
||||
|
||||
|
|
@ -510,6 +643,132 @@
|
|||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Student Progress Detail Modal -->
|
||||
<q-dialog v-model="showStudentDetailModal" maximized transition-show="slide-up" transition-hide="slide-down">
|
||||
<q-card class="bg-gray-50">
|
||||
<!-- Header -->
|
||||
<q-card-section class="bg-white shadow-sm">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center gap-4" v-if="studentDetail">
|
||||
<q-avatar size="60px" color="primary" text-color="white">
|
||||
<img v-if="studentDetail.student.avatar_url" :src="studentDetail.student.avatar_url" />
|
||||
<span v-else>{{ studentDetail.student.username.charAt(0).toUpperCase() }}</span>
|
||||
</q-avatar>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">{{ studentDetail.student.first_name }} {{ studentDetail.student.last_name }}</h2>
|
||||
<p class="text-gray-500">{{ studentDetail.student.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<q-btn icon="close" flat round dense v-close-popup />
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loadingStudentDetail" class="flex justify-center items-center py-20">
|
||||
<q-spinner color="primary" size="50px" />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<q-card-section v-else-if="studentDetail" class="q-pa-lg">
|
||||
<!-- Progress Summary Bar -->
|
||||
<q-card flat bordered class="mb-6 rounded-lg bg-white">
|
||||
<q-card-section>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-gray-600">ความคืบหน้าทั้งหมด</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<q-badge :color="getStudentStatusColor(studentDetail.enrollment.status)">
|
||||
{{ getStudentStatusLabel(studentDetail.enrollment.status) }}
|
||||
</q-badge>
|
||||
<span class="font-semibold text-lg">{{ studentDetail.enrollment.progress_percentage }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<q-linear-progress
|
||||
:value="studentDetail.enrollment.progress_percentage / 100"
|
||||
color="primary"
|
||||
track-color="grey-3"
|
||||
rounded
|
||||
size="12px"
|
||||
/>
|
||||
<div class="flex justify-between mt-2 text-sm text-gray-500">
|
||||
<span>เรียนจบ {{ studentDetail.total_completed_lessons }} / {{ studentDetail.total_lessons }} บทเรียน</span>
|
||||
<span>ลงทะเบียน {{ formatEnrollDate(studentDetail.enrollment.enrolled_at) }}</span>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- Chapters & Lessons -->
|
||||
<div class="space-y-4">
|
||||
<q-expansion-item
|
||||
v-for="chapter in studentDetail.chapters"
|
||||
:key="chapter.chapter_id"
|
||||
group="chapters"
|
||||
class="bg-white rounded-lg shadow-sm overflow-hidden"
|
||||
expand-separator
|
||||
dense
|
||||
>
|
||||
<template v-slot:header>
|
||||
<q-item-section avatar>
|
||||
<q-circular-progress
|
||||
:value="chapter.total_lessons > 0 ? (chapter.completed_lessons / chapter.total_lessons) * 100 : 0"
|
||||
size="45px"
|
||||
:thickness="0.15"
|
||||
color="primary"
|
||||
track-color="grey-3"
|
||||
show-value
|
||||
class="text-primary"
|
||||
>
|
||||
<span class="text-xs">{{ chapter.completed_lessons }}/{{ chapter.total_lessons }}</span>
|
||||
</q-circular-progress>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label class="font-medium">{{ chapter.chapter_title.th }}</q-item-label>
|
||||
<q-item-label caption>{{ chapter.completed_lessons }} จาก {{ chapter.total_lessons }} บทเรียน</q-item-label>
|
||||
</q-item-section>
|
||||
</template>
|
||||
|
||||
<!-- Lessons List -->
|
||||
<q-list separator class="bg-gray-50">
|
||||
<q-item v-for="lesson in chapter.lessons" :key="lesson.lesson_id" class="py-3">
|
||||
<q-item-section avatar>
|
||||
<q-icon
|
||||
:name="lesson.is_completed ? 'check_circle' : 'radio_button_unchecked'"
|
||||
:color="lesson.is_completed ? 'green' : 'grey'"
|
||||
size="24px"
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ lesson.lesson_title.th }}</q-item-label>
|
||||
<q-item-label caption class="flex items-center gap-2">
|
||||
<q-icon :name="getLessonTypeIcon(lesson.lesson_type)" size="14px" />
|
||||
{{ getLessonTypeLabel(lesson.lesson_type) }}
|
||||
<template v-if="lesson.lesson_type === 'VIDEO' && lesson.video_duration_seconds > 0">
|
||||
• {{ formatVideoTime(lesson.video_progress_seconds) }} / {{ formatVideoTime(lesson.video_duration_seconds) }}
|
||||
({{ lesson.video_progress_percentage }}%)
|
||||
</template>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side v-if="lesson.is_completed">
|
||||
<q-item-label caption class="text-green-600">
|
||||
เสร็จ {{ formatCompletedDate(lesson.completed_at) }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side v-else-if="lesson.lesson_type === 'VIDEO' && lesson.video_progress_percentage > 0">
|
||||
<q-linear-progress
|
||||
:value="lesson.video_progress_percentage / 100"
|
||||
color="primary"
|
||||
size="6px"
|
||||
class="w-20"
|
||||
rounded
|
||||
/>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-expansion-item>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -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<EnrolledStudentResponse[]>([]);
|
||||
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<StudentDetailData | null>(null);
|
||||
|
||||
// Attachment handling
|
||||
const fileInputRef = ref<HTMLInputElement | null>(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<string, string> = {
|
||||
VIDEO: 'play_circle',
|
||||
DOCUMENT: 'description',
|
||||
QUIZ: 'quiz',
|
||||
ASSIGNMENT: 'assignment'
|
||||
};
|
||||
return icons[type] || 'article';
|
||||
};
|
||||
|
||||
const getLessonTypeLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
ENROLLED: 'blue',
|
||||
COMPLETED: 'green',
|
||||
DROPPED: 'red'
|
||||
};
|
||||
return colors[status] || 'grey';
|
||||
};
|
||||
|
||||
const getStudentStatusLabel = (status: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
137
frontend_management/pages/verify-email.vue
Normal file
137
frontend_management/pages/verify-email.vue
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-indigo-50 to-blue-100 p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<q-card class="rounded-2xl shadow-xl">
|
||||
<q-card-section class="text-center py-10 px-8">
|
||||
<!-- Loading State -->
|
||||
<template v-if="loading">
|
||||
<q-spinner color="primary" size="60px" class="mb-6" />
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-2">กำลังยืนยันอีเมล...</h1>
|
||||
<p class="text-gray-500">กรุณารอสักครู่</p>
|
||||
</template>
|
||||
|
||||
<!-- Success State -->
|
||||
<template v-else-if="verified">
|
||||
<div class="w-20 h-20 rounded-full bg-green-100 flex items-center justify-center mx-auto mb-6">
|
||||
<q-icon name="check_circle" size="60px" color="positive" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-2">ยืนยันอีเมลสำเร็จ!</h1>
|
||||
<p class="text-gray-500 mb-6">{{ message || 'อีเมลของคุณได้รับการยืนยันเรียบร้อยแล้ว' }}</p>
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="กลับสู่หน้าหลัก"
|
||||
class="px-8"
|
||||
@click="navigateTo('/instructor')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Error State -->
|
||||
<template v-else-if="error">
|
||||
<div class="w-20 h-20 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-6">
|
||||
<q-icon name="error" size="60px" color="negative" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-2">ยืนยันอีเมลไม่สำเร็จ</h1>
|
||||
<p class="text-gray-500 mb-6">{{ message || 'ลิงก์ยืนยันอีเมลไม่ถูกต้องหรือหมดอายุ' }}</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="เข้าสู่ระบบ"
|
||||
class="px-8"
|
||||
@click="navigateTo('/login')"
|
||||
/>
|
||||
<q-btn
|
||||
flat
|
||||
color="grey-7"
|
||||
label="ขอลิงก์ใหม่"
|
||||
@click="resendVerification"
|
||||
:loading="resending"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- No Token State -->
|
||||
<template v-else>
|
||||
<div class="w-20 h-20 rounded-full bg-orange-100 flex items-center justify-center mx-auto mb-6">
|
||||
<q-icon name="warning" size="60px" color="warning" />
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold text-gray-800 mb-2">ไม่พบ Token</h1>
|
||||
<p class="text-gray-500 mb-6">ไม่พบ Token สำหรับยืนยันอีเมล กรุณาตรวจสอบลิงก์อีกครั้ง</p>
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="กลับสู่หน้าหลัก"
|
||||
class="px-8"
|
||||
@click="navigateTo('/')"
|
||||
/>
|
||||
</template>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { authService } from '~/services/auth.service';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'blank'
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const loading = ref(false);
|
||||
const verified = ref(false);
|
||||
const error = ref(false);
|
||||
const message = ref('');
|
||||
const resending = ref(false);
|
||||
|
||||
const verifyEmail = async (token: string) => {
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
verified.value = false;
|
||||
|
||||
try {
|
||||
const response = await authService.verifyEmail(token);
|
||||
verified.value = true;
|
||||
message.value = response.message || '';
|
||||
} catch (err: any) {
|
||||
error.value = true;
|
||||
message.value = err.data?.message || err.data?.error?.message || 'ลิงก์ยืนยันอีเมลไม่ถูกต้องหรือหมดอายุ';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resendVerification = async () => {
|
||||
resending.value = true;
|
||||
try {
|
||||
await authService.sendVerifyEmail();
|
||||
message.value = 'ส่งอีเมลยืนยันใหม่แล้ว กรุณาตรวจสอบอีเมลของคุณ';
|
||||
error.value = false;
|
||||
} catch (err: any) {
|
||||
message.value = err.data?.message || 'กรุณาเข้าสู่ระบบก่อนขอลิงก์ยืนยันใหม่';
|
||||
} finally {
|
||||
resending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const token = route.query.token as string;
|
||||
if (token) {
|
||||
verifyEmail(token);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bg-green-100 {
|
||||
background-color: #dcfce7;
|
||||
}
|
||||
|
||||
.bg-red-100 {
|
||||
background-color: #fee2e2;
|
||||
}
|
||||
|
||||
.bg-orange-100 {
|
||||
background-color: #ffedd5;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue