447 lines
16 KiB
Vue
447 lines
16 KiB
Vue
<template>
|
|
<div>
|
|
<!-- 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">{{ pagination.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">{{ completedCount }}</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="search"
|
|
outlined
|
|
dense
|
|
placeholder="ค้นหาผู้เรียน..."
|
|
class="flex-1"
|
|
debounce="600"
|
|
@update:model-value="handleSearch"
|
|
>
|
|
<template v-slot:prepend>
|
|
<q-icon name="search" />
|
|
</template>
|
|
<template v-slot:append v-if="search">
|
|
<q-icon name="close" class="cursor-pointer" @click="search = ''; handleSearch()" />
|
|
</template>
|
|
</q-input>
|
|
<q-select
|
|
v-model="statusFilter"
|
|
outlined
|
|
dense
|
|
:options="statusFilterOptions"
|
|
option-value="value"
|
|
option-label="label"
|
|
emit-value
|
|
map-options
|
|
class="w-full md:w-40"
|
|
@update:model-value="handleSearch"
|
|
/>
|
|
<q-select
|
|
v-model="pagination.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="loading" 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>{{ search || statusFilter !== '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="pagination.total > pagination.limit" class="flex justify-center mt-4">
|
|
<q-pagination
|
|
v-model="pagination.page"
|
|
:max="Math.ceil(pagination.total / pagination.limit)"
|
|
:max-pages="6"
|
|
direction-links
|
|
boundary-links
|
|
@update:model-value="fetchStudents"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Student Progress Detail Modal -->
|
|
<q-dialog v-model="showDetailModal" 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="loadingDetail" 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>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useQuasar } from 'quasar';
|
|
import {
|
|
instructorService,
|
|
type EnrolledStudentResponse,
|
|
type StudentDetailData
|
|
} from '~/services/instructor.service';
|
|
|
|
interface Props {
|
|
courseId: number;
|
|
}
|
|
|
|
const props = defineProps<Props>();
|
|
const $q = useQuasar();
|
|
|
|
// State
|
|
const students = ref<EnrolledStudentResponse[]>([]);
|
|
const loading = ref(false);
|
|
const search = ref('');
|
|
const statusFilter = ref('all');
|
|
const completedCount = ref(0);
|
|
const pagination = ref({
|
|
page: 1,
|
|
limit: 20,
|
|
total: 0
|
|
});
|
|
|
|
// Student Detail Modal
|
|
const showDetailModal = ref(false);
|
|
const loadingDetail = ref(false);
|
|
const studentDetail = ref<StudentDetailData | null>(null);
|
|
|
|
// Options
|
|
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 }
|
|
];
|
|
|
|
// Computed
|
|
const filteredStudents = computed(() => {
|
|
let result = students.value;
|
|
|
|
if (search.value) {
|
|
const query = search.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)
|
|
);
|
|
}
|
|
|
|
if (statusFilter.value !== 'all') {
|
|
result = result.filter(s => s.status === statusFilter.value);
|
|
}
|
|
|
|
return result;
|
|
});
|
|
|
|
// Methods
|
|
const fetchStudents = async (page: number = 1) => {
|
|
loading.value = true;
|
|
try {
|
|
const response = await instructorService.getEnrolledStudents(
|
|
props.courseId,
|
|
page,
|
|
pagination.value.limit,
|
|
search.value,
|
|
statusFilter.value
|
|
);
|
|
students.value = response.data;
|
|
pagination.value = {
|
|
page: response.page,
|
|
limit: response.limit,
|
|
total: response.total
|
|
};
|
|
completedCount.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 {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const handleSearch = () => {
|
|
pagination.value.page = 1;
|
|
fetchStudents(1);
|
|
};
|
|
|
|
const handleLimitChange = () => {
|
|
pagination.value.page = 1;
|
|
fetchStudents(1);
|
|
};
|
|
|
|
const openStudentDetail = async (studentId: number) => {
|
|
showDetailModal.value = true;
|
|
loadingDetail.value = true;
|
|
studentDetail.value = null;
|
|
|
|
try {
|
|
studentDetail.value = await instructorService.getStudentDetail(props.courseId, studentId);
|
|
} catch (error) {
|
|
console.error('Failed to fetch student detail:', error);
|
|
$q.notify({ type: 'negative', message: 'ไม่สามารถโหลดข้อมูลผู้เรียนได้', position: 'top' });
|
|
showDetailModal.value = false;
|
|
} finally {
|
|
loadingDetail.value = false;
|
|
}
|
|
};
|
|
|
|
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 = (dateStr: string) => {
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
};
|
|
|
|
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' });
|
|
};
|
|
|
|
// Fetch on mount
|
|
onMounted(() => {
|
|
fetchStudents();
|
|
});
|
|
</script>
|