elearning/frontend_management/components/course/QuizResultsTab.vue

471 lines
17 KiB
Vue
Raw Normal View History

<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>