elearning/frontend_management/pages/instructor/courses/[id]/index.vue

740 lines
25 KiB
Vue
Raw Normal View History

<template>
<div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-20">
<q-spinner-dots size="50px" color="primary" />
</div>
<template v-else-if="course">
<!-- Course Header -->
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
<div class="flex flex-col md:flex-row gap-6">
<!-- Thumbnail -->
<div class="w-full md:w-48 h-32 bg-gradient-to-br from-primary-400 to-primary-600 rounded-lg flex items-center justify-center flex-shrink-0">
<img
v-if="course.thumbnail_url"
:src="course.thumbnail_url"
:alt="course.title.th"
class="w-full h-full object-cover rounded-lg"
/>
<span v-else class="text-white text-sm">ปหลกสตร</span>
</div>
<!-- Info -->
<div class="flex-1">
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ course.title.th }}</h1>
<p class="text-gray-600 mt-1">{{ course.description.th }}</p>
</div>
<!-- Status Badges -->
<div class="flex gap-2">
<q-badge v-if="course.is_free" color="purple">เผยแพร่</q-badge>
<q-badge :color="getStatusColor(course.status)">
{{ getStatusLabel(course.status) }}
</q-badge>
</div>
</div>
<!-- Stats -->
<div class="flex items-center gap-6 mt-4 text-gray-600">
<div class="flex items-center gap-1">
<q-icon name="menu_book" size="20px" />
<span>{{ totalLessons }} บทเรยน</span>
</div>
<div class="flex items-center gap-1">
<q-icon name="people" size="20px" />
<span>0 เรยน</span>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex flex-col gap-2">
<q-btn
outline
color="primary"
label="แก้ไข"
icon="edit"
@click="navigateTo(`/instructor/courses/${course.id}/edit`)"
/>
<q-btn
v-if="course.status === 'DRAFT'"
color="primary"
label="ขออนุมัติหลักสูตร"
@click="requestApproval"
/>
</div>
</div>
</div>
<!-- Tabs -->
<q-tabs
v-model="activeTab"
class="bg-white rounded-t-xl shadow-sm text-primary-600"
active-color="primary"
indicator-color="primary"
align="left"
>
<q-tab name="structure" icon="list" label="โครงสร้าง" />
<q-tab name="students" icon="people" label="ผู้เรียน" />
<q-tab name="quiz" icon="quiz" label="ผลการทดสอบ" />
<q-tab name="announcements" icon="campaign" label="ประกาศ" />
</q-tabs>
<!-- Tab Panels -->
<q-tab-panels v-model="activeTab" class="bg-white rounded-b-xl shadow-sm">
<!-- Structure Tab -->
<q-tab-panel name="structure" class="p-6">
<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/${course.id}/structure`)"
/>
</div>
<!-- Chapters -->
<div v-if="course.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>
</q-tab-panel>
<!-- Students Tab -->
<q-tab-panel name="students" class="p-6">
<div class="text-center py-10 text-gray-500">
<q-icon name="people" size="60px" color="grey-4" class="mb-4" />
<p>งไมเรยนในหลกสตรน</p>
</div>
</q-tab-panel>
<!-- Quiz Results Tab -->
<q-tab-panel name="quiz" class="p-6">
<div class="text-center py-10 text-gray-500">
<q-icon name="quiz" size="60px" color="grey-4" class="mb-4" />
<p>งไมผลการทดสอบ</p>
</div>
</q-tab-panel>
<!-- Announcements Tab -->
<q-tab-panel name="announcements" class="p-6">
<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="openAnnouncementDialog()"
/>
</div>
<div v-if="loadingAnnouncements" 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="openAnnouncementDialog()"
/>
</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="openAnnouncementDialog(announcement)" />
<q-btn flat round icon="delete" color="negative" size="sm" @click="confirmDeleteAnnouncement(announcement)" />
</div>
</div>
</q-card-section>
</q-card>
</div>
</q-tab-panel>
</q-tab-panels>
<!-- Announcement Dialog -->
<q-dialog v-model="showAnnouncementDialog" persistent>
<q-card style="min-width: 600px; max-width: 700px">
<q-card-section class="row items-center q-pb-none">
<div class="text-h6">{{ editingAnnouncement ? 'แก้ไขประกาศ' : 'สร้างประกาศใหม่' }}</div>
<q-space />
<q-btn icon="close" flat round dense @click="showAnnouncementDialog = false" />
</q-card-section>
<q-card-section>
<div class="space-y-4">
<q-input
v-model="announcementForm.title.th"
outlined
label="หัวข้อ (ภาษาไทย) *"
:rules="[val => !!val || 'กรุณากรอกหัวข้อ']"
/>
<q-input
v-model="announcementForm.title.en"
outlined
label="หัวข้อ (English)"
/>
<q-input
v-model="announcementForm.content.th"
outlined
type="textarea"
rows="4"
label="เนื้อหา (ภาษาไทย) *"
:rules="[val => !!val || 'กรุณากรอกเนื้อหา']"
/>
<q-input
v-model="announcementForm.content.en"
outlined
type="textarea"
rows="4"
label="เนื้อหา (English)"
/>
<div class="flex gap-4">
<q-toggle v-model="announcementForm.is_pinned" label="ปักหมุด" />
<q-select
v-model="announcementForm.status"
:options="[
{ label: 'ฉบับร่าง', value: 'DRAFT' },
{ label: 'เผยแพร่', value: 'PUBLISHED' }
]"
outlined
emit-value
map-options
label="สถานะ"
class="flex-1"
/>
</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="editingAnnouncement?.attachments?.length > 0" class="space-y-2 mb-2">
<div
v-for="attachment in editingAnnouncement.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="!editingAnnouncement?.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="showAnnouncementDialog = false" />
<q-btn
:label="editingAnnouncement ? 'บันทึก' : 'สร้าง'"
color="primary"
:loading="savingAnnouncement"
@click="saveAnnouncement"
/>
</q-card-actions>
</q-card>
</q-dialog>
</template>
</div>
</template>
<script setup lang="ts">
import { useQuasar } from 'quasar';
import {
instructorService,
type CourseDetailResponse,
type ChapterResponse,
type AnnouncementResponse,
type CreateAnnouncementRequest
} from '~/services/instructor.service';
definePageMeta({
layout: 'instructor',
middleware: ['auth']
});
const $q = useQuasar();
const route = useRoute();
// Data
const course = ref<CourseDetailResponse | null>(null);
const loading = ref(true);
const activeTab = ref('structure');
// Announcements data
const announcements = ref<AnnouncementResponse[]>([]);
const loadingAnnouncements = ref(false);
const showAnnouncementDialog = ref(false);
const editingAnnouncement = ref<AnnouncementResponse | null>(null);
const savingAnnouncement = ref(false);
const announcementForm = ref<CreateAnnouncementRequest>({
title: { th: '', en: '' },
content: { th: '', en: '' },
status: 'DRAFT',
is_pinned: false
});
// Attachment handling
const fileInputRef = ref<HTMLInputElement | null>(null);
const uploadingAttachment = ref(false);
const deletingAttachmentId = ref<number | null>(null);
const pendingFiles = ref<File[]>([]);
// Computed
const totalLessons = computed(() => {
if (!course.value) return 0;
return course.value.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0);
});
const sortedChapters = computed(() => {
if (!course.value) return [];
return course.value.chapters.slice().sort((a, b) => a.sort_order - b.sort_order);
});
// Methods
const fetchCourse = async () => {
loading.value = true;
try {
const courseId = parseInt(route.params.id as string);
course.value = await instructorService.getCourseById(courseId);
} catch (error) {
$q.notify({
type: 'negative',
message: 'ไม่สามารถโหลดข้อมูลหลักสูตรได้',
position: 'top'
});
navigateTo('/instructor/courses');
} finally {
loading.value = false;
}
};
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
APPROVED: 'green',
PENDING: 'orange',
DRAFT: 'grey',
REJECTED: 'red'
};
return colors[status] || 'grey';
};
const getStatusLabel = (status: string) => {
const labels: Record<string, string> = {
APPROVED: 'อนุมัติแล้ว',
PENDING: 'รอตรวจสอบ',
DRAFT: 'แบบร่าง',
REJECTED: 'ถูกปฏิเสธ'
};
return labels[status] || status;
};
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',
QUIZ: 'quiz',
DOCUMENT: 'description'
};
return icons[type] || 'article';
};
const getLessonIconColor = (type: string) => {
const colors: Record<string, string> = {
VIDEO: 'primary',
QUIZ: 'orange',
DOCUMENT: 'grey'
};
return colors[type] || 'grey';
};
const requestApproval = () => {
$q.dialog({
title: 'ขออนุมัติหลักสูตร',
message: 'ยืนยันการขออนุมัติหลักสูตรนี้?',
cancel: true,
persistent: true
}).onOk(async () => {
try {
const courseId = parseInt(route.params.id as string);
await instructorService.sendForReview(courseId);
$q.notify({
type: 'positive',
message: 'ส่งคำขออนุมัติแล้ว',
position: 'top'
});
// Refresh course data
fetchCourse();
} catch (error: any) {
$q.notify({
type: 'negative',
message: error.data?.error?.message || 'ไม่สามารถส่งคำขอได้',
position: 'top'
});
}
});
};
// Announcements methods
const fetchAnnouncements = async () => {
loadingAnnouncements.value = true;
try {
const courseId = parseInt(route.params.id as string);
announcements.value = await instructorService.getAnnouncements(courseId);
} catch (error) {
$q.notify({
type: 'negative',
message: 'ไม่สามารถโหลดข้อมูลประกาศได้',
position: 'top'
});
} finally {
loadingAnnouncements.value = false;
}
};
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('th-TH', {
day: 'numeric',
month: 'short',
year: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const openAnnouncementDialog = (announcement?: AnnouncementResponse) => {
if (announcement) {
editingAnnouncement.value = announcement;
announcementForm.value = {
title: { ...announcement.title },
content: { ...announcement.content },
status: announcement.status,
is_pinned: announcement.is_pinned
};
} else {
editingAnnouncement.value = null;
announcementForm.value = {
title: { th: '', en: '' },
content: { th: '', en: '' },
status: 'DRAFT',
is_pinned: false
};
}
pendingFiles.value = [];
showAnnouncementDialog.value = true;
};
const saveAnnouncement = async () => {
if (!announcementForm.value.title.th || !announcementForm.value.content.th) {
$q.notify({
type: 'warning',
message: 'กรุณากรอกหัวข้อและเนื้อหา',
position: 'top'
});
return;
}
savingAnnouncement.value = true;
try {
const courseId = parseInt(route.params.id as string);
if (editingAnnouncement.value) {
await instructorService.updateAnnouncement(courseId, editingAnnouncement.value.id, announcementForm.value);
$q.notify({
type: 'positive',
message: 'บันทึกประกาศสำเร็จ',
position: 'top'
});
} else {
// Create announcement with files
await instructorService.createAnnouncement(
courseId,
announcementForm.value,
pendingFiles.value.length > 0 ? pendingFiles.value : undefined
);
pendingFiles.value = [];
$q.notify({
type: 'positive',
message: 'สร้างประกาศสำเร็จ',
position: 'top'
});
}
showAnnouncementDialog.value = false;
fetchAnnouncements();
} catch (error) {
$q.notify({
type: 'negative',
message: 'เกิดข้อผิดพลาด',
position: 'top'
});
} finally {
savingAnnouncement.value = false;
}
};
const confirmDeleteAnnouncement = (announcement: AnnouncementResponse) => {
$q.dialog({
title: 'ยืนยันการลบ',
message: `คุณต้องการลบประกาศ "${announcement.title.th}" หรือไม่?`,
cancel: true,
persistent: true
}).onOk(async () => {
try {
const courseId = parseInt(route.params.id as string);
await instructorService.deleteAnnouncement(courseId, announcement.id);
$q.notify({
type: 'positive',
message: 'ลบประกาศสำเร็จ',
position: 'top'
});
fetchAnnouncements();
} catch (error) {
$q.notify({
type: 'negative',
message: 'เกิดข้อผิดพลาดในการลบประกาศ',
position: 'top'
});
}
});
};
// Attachment handling methods
const triggerFileInput = () => {
fileInputRef.value?.click();
};
const handleFileUpload = async (event: Event) => {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
// If editing existing announcement, upload immediately
if (editingAnnouncement.value) {
uploadingAttachment.value = true;
try {
const courseId = parseInt(route.params.id as string);
const updated = await instructorService.uploadAnnouncementAttachment(
courseId,
editingAnnouncement.value.id,
file
);
editingAnnouncement.value = updated;
$q.notify({
type: 'positive',
message: 'อัพโหลดไฟล์สำเร็จ',
position: 'top'
});
fetchAnnouncements();
} catch (error) {
$q.notify({
type: 'negative',
message: 'เกิดข้อผิดพลาดในการอัพโหลดไฟล์',
position: 'top'
});
} finally {
uploadingAttachment.value = false;
input.value = '';
}
} else {
// If creating new announcement, add to pending files
pendingFiles.value.push(file);
input.value = '';
}
};
const removePendingFile = (index: number) => {
pendingFiles.value.splice(index, 1);
};
const deleteAttachment = async (attachmentId: number) => {
if (!editingAnnouncement.value) return;
deletingAttachmentId.value = attachmentId;
try {
const courseId = parseInt(route.params.id as string);
await instructorService.deleteAnnouncementAttachment(
courseId,
editingAnnouncement.value.id,
attachmentId
);
// Remove from local state
editingAnnouncement.value.attachments = editingAnnouncement.value.attachments.filter(
a => a.id !== attachmentId
);
$q.notify({
type: 'positive',
message: 'ลบไฟล์สำเร็จ',
position: 'top'
});
fetchAnnouncements();
} catch (error) {
$q.notify({
type: 'negative',
message: 'เกิดข้อผิดพลาดในการลบไฟล์',
position: 'top'
});
} finally {
deletingAttachmentId.value = null;
}
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// Watch for tab change to load announcements
watch(activeTab, (newTab) => {
if (newTab === 'announcements' && announcements.value.length === 0) {
fetchAnnouncements();
}
});
// Lifecycle
onMounted(() => {
fetchCourse();
});
</script>