feat: Implement instructor course management, including student progress tracking and course content views, alongside admin user management.
This commit is contained in:
parent
c9381b9385
commit
be5b9756be
11 changed files with 2116 additions and 1386 deletions
458
frontend_management/components/course/AnnouncementsTab.vue
Normal file
458
frontend_management/components/course/AnnouncementsTab.vue
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900">ประกาศ</h2>
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="สร้างประกาศ"
|
||||
icon="add"
|
||||
@click="openDialog()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-10">
|
||||
<q-spinner color="primary" size="40px" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="announcements.length === 0" class="text-center py-10 text-gray-500">
|
||||
<q-icon name="campaign" size="60px" color="grey-4" class="mb-4" />
|
||||
<p>ยังไม่มีประกาศ</p>
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="สร้างประกาศแรก"
|
||||
class="mt-4"
|
||||
@click="openDialog()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<q-card
|
||||
v-for="announcement in announcements"
|
||||
:key="announcement.id"
|
||||
flat
|
||||
bordered
|
||||
class="rounded-lg"
|
||||
>
|
||||
<q-card-section>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<q-icon v-if="announcement.is_pinned" name="push_pin" color="orange" size="20px" />
|
||||
<h3 class="font-semibold text-gray-900">{{ announcement.title.th }}</h3>
|
||||
<q-badge :color="announcement.status === 'PUBLISHED' ? 'green' : 'grey'">
|
||||
{{ announcement.status === 'PUBLISHED' ? 'เผยแพร่' : 'ฉบับร่าง' }}
|
||||
</q-badge>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ announcement.title.en }}</p>
|
||||
<p class="text-gray-600 mt-3 whitespace-pre-line">{{ announcement.content.th }}</p>
|
||||
<p class="text-xs text-gray-400 mt-3">
|
||||
สร้างเมื่อ {{ formatDate(announcement.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<q-btn flat round icon="edit" color="primary" size="sm" @click="openDialog(announcement)" />
|
||||
<q-btn flat round icon="delete" color="negative" size="sm" @click="confirmDelete(announcement)" />
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<!-- Announcement Dialog -->
|
||||
<q-dialog v-model="showDialog" persistent>
|
||||
<q-card style="min-width: 600px; max-width: 700px">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<div class="text-h6">{{ editing ? 'แก้ไขประกาศ' : 'สร้างประกาศใหม่' }}</div>
|
||||
<q-space />
|
||||
<q-btn icon="close" flat round dense @click="showDialog = false" />
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<div class="space-y-4">
|
||||
<q-input
|
||||
v-model="form.title.th"
|
||||
outlined
|
||||
label="หัวข้อ (ภาษาไทย) *"
|
||||
:rules="[val => !!val || 'กรุณากรอกหัวข้อ']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
/>
|
||||
<q-input
|
||||
v-model="form.title.en"
|
||||
outlined
|
||||
label="หัวข้อ (English)"
|
||||
/>
|
||||
<q-input
|
||||
v-model="form.content.th"
|
||||
outlined
|
||||
type="textarea"
|
||||
rows="4"
|
||||
label="เนื้อหา (ภาษาไทย) *"
|
||||
:rules="[val => !!val || 'กรุณากรอกเนื้อหา']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
/>
|
||||
<q-input
|
||||
v-model="form.content.en"
|
||||
outlined
|
||||
type="textarea"
|
||||
rows="4"
|
||||
label="เนื้อหา (English)"
|
||||
/>
|
||||
<div class="flex gap-4">
|
||||
<q-toggle v-model="form.is_pinned" label="ปักหมุด" />
|
||||
<q-select
|
||||
v-model="form.status"
|
||||
:options="[
|
||||
{ label: 'ฉบับร่าง', value: 'DRAFT' },
|
||||
{ label: 'เผยแพร่', value: 'PUBLISHED' }
|
||||
]"
|
||||
outlined
|
||||
emit-value
|
||||
map-options
|
||||
label="สถานะ"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Published At -->
|
||||
<div class="row q-col-gutter-sm" v-if="form.status === 'PUBLISHED'">
|
||||
<div class="col-12 col-md-12">
|
||||
<q-input filled v-model="form.published_at" label="วันเวลาที่เผยแพร่ (ไม่ระบุ = เผยแพร่ทันที)">
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="event" class="cursor-pointer">
|
||||
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
|
||||
<q-date v-model="form.published_at" mask="YYYY-MM-DD HH:mm">
|
||||
<div class="row items-center justify-end">
|
||||
<q-btn v-close-popup label="Close" color="primary" flat />
|
||||
</div>
|
||||
</q-date>
|
||||
</q-popup-proxy>
|
||||
</q-icon>
|
||||
</template>
|
||||
|
||||
<template v-slot:append>
|
||||
<q-icon name="access_time" class="cursor-pointer">
|
||||
<q-popup-proxy cover transition-show="scale" transition-hide="scale">
|
||||
<q-time v-model="form.published_at" mask="YYYY-MM-DD HH:mm" format24h>
|
||||
<div class="row items-center justify-end">
|
||||
<q-btn v-close-popup label="Close" color="primary" flat />
|
||||
</div>
|
||||
</q-time>
|
||||
</q-popup-proxy>
|
||||
</q-icon>
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attachments Section -->
|
||||
<div class="border rounded-lg p-4">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h4 class="font-semibold text-gray-700">ไฟล์แนบ</h4>
|
||||
<q-btn
|
||||
size="sm"
|
||||
color="primary"
|
||||
icon="attach_file"
|
||||
label="เพิ่มไฟล์"
|
||||
:loading="uploadingAttachment"
|
||||
@click="triggerFileInput"
|
||||
/>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
class="hidden"
|
||||
@change="handleFileUpload"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Existing Attachments (edit mode) -->
|
||||
<div v-if="editing?.attachments && editing.attachments.length > 0" class="space-y-2 mb-2">
|
||||
<div
|
||||
v-for="attachment in editing?.attachments"
|
||||
:key="attachment.id"
|
||||
class="flex items-center justify-between bg-gray-50 rounded p-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<q-icon name="insert_drive_file" color="grey" />
|
||||
<span class="text-sm">{{ attachment.file_name }}</span>
|
||||
<span class="text-xs text-gray-400">({{ formatFileSize(attachment.file_size) }})</span>
|
||||
</div>
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
size="sm"
|
||||
icon="delete"
|
||||
color="negative"
|
||||
:loading="deletingAttachmentId === attachment.id"
|
||||
@click="deleteAttachment(attachment.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending Files (create mode) -->
|
||||
<div v-if="pendingFiles.length > 0" class="space-y-2 mb-2">
|
||||
<div
|
||||
v-for="(file, index) in pendingFiles"
|
||||
:key="index"
|
||||
class="flex items-center justify-between bg-blue-50 rounded p-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<q-icon name="upload_file" color="primary" />
|
||||
<span class="text-sm">{{ file.name }}</span>
|
||||
<span class="text-xs text-gray-400">({{ formatFileSize(file.size) }})</span>
|
||||
<q-badge color="blue" label="รอสร้าง" size="sm" />
|
||||
</div>
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
size="sm"
|
||||
icon="close"
|
||||
color="grey"
|
||||
@click="removePendingFile(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!editing?.attachments?.length && pendingFiles.length === 0" class="text-center py-4 text-gray-400 text-sm">
|
||||
ยังไม่มีไฟล์แนบ
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right" class="q-pa-md">
|
||||
<q-btn flat label="ยกเลิก" color="grey-7" @click="showDialog = false" />
|
||||
<q-btn
|
||||
:label="editing ? 'บันทึก' : 'สร้าง'"
|
||||
color="primary"
|
||||
:loading="saving"
|
||||
@click="save"
|
||||
/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuasar } from 'quasar';
|
||||
import {
|
||||
instructorService,
|
||||
type AnnouncementResponse,
|
||||
type CreateAnnouncementRequest
|
||||
} from '~/services/instructor.service';
|
||||
|
||||
interface Props {
|
||||
courseId: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const $q = useQuasar();
|
||||
|
||||
// State
|
||||
const announcements = ref<AnnouncementResponse[]>([]);
|
||||
const loading = ref(false);
|
||||
const showDialog = ref(false);
|
||||
const editing = ref<AnnouncementResponse | null>(null);
|
||||
const saving = ref(false);
|
||||
|
||||
// Form
|
||||
const form = ref<CreateAnnouncementRequest>({
|
||||
title: { th: '', en: '' },
|
||||
content: { th: '', en: '' },
|
||||
is_pinned: false,
|
||||
status: 'DRAFT',
|
||||
published_at: ''
|
||||
});
|
||||
|
||||
// Attachments
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const uploadingAttachment = ref(false);
|
||||
const deletingAttachmentId = ref<number | null>(null);
|
||||
const pendingFiles = ref<File[]>([]);
|
||||
|
||||
// Methods
|
||||
const fetchAnnouncements = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
announcements.value = await instructorService.getAnnouncements(props.courseId);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch announcements:', error);
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถโหลดประกาศได้', position: 'top' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openDialog = (announcement?: AnnouncementResponse) => {
|
||||
editing.value = announcement || null;
|
||||
if (announcement) {
|
||||
// Format date for q-date/q-time (YYYY-MM-DD HH:mm)
|
||||
let formattedPublishedAt = '';
|
||||
if (announcement.published_at) {
|
||||
const date = new Date(announcement.published_at);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
formattedPublishedAt = `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
form.value = {
|
||||
title: { ...announcement.title },
|
||||
content: { ...announcement.content },
|
||||
is_pinned: announcement.is_pinned,
|
||||
status: announcement.status,
|
||||
published_at: formattedPublishedAt
|
||||
};
|
||||
} else {
|
||||
form.value = {
|
||||
title: { th: '', en: '' },
|
||||
content: { th: '', en: '' },
|
||||
is_pinned: false,
|
||||
status: 'DRAFT',
|
||||
published_at: ''
|
||||
};
|
||||
}
|
||||
pendingFiles.value = [];
|
||||
showDialog.value = true;
|
||||
};
|
||||
|
||||
// Watch for published_at changes to auto-set status
|
||||
watch(() => form.value.published_at, (newVal) => {
|
||||
if (newVal) {
|
||||
form.value.status = 'PUBLISHED';
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for status changes to clear published_at if DRAFT
|
||||
watch(() => form.value.status, (newVal) => {
|
||||
if (newVal === 'DRAFT') {
|
||||
form.value.published_at = '';
|
||||
}
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
if (!form.value.title.th || !form.value.content.th) {
|
||||
$q.notify({ type: 'warning', message: 'กรุณากรอกข้อมูลที่จำเป็น', position: 'top' });
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (editing.value) {
|
||||
await instructorService.updateAnnouncement(props.courseId, editing.value.id, form.value);
|
||||
$q.notify({ type: 'positive', message: 'บันทึกประกาศสำเร็จ', position: 'top' });
|
||||
} else {
|
||||
const created = await instructorService.createAnnouncement(props.courseId, form.value);
|
||||
|
||||
// Upload pending files
|
||||
for (const file of pendingFiles.value) {
|
||||
try {
|
||||
await instructorService.uploadAnnouncementAttachment(props.courseId, created.data.id, file);
|
||||
} catch (err) {
|
||||
console.error('Failed to upload attachment:', err);
|
||||
}
|
||||
}
|
||||
|
||||
$q.notify({ type: 'positive', message: 'สร้างประกาศสำเร็จ', position: 'top' });
|
||||
}
|
||||
showDialog.value = false;
|
||||
fetchAnnouncements();
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.data?.message || 'ไม่สามารถบันทึกประกาศได้',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = (announcement: AnnouncementResponse) => {
|
||||
$q.dialog({
|
||||
title: 'ยืนยันการลบ',
|
||||
message: `คุณต้องการลบประกาศ "${announcement.title.th}" หรือไม่?`,
|
||||
cancel: true,
|
||||
persistent: true
|
||||
}).onOk(async () => {
|
||||
try {
|
||||
await instructorService.deleteAnnouncement(props.courseId, announcement.id);
|
||||
$q.notify({ type: 'positive', message: 'ลบประกาศสำเร็จ', position: 'top' });
|
||||
fetchAnnouncements();
|
||||
} catch (error) {
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถลบประกาศได้', position: 'top' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.value?.click();
|
||||
};
|
||||
|
||||
const handleFileUpload = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = input.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
if (editing.value) {
|
||||
// Upload immediately in edit mode
|
||||
uploadingAttachment.value = true;
|
||||
try {
|
||||
await instructorService.uploadAnnouncementAttachment(props.courseId, editing.value.id, files[0]);
|
||||
$q.notify({ type: 'positive', message: 'อัพโหลดไฟล์สำเร็จ', position: 'top' });
|
||||
// Refresh editing
|
||||
const all = await instructorService.getAnnouncements(props.courseId);
|
||||
editing.value = all.find(a => a.id === editing.value?.id) || null;
|
||||
} catch (error) {
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถอัพโหลดไฟล์ได้', position: 'top' });
|
||||
} finally {
|
||||
uploadingAttachment.value = false;
|
||||
}
|
||||
} else {
|
||||
// Add to pending files in create mode
|
||||
pendingFiles.value.push(files[0]);
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
};
|
||||
|
||||
const removePendingFile = (index: number) => {
|
||||
pendingFiles.value.splice(index, 1);
|
||||
};
|
||||
|
||||
const deleteAttachment = async (attachmentId: number) => {
|
||||
if (!editing.value) return;
|
||||
deletingAttachmentId.value = attachmentId;
|
||||
try {
|
||||
await instructorService.deleteAnnouncementAttachment(props.courseId, editing.value.id, attachmentId);
|
||||
$q.notify({ type: 'positive', message: 'ลบไฟล์สำเร็จ', position: 'top' });
|
||||
// Refresh editing
|
||||
const all = await instructorService.getAnnouncements(props.courseId);
|
||||
editing.value = all.find(a => a.id === editing.value?.id) || null;
|
||||
} catch (error) {
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถลบไฟล์ได้', position: 'top' });
|
||||
} finally {
|
||||
deletingAttachmentId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('th-TH', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
// Fetch on mount
|
||||
onMounted(() => {
|
||||
fetchAnnouncements();
|
||||
});
|
||||
</script>
|
||||
233
frontend_management/components/course/InstructorsTab.vue
Normal file
233
frontend_management/components/course/InstructorsTab.vue
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900">ผู้สอนในรายวิชา</h2>
|
||||
<q-btn v-if="isPrimaryInstructor" color="primary" icon="person_add" label="เพิ่มผู้สอน" @click="showAddDialog = true" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex justify-center py-10">
|
||||
<q-spinner color="primary" size="40px" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="instructors.length === 0" class="text-center py-10 text-gray-500">
|
||||
<q-icon name="group_off" size="60px" color="grey-4" class="mb-4" />
|
||||
<p>ยังไม่มีข้อมูลผู้สอน</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<q-card v-for="instructor in instructors" :key="instructor.id" flat bordered class="rounded-lg">
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<q-avatar size="50px" color="primary" text-color="white">
|
||||
<img v-if="instructor.user.avatar_url" :src="instructor.user.avatar_url">
|
||||
<span v-else>{{ instructor.user.username.charAt(0).toUpperCase() }}</span>
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section>
|
||||
<q-item-label class="text-base font-medium flex items-center gap-2">
|
||||
{{ instructor.user.username }}
|
||||
<q-badge v-if="instructor.is_primary" color="primary" label="หัวหน้าผู้สอน" />
|
||||
</q-item-label>
|
||||
<q-item-label caption>{{ instructor.user.email }}</q-item-label>
|
||||
</q-item-section>
|
||||
|
||||
<q-item-section side v-if="isPrimaryInstructor">
|
||||
<q-btn flat dense round icon="more_vert">
|
||||
<q-menu>
|
||||
<q-item v-if="!instructor.is_primary" clickable v-close-popup @click="setPrimary(instructor.user_id)">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="verified_user" color="primary" />
|
||||
</q-item-section>
|
||||
<q-item-section>ตั้งเป็นหัวหน้าผู้สอน</q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="removeInstructor(instructor.user_id)" class="text-red">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="person_remove" color="red" />
|
||||
</q-item-section>
|
||||
<q-item-section>ลบผู้สอน</q-item-section>
|
||||
</q-item>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<!-- Add Instructor Dialog -->
|
||||
<q-dialog v-model="showAddDialog">
|
||||
<q-card style="min-width: 400px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">เพิ่มผู้สอน</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-select
|
||||
v-model="selectedUser"
|
||||
:options="searchResults"
|
||||
:option-label="(item) => item ? `${item.username} (${item.email})` : ''"
|
||||
label="ค้นหาผู้ใช้"
|
||||
use-input
|
||||
filled
|
||||
@filter="filterUsers"
|
||||
:loading="loadingSearch"
|
||||
>
|
||||
<template v-slot:option="scope">
|
||||
<q-item v-bind="scope.itemProps">
|
||||
<q-item-section avatar>
|
||||
<q-avatar>
|
||||
<img v-if="scope.opt.profile?.avatar_url" :src="scope.opt.profile.avatar_url">
|
||||
<span v-else>{{ scope.opt.username.charAt(0).toUpperCase() }}</span>
|
||||
</q-avatar>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ scope.opt.username }}</q-item-label>
|
||||
<q-item-label caption>{{ scope.opt.email }}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
<template v-slot:no-option>
|
||||
<q-item>
|
||||
<q-item-section class="text-grey">
|
||||
ไม่พบผู้ใช้
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
</q-select>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="ยกเลิก" color="primary" v-close-popup />
|
||||
<q-btn flat label="เพิ่ม" color="primary" @click="addInstructor" :disable="!selectedUser" :loading="addingInstructor" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuasar } from 'quasar';
|
||||
import {
|
||||
instructorService,
|
||||
type CourseInstructorResponse,
|
||||
type SearchInstructorResult
|
||||
} from '~/services/instructor.service';
|
||||
import { useAuthStore } from '~/stores/auth';
|
||||
|
||||
interface Props {
|
||||
courseId: number;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const $q = useQuasar();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// State
|
||||
const instructors = ref<CourseInstructorResponse[]>([]);
|
||||
const loading = ref(false);
|
||||
const showAddDialog = ref(false);
|
||||
const selectedUser = ref<SearchInstructorResult | null>(null);
|
||||
const searchResults = ref<SearchInstructorResult[]>([]);
|
||||
const loadingSearch = ref(false);
|
||||
const addingInstructor = ref(false);
|
||||
|
||||
// Computed
|
||||
const isPrimaryInstructor = computed(() => {
|
||||
if (!authStore.user?.id) return false;
|
||||
const currentUserId = parseInt(authStore.user.id);
|
||||
const myRecord = instructors.value.find(i => i.user_id === currentUserId);
|
||||
return myRecord?.is_primary === true;
|
||||
});
|
||||
|
||||
// Methods
|
||||
const fetchInstructors = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
instructors.value = await instructorService.getCourseInstructors(props.courseId);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch instructors:', error);
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถโหลดข้อมูลผู้สอนได้', position: 'top' });
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const filterUsers = async (val: string, update: (fn: () => void) => void, abort: () => void) => {
|
||||
if (val.length < 2) {
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
loadingSearch.value = true;
|
||||
try {
|
||||
const results = await instructorService.searchInstructors(val, props.courseId);
|
||||
update(() => {
|
||||
searchResults.value = results;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
abort();
|
||||
} finally {
|
||||
loadingSearch.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const addInstructor = async () => {
|
||||
if (!selectedUser.value) return;
|
||||
addingInstructor.value = true;
|
||||
try {
|
||||
await instructorService.addCourseInstructor(props.courseId, selectedUser.value.id);
|
||||
$q.notify({ type: 'positive', message: 'เพิ่มผู้สอนสำเร็จ', position: 'top' });
|
||||
showAddDialog.value = false;
|
||||
selectedUser.value = null;
|
||||
fetchInstructors();
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.data?.message || 'ไม่สามารถเพิ่มผู้สอนได้',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
addingInstructor.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setPrimary = async (userId: number) => {
|
||||
try {
|
||||
await instructorService.setPrimaryInstructor(props.courseId, userId);
|
||||
$q.notify({ type: 'positive', message: 'ตั้งเป็นหัวหน้าผู้สอนสำเร็จ', position: 'top' });
|
||||
fetchInstructors();
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.data?.message || 'ไม่สามารถตั้งเป็นหัวหน้าผู้สอนได้',
|
||||
position: 'top'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const removeInstructor = async (userId: number) => {
|
||||
$q.dialog({
|
||||
title: 'ยืนยันการลบ',
|
||||
message: 'คุณต้องการลบผู้สอนคนนี้หรือไม่?',
|
||||
cancel: true,
|
||||
persistent: true
|
||||
}).onOk(async () => {
|
||||
try {
|
||||
await instructorService.removeCourseInstructor(props.courseId, userId);
|
||||
$q.notify({ type: 'positive', message: 'ลบผู้สอนสำเร็จ', position: 'top' });
|
||||
fetchInstructors();
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.data?.message || 'ไม่สามารถลบผู้สอนได้',
|
||||
position: 'top'
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Fetch on mount
|
||||
onMounted(() => {
|
||||
fetchInstructors();
|
||||
});
|
||||
</script>
|
||||
470
frontend_management/components/course/QuizResultsTab.vue
Normal file
470
frontend_management/components/course/QuizResultsTab.vue
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
<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>
|
||||
107
frontend_management/components/course/StructureTab.vue
Normal file
107
frontend_management/components/course/StructureTab.vue
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900">โครงสร้างบทเรียน</h2>
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="จัดการโครงสร้าง"
|
||||
@click="navigateTo(`/instructor/courses/${courseId}/structure`)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Chapters -->
|
||||
<div v-if="chapters.length === 0" class="text-center py-10 text-gray-500">
|
||||
ยังไม่มีบทเรียน
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<q-card
|
||||
v-for="chapter in sortedChapters"
|
||||
:key="chapter.id"
|
||||
flat
|
||||
bordered
|
||||
class="rounded-lg"
|
||||
>
|
||||
<q-card-section>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900">
|
||||
Chapter {{ chapter.sort_order }}: {{ chapter.title.th }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 mt-1">
|
||||
{{ chapter.lessons.length }} บทเรียน · {{ getChapterDuration(chapter) }} นาที
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<!-- Lessons -->
|
||||
<q-list separator class="border-t">
|
||||
<q-item
|
||||
v-for="lesson in getSortedLessons(chapter)"
|
||||
:key="lesson.id"
|
||||
class="py-3"
|
||||
>
|
||||
<q-item-section avatar>
|
||||
<q-icon
|
||||
:name="getLessonIcon(lesson.type)"
|
||||
:color="getLessonIconColor(lesson.type)"
|
||||
/>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
Lesson {{ chapter.sort_order }}.{{ lesson.sort_order }}: {{ lesson.title.th }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<span class="text-sm text-gray-500">{{ lesson.duration_minutes }} นาที</span>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ChapterResponse } from '~/services/instructor.service';
|
||||
|
||||
interface Props {
|
||||
courseId: number;
|
||||
chapters: ChapterResponse[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
// Computed
|
||||
const sortedChapters = computed(() => {
|
||||
return props.chapters.slice().sort((a, b) => a.sort_order - b.sort_order);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const getChapterDuration = (chapter: ChapterResponse) => {
|
||||
return chapter.lessons.reduce((sum, l) => sum + l.duration_minutes, 0);
|
||||
};
|
||||
|
||||
const getSortedLessons = (chapter: ChapterResponse) => {
|
||||
return chapter.lessons.slice().sort((a, b) => a.sort_order - b.sort_order);
|
||||
};
|
||||
|
||||
const getLessonIcon = (type: string) => {
|
||||
const icons: Record<string, string> = {
|
||||
VIDEO: 'play_circle',
|
||||
DOCUMENT: 'description',
|
||||
QUIZ: 'quiz'
|
||||
};
|
||||
return icons[type] || 'article';
|
||||
};
|
||||
|
||||
const getLessonIconColor = (type: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
VIDEO: 'blue',
|
||||
DOCUMENT: 'orange',
|
||||
QUIZ: 'orange'
|
||||
};
|
||||
return colors[type] || 'grey';
|
||||
};
|
||||
</script>
|
||||
447
frontend_management/components/course/StudentsTab.vue
Normal file
447
frontend_management/components/course/StudentsTab.vue
Normal file
|
|
@ -0,0 +1,447 @@
|
|||
<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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue