470 lines
17 KiB
Vue
470 lines
17 KiB
Vue
<template>
|
|
<div>
|
|
<!-- No Quizzes State -->
|
|
<div v-if="quizzes.length === 0" class="text-center py-10 text-gray-500">
|
|
<q-icon name="quiz" size="60px" color="grey-4" class="mb-4" />
|
|
<p>หลักสูตรนี้ยังไม่มีแบบทดสอบ</p>
|
|
</div>
|
|
|
|
<!-- Quiz Selection -->
|
|
<div v-else class="space-y-6">
|
|
<div class="flex flex-col md:flex-row items-center gap-4 bg-white p-4 rounded-lg border border-gray-200">
|
|
<div class="flex-1 w-full">
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">เลือกแบบทดสอบ</label>
|
|
<q-select
|
|
v-model="selectedQuiz"
|
|
:options="quizzes"
|
|
option-label="label"
|
|
outlined
|
|
dense
|
|
emit-value
|
|
map-options
|
|
class="bg-white"
|
|
@update:model-value="handleQuizChange"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Search and Filter -->
|
|
<q-card flat bordered class="rounded-lg mb-6" v-if="selectedQuiz">
|
|
<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.rowsPerPage"
|
|
:options="limitOptions"
|
|
option-value="value"
|
|
option-label="label"
|
|
outlined
|
|
dense
|
|
emit-value
|
|
map-options
|
|
label="จำนวนต่อหน้า"
|
|
class="w-full md:w-32"
|
|
@update:model-value="handleLimitChange"
|
|
/>
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
<!-- Stats Cards -->
|
|
<div v-if="stats" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<q-card flat bordered class="bg-blue-50 border-blue-100">
|
|
<q-card-section>
|
|
<div class="text-blue-800 text-sm font-medium">คะแนนเฉลี่ย</div>
|
|
<div class="text-2xl font-bold text-blue-900 mt-1">
|
|
{{ stats.averageScore.toFixed(1) }} / {{ stats.totalScore }}
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
<q-card flat bordered class="bg-green-50 border-green-100">
|
|
<q-card-section>
|
|
<div class="text-green-800 text-sm font-medium">ผู้ที่สอบผ่าน</div>
|
|
<div class="text-2xl font-bold text-green-900 mt-1">
|
|
{{ stats.passCount }} คน
|
|
<span class="text-sm font-normal text-green-700">({{ stats.passRate.toFixed(1) }}%)</span>
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
|
|
<q-card flat bordered class="bg-purple-50 border-purple-100">
|
|
<q-card-section>
|
|
<div class="text-purple-800 text-sm font-medium">จำนวนผู้เข้าสอบ</div>
|
|
<div class="text-2xl font-bold text-purple-900 mt-1">
|
|
{{ stats.totalStudents }} คน
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
</div>
|
|
|
|
<!-- Students Table -->
|
|
<div v-if="selectedQuiz">
|
|
<q-table
|
|
:rows="students"
|
|
:columns="columns"
|
|
row-key="user_id"
|
|
:loading="loading"
|
|
flat
|
|
bordered
|
|
hide-pagination
|
|
:pagination="pagination"
|
|
no-data-label="ยังไม่มีข้อมูล"
|
|
>
|
|
<template v-slot:body="props">
|
|
<q-tr :props="props" @click="openStudentDetail(props.row.user_id)" class="cursor-pointer hover:bg-gray-50">
|
|
<q-td key="student" :props="props">
|
|
<div class="flex items-center gap-3">
|
|
<q-avatar size="32px">
|
|
<img :src="props.row.avatar_url || 'https://cdn.quasar.dev/img/boy-avatar.png'" />
|
|
</q-avatar>
|
|
<div>
|
|
<div class="font-medium">{{ props.row.first_name }} {{ props.row.last_name }}</div>
|
|
<div class="text-xs text-gray-500">{{ props.row.email }}</div>
|
|
</div>
|
|
</div>
|
|
</q-td>
|
|
<q-td key="score" :props="props" class="text-center">
|
|
<div class="font-medium">
|
|
{{ props.row.best_score }} / {{ currentQuizData?.total_score }}
|
|
</div>
|
|
</q-td>
|
|
<q-td key="status" :props="props" class="text-center">
|
|
<q-chip
|
|
:color="props.row.is_passed ? 'green-1' : 'red-1'"
|
|
:text-color="props.row.is_passed ? 'green-9' : 'red-9'"
|
|
size="md"
|
|
>
|
|
{{ props.row.is_passed ? 'ผ่าน' : 'ไม่ผ่าน' }}
|
|
</q-chip>
|
|
</q-td>
|
|
<q-td key="attempts" :props="props" class="text-center">
|
|
{{ props.row.total_attempts }} ครั้ง
|
|
</q-td>
|
|
<q-td key="last_attempt" :props="props">
|
|
<div v-if="props.row.latest_attempt">
|
|
{{ formatDate(props.row.latest_attempt.completed_at) }}
|
|
</div>
|
|
<div v-else class="text-gray-400">-</div>
|
|
</q-td>
|
|
</q-tr>
|
|
</template>
|
|
</q-table>
|
|
|
|
<!-- Custom Pagination -->
|
|
<div v-if="pagination.rowsNumber > pagination.rowsPerPage" class="flex justify-center mt-4">
|
|
<q-pagination
|
|
v-model="pagination.page"
|
|
:max="Math.ceil(pagination.rowsNumber / pagination.rowsPerPage)"
|
|
:max-pages="6"
|
|
direction-links
|
|
boundary-links
|
|
@update:model-value="handlePageChange"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Student Quiz Detail Dialog -->
|
|
<q-dialog v-model="showDetailDialog" maximized transition-show="slide-up" transition-hide="slide-down">
|
|
<q-card class="bg-gray-50">
|
|
<q-card-section class="bg-white shadow-sm sticky top-0 z-10">
|
|
<div class="flex justify-between items-center">
|
|
<div class="flex items-center gap-4" v-if="attemptDetail">
|
|
<q-avatar size="48px" color="primary" text-color="white">
|
|
<img v-if="attemptDetail.student.user_id" :src="`https://ui-avatars.com/api/?name=${attemptDetail.student.first_name}+${attemptDetail.student.last_name}`" />
|
|
<span v-else>{{ attemptDetail.student.username.charAt(0).toUpperCase() }}</span>
|
|
</q-avatar>
|
|
<div>
|
|
<h2 class="text-xl font-semibold">{{ attemptDetail.student.first_name }} {{ attemptDetail.student.last_name }}</h2>
|
|
<div class="text-gray-500 text-sm">
|
|
{{ attemptDetail.student.email }} · ครั้งที่ {{ attemptDetail.attempt_number }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="h-12 w-48 bg-gray-200 animate-pulse rounded"></div>
|
|
<q-btn icon="close" flat round dense v-close-popup />
|
|
</div>
|
|
</q-card-section>
|
|
|
|
<q-card-section class="p-6 max-w-4xl mx-auto w-full">
|
|
<div v-if="loadingDetail" class="flex justify-center py-20">
|
|
<q-spinner color="primary" size="50px" />
|
|
</div>
|
|
|
|
<div v-else-if="attemptDetail" class="space-y-6">
|
|
<!-- Summary Stats -->
|
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<q-card flat bordered class="text-center p-4">
|
|
<div class="text-gray-500 text-sm mb-1">คะแนนที่ได้</div>
|
|
<div class="text-3xl font-bold" :class="attemptDetail.is_passed ? 'text-green-600' : 'text-red-600'">
|
|
{{ attemptDetail.score }} / {{ attemptDetail.total_score }}
|
|
</div>
|
|
</q-card>
|
|
<q-card flat bordered class="text-center p-4">
|
|
<div class="text-gray-500 text-sm mb-1">สถานะ</div>
|
|
<q-chip
|
|
:color="attemptDetail.is_passed ? 'green-1' : 'red-1'"
|
|
:text-color="attemptDetail.is_passed ? 'green-9' : 'red-9'"
|
|
class="font-bold"
|
|
>
|
|
{{ attemptDetail.is_passed ? 'ผ่าน' : 'ไม่ผ่าน' }}
|
|
</q-chip>
|
|
</q-card>
|
|
<q-card flat bordered class="text-center p-4">
|
|
<div class="text-gray-500 text-sm mb-1">ตอบถูก</div>
|
|
<div class="text-xl font-semibold">
|
|
{{ attemptDetail.correct_answers }} / {{ attemptDetail.total_questions }} ข้อ
|
|
</div>
|
|
</q-card>
|
|
<q-card flat bordered class="text-center p-4">
|
|
<div class="text-gray-500 text-sm mb-1">วันที่ทำ</div>
|
|
<div class="text-xl font-semibold">
|
|
{{ formatDate(attemptDetail.completed_at) }}
|
|
<!-- Note: Ideally calculate duration if started_at is available -->
|
|
</div>
|
|
</q-card>
|
|
</div>
|
|
|
|
<!-- Questions Review -->
|
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
|
<div class="p-4 border-b bg-gray-50 font-semibold text-gray-700">
|
|
รายละเอียดการตอบคำถาม
|
|
</div>
|
|
<div class="divide-y">
|
|
<div
|
|
v-for="(answer, index) in attemptDetail.answers_review"
|
|
:key="answer.question_id"
|
|
class="p-6"
|
|
>
|
|
<div class="flex gap-4">
|
|
<div class="flex-none">
|
|
<div class="w-8 h-8 rounded-full flex items-center justify-center font-bold text-white"
|
|
:class="answer.is_correct ? 'bg-green-500' : 'bg-red-500'"
|
|
>
|
|
{{ index + 1 }}
|
|
</div>
|
|
</div>
|
|
<div class="flex-1">
|
|
<div class="font-medium text-lg mb-3">{{ answer.question_text?.th || 'ไม่สามารถโหลดคำถามได้' }}</div>
|
|
|
|
<div class="p-3 rounded-lg border-l-4"
|
|
:class="answer.is_correct ? 'bg-green-50 border-green-500' : 'bg-red-50 border-red-500'"
|
|
>
|
|
<div class="text-sm text-gray-500 mb-1">คำตอบที่เลือก:</div>
|
|
<div class="font-medium" :class="answer.is_correct ? 'text-green-800' : 'text-red-800'">
|
|
{{ answer.selected_choice_text?.th || '-' }}
|
|
</div>
|
|
</div>
|
|
<div class="mt-2 text-right text-sm text-gray-500">
|
|
คะแนน: {{ answer.score }} / {{ answer.question_score }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</q-card-section>
|
|
</q-card>
|
|
</q-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useQuasar } from 'quasar';
|
|
import {
|
|
instructorService,
|
|
type ChapterResponse,
|
|
type QuizScoreStudentResponse,
|
|
type QuizScoresData,
|
|
type QuizAttemptDetailData
|
|
} from '~/services/instructor.service';
|
|
|
|
interface Props {
|
|
courseId: number;
|
|
chapters: ChapterResponse[];
|
|
}
|
|
|
|
const props = defineProps<Props>();
|
|
const $q = useQuasar();
|
|
|
|
// Columns
|
|
const columns = [
|
|
{ name: 'student', label: 'ผู้เรียน', align: 'left' as const, field: 'first_name' },
|
|
{ name: 'score', label: 'คะแนนที่ดีที่สุด', align: 'center' as const, field: 'best_score' },
|
|
{ name: 'status', label: 'สถานะ', align: 'center' as const, field: 'is_passed' },
|
|
{ name: 'attempts', label: 'จำนวนครั้งที่สอบ', align: 'center' as const, field: 'total_attempts' },
|
|
{ name: 'last_attempt', label: 'สอบล่าสุดเมื่อ', align: 'left' as const, field: 'latest_attempt' },
|
|
];
|
|
|
|
// State
|
|
const selectedQuiz = ref<any>(null); // Option object
|
|
const loading = ref(false);
|
|
const students = ref<QuizScoreStudentResponse[]>([]);
|
|
const currentQuizData = ref<QuizScoresData | null>(null);
|
|
const search = ref('');
|
|
const statusFilter = ref('all');
|
|
|
|
// Student Detail Dialog
|
|
const showDetailDialog = ref(false);
|
|
const loadingDetail = ref(false);
|
|
const attemptDetail = ref<QuizAttemptDetailData | null>(null);
|
|
|
|
const pagination = ref({
|
|
page: 1,
|
|
rowsPerPage: 20,
|
|
rowsNumber: 0
|
|
});
|
|
|
|
const limitOptions = [
|
|
{ label: '5 รายการ', value: 5 },
|
|
{ label: '10 รายการ', value: 10 },
|
|
{ label: '20 รายการ', value: 20 },
|
|
{ label: '50 รายการ', value: 50 },
|
|
];
|
|
|
|
const statusFilterOptions = [
|
|
{ label: 'ทั้งหมด', value: 'all' },
|
|
{ label: 'ผ่าน', value: 'passed' },
|
|
{ label: 'ไม่ผ่าน', value: 'failed' }
|
|
];
|
|
|
|
// Computed
|
|
const quizzes = computed(() => {
|
|
const list: any[] = [];
|
|
props.chapters.forEach(chapter => {
|
|
chapter.lessons.forEach(lesson => {
|
|
// Assuming lesson.type === 'QUIZ' identifies a quiz
|
|
// Also checking if quiz object exists might be safer if type is not strictly enforcing it
|
|
if (lesson.type === 'QUIZ') {
|
|
list.push({
|
|
label: `${chapter.title.th} - ${lesson.title.th}`,
|
|
value: lesson.id,
|
|
total_score: lesson.quiz?.questions ? lesson.quiz.questions.reduce((sum, q) => sum + (q.score || 0), 0) : 0
|
|
});
|
|
}
|
|
});
|
|
});
|
|
return list;
|
|
});
|
|
|
|
const stats = computed(() => {
|
|
if (!currentQuizData.value || !students.value.length) return null;
|
|
|
|
// Basic stats logic from current page data or explicit API data if available
|
|
const passed = students.value.filter(s => s.is_passed).length;
|
|
const avg = students.value.reduce((sum, s) => sum + s.best_score, 0) / students.value.length;
|
|
|
|
return {
|
|
totalStudents: pagination.value.rowsNumber, // Total from API
|
|
passCount: passed,
|
|
averageScore: avg || 0,
|
|
totalScore: currentQuizData.value.total_score,
|
|
passRate: (passed / students.value.length) * 100 || 0
|
|
};
|
|
});
|
|
|
|
// Methods
|
|
const handleQuizChange = () => {
|
|
pagination.value.page = 1;
|
|
search.value = '';
|
|
statusFilter.value = 'all';
|
|
fetchScores();
|
|
};
|
|
|
|
const handleSearch = () => {
|
|
pagination.value.page = 1;
|
|
fetchScores();
|
|
};
|
|
|
|
const handleLimitChange = () => {
|
|
pagination.value.page = 1;
|
|
fetchScores();
|
|
};
|
|
|
|
const handlePageChange = (vals:any) => {
|
|
fetchScores();
|
|
}
|
|
|
|
const fetchScores = async () => {
|
|
if (!selectedQuiz.value) return;
|
|
|
|
loading.value = true;
|
|
try {
|
|
let isPassed: boolean | undefined = undefined;
|
|
if (statusFilter.value === 'passed') isPassed = true;
|
|
if (statusFilter.value === 'failed') isPassed = false;
|
|
|
|
const response = await instructorService.getLessonQuizScores(
|
|
props.courseId,
|
|
selectedQuiz.value,
|
|
pagination.value.page,
|
|
pagination.value.rowsPerPage,
|
|
search.value,
|
|
isPassed
|
|
);
|
|
|
|
currentQuizData.value = response.data;
|
|
students.value = response.data.students;
|
|
|
|
pagination.value.rowsNumber = response.total;
|
|
} catch (error) {
|
|
console.error('Failed to fetch quiz scores:', error);
|
|
$q.notify({ type: 'negative', message: 'ไม่สามารถโหลดผลสอบได้', position: 'top' });
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const openStudentDetail = async (studentId: number) => {
|
|
showDetailDialog.value = true;
|
|
loadingDetail.value = true;
|
|
attemptDetail.value = null;
|
|
|
|
try {
|
|
// Assuming we want the detail for the current lesson/quiz
|
|
const response = await instructorService.getStudentQuizAttemptDetail(
|
|
props.courseId,
|
|
selectedQuiz.value,
|
|
studentId
|
|
);
|
|
attemptDetail.value = response.data;
|
|
} catch (error) {
|
|
console.error('Failed to fetch student quiz detail:', error);
|
|
$q.notify({ type: 'negative', message: 'ไม่สามารถโหลดรายละเอียดการสอบได้', position: 'top' });
|
|
showDetailDialog.value = false;
|
|
} finally {
|
|
loadingDetail.value = false;
|
|
}
|
|
};
|
|
|
|
const formatDate = (dateStr: string) => {
|
|
if (!dateStr) return '-';
|
|
const date = new Date(dateStr);
|
|
return date.toLocaleDateString('th-TH', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
};
|
|
|
|
// Lifecycle
|
|
onMounted(() => {
|
|
if (quizzes.value.length > 0) {
|
|
selectedQuiz.value = quizzes.value[0].value;
|
|
fetchScores();
|
|
}
|
|
});
|
|
</script>
|