feat: student page and create email verification page.

This commit is contained in:
Missez 2026-02-03 17:13:30 +07:00
parent 52d86400b3
commit e8a10e5024
6 changed files with 894 additions and 8 deletions

View file

@ -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();
}
});