feat: Implement instructor course and lesson management with dedicated views for quizzes, videos, and admin categories.
This commit is contained in:
parent
344e1e4341
commit
878a17f922
9 changed files with 903 additions and 399 deletions
|
|
@ -25,6 +25,7 @@
|
|||
outlined
|
||||
dense
|
||||
bg-color="grey-1"
|
||||
hide-bottom-space
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="search" />
|
||||
|
|
@ -105,12 +106,16 @@
|
|||
label="ชื่อ (ภาษาไทย)"
|
||||
outlined
|
||||
:rules="[val => !!val || 'กรุณากรอกชื่อ']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
/>
|
||||
<q-input
|
||||
v-model="form.name.en"
|
||||
label="ชื่อ (English)"
|
||||
outlined
|
||||
:rules="[val => !!val || 'Please enter name']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -121,6 +126,8 @@
|
|||
class="mb-4"
|
||||
hint="ใช้สำหรับ URL เช่น web-development"
|
||||
:rules="[val => !!val || 'กรุณากรอก slug']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
/>
|
||||
|
||||
<q-input
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@
|
|||
label="ชื่อแบบทดสอบ (ภาษาไทย) *"
|
||||
outlined
|
||||
:rules="[val => !!val || 'กรุณากรอกชื่อแบบทดสอบ']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
/>
|
||||
|
||||
<!-- Title English -->
|
||||
|
|
@ -317,7 +319,7 @@ const savingSettings = ref(false);
|
|||
const saveQuizSettings = async () => {
|
||||
savingSettings.value = true;
|
||||
try {
|
||||
await instructorService.updateQuizSettings(courseId, chapterId, lessonId, {
|
||||
const response = await instructorService.updateQuizSettings(courseId, chapterId, lessonId, {
|
||||
title: form.value.title,
|
||||
description: form.value.content,
|
||||
passing_score: quizSettings.value.passing_score,
|
||||
|
|
@ -326,10 +328,10 @@ const saveQuizSettings = async () => {
|
|||
shuffle_choices: quizSettings.value.shuffle_choices,
|
||||
show_answers_after_completion: quizSettings.value.show_answers_after_completion
|
||||
});
|
||||
$q.notify({ type: 'positive', message: 'บันทึกการตั้งค่าสำเร็จ', position: 'top' });
|
||||
$q.notify({ type: 'positive', message: response.message, position: 'top' });
|
||||
} catch (error) {
|
||||
console.error('Failed to save quiz settings:', error);
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถบันทึกการตั้งค่าได้', position: 'top' });
|
||||
$q.notify({ type: 'negative', message: (error as any).data?.error?.message || (error as any).data?.message || 'ไม่สามารถบันทึกการตั้งค่าได้', position: 'top' });
|
||||
} finally {
|
||||
savingSettings.value = false;
|
||||
}
|
||||
|
|
@ -345,14 +347,14 @@ const onDragEnd = async (event: { oldIndex: number; newIndex: number }) => {
|
|||
|
||||
try {
|
||||
// Call API with new position (1-indexed)
|
||||
await instructorService.reorderQuestion(courseId, chapterId, lessonId, question.id, newIndex + 1);
|
||||
$q.notify({ type: 'positive', message: 'เรียงลำดับคำถามสำเร็จ', position: 'top' });
|
||||
const response = await instructorService.reorderQuestion(courseId, chapterId, lessonId, question.id, newIndex + 1);
|
||||
$q.notify({ type: 'positive', message: response.message, position: 'top' });
|
||||
} catch (error) {
|
||||
console.error('Failed to reorder question:', error);
|
||||
// Revert on error - swap back
|
||||
const [item] = questions.value.splice(newIndex, 1);
|
||||
questions.value.splice(oldIndex, 0, item);
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถเรียงลำดับได้', position: 'top' });
|
||||
$q.notify({ type: 'negative', message: (error as any).data?.error?.message || (error as any).data?.message || 'ไม่สามารถเรียงลำดับได้', position: 'top' });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -423,8 +425,8 @@ const fetchLesson = async () => {
|
|||
const saveLesson = async () => {
|
||||
saving.value = true;
|
||||
try {
|
||||
await instructorService.updateLesson(courseId, chapterId, lessonId, form.value);
|
||||
$q.notify({ type: 'positive', message: 'บันทึกสำเร็จ', position: 'top' });
|
||||
const response = await instructorService.updateLesson(courseId, chapterId, lessonId, form.value);
|
||||
$q.notify({ type: 'positive', message: response.message, position: 'top' });
|
||||
navigateTo(`/instructor/courses/${courseId}/structure`);
|
||||
} catch (error) {
|
||||
console.error('Failed to save lesson:', error);
|
||||
|
|
@ -462,13 +464,15 @@ const removeQuestion = async (index: number) => {
|
|||
}).onOk(async () => {
|
||||
try {
|
||||
if (question.id) {
|
||||
await instructorService.deleteQuestion(courseId, chapterId, lessonId, question.id);
|
||||
const response = await instructorService.deleteQuestion(courseId, chapterId, lessonId, question.id);
|
||||
$q.notify({ type: 'positive', message: response.message, position: 'top' });
|
||||
} else {
|
||||
$q.notify({ type: 'positive', message: 'ลบคำถามสำเร็จ', position: 'top' });
|
||||
}
|
||||
questions.value.splice(index, 1);
|
||||
$q.notify({ type: 'positive', message: 'ลบคำถามสำเร็จ', position: 'top' });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete question:', error);
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถลบคำถามได้', position: 'top' });
|
||||
$q.notify({ type: 'negative', message: (error as any).data?.error?.message || (error as any).data?.message || 'ไม่สามารถลบคำถามได้', position: 'top' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -501,16 +505,16 @@ const saveQuestion = async () => {
|
|||
|
||||
if (editingQuestionIndex.value !== null && questions.value[editingQuestionIndex.value].id) {
|
||||
// Update existing question
|
||||
await instructorService.updateQuestion(
|
||||
const response = await instructorService.updateQuestion(
|
||||
courseId, chapterId, lessonId,
|
||||
questions.value[editingQuestionIndex.value].id!,
|
||||
questionData
|
||||
);
|
||||
$q.notify({ type: 'positive', message: 'แก้ไขคำถามสำเร็จ', position: 'top' });
|
||||
$q.notify({ type: 'positive', message: response.message, position: 'top' });
|
||||
} else {
|
||||
// Create new question
|
||||
await instructorService.createQuestion(courseId, chapterId, lessonId, questionData);
|
||||
$q.notify({ type: 'positive', message: 'เพิ่มคำถามสำเร็จ', position: 'top' });
|
||||
const response = await instructorService.createQuestion(courseId, chapterId, lessonId, questionData);
|
||||
$q.notify({ type: 'positive', message: response.message, position: 'top' });
|
||||
}
|
||||
|
||||
questionDialog.value = false;
|
||||
|
|
@ -518,7 +522,7 @@ const saveQuestion = async () => {
|
|||
await fetchLesson();
|
||||
} catch (error) {
|
||||
console.error('Failed to save question:', error);
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถบันทึกคำถามได้', position: 'top' });
|
||||
$q.notify({ type: 'negative', message: (error as any).data?.error?.message || (error as any).data?.message || 'ไม่สามารถบันทึกคำถามได้', position: 'top' });
|
||||
} finally {
|
||||
savingQuestion.value = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@
|
|||
label="ชื่อบทเรียน (ภาษาไทย) *"
|
||||
outlined
|
||||
:rules="[val => !!val || 'กรุณากรอกชื่อบทเรียน']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
/>
|
||||
|
||||
<!-- Title English -->
|
||||
|
|
@ -255,12 +257,12 @@ const fetchLesson = async () => {
|
|||
const saveLesson = async () => {
|
||||
saving.value = true;
|
||||
try {
|
||||
await instructorService.updateLesson(courseId, chapterId, lessonId, form.value);
|
||||
$q.notify({ type: 'positive', message: 'บันทึกสำเร็จ', position: 'top' });
|
||||
const response = await instructorService.updateLesson(courseId, chapterId, lessonId, form.value);
|
||||
$q.notify({ type: 'positive', message: response.message, position: 'top' });
|
||||
navigateTo(`/instructor/courses/${courseId}/structure`);
|
||||
} catch (error) {
|
||||
console.error('Failed to save lesson:', error);
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถบันทึกได้', position: 'top' });
|
||||
$q.notify({ type: 'negative', message: (error as any).data?.error?.message || (error as any).data?.message || 'ไม่สามารถบันทึกได้', position: 'top' });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
|
|
@ -304,17 +306,18 @@ const uploadVideo = async () => {
|
|||
|
||||
uploadingVideo.value = true;
|
||||
try {
|
||||
let response;
|
||||
if (lesson.value?.video_url) {
|
||||
await instructorService.updateLessonVideo(courseId, chapterId, lessonId, selectedVideo.value);
|
||||
response = await instructorService.updateLessonVideo(courseId, chapterId, lessonId, selectedVideo.value);
|
||||
} else {
|
||||
await instructorService.uploadLessonVideo(courseId, chapterId, lessonId, selectedVideo.value);
|
||||
response = await instructorService.uploadLessonVideo(courseId, chapterId, lessonId, selectedVideo.value);
|
||||
}
|
||||
$q.notify({ type: 'positive', message: 'อัพโหลดวิดีโอสำเร็จ', position: 'top' });
|
||||
$q.notify({ type: 'positive', message: response.message, position: 'top' });
|
||||
selectedVideo.value = null;
|
||||
await fetchLesson();
|
||||
} catch (error) {
|
||||
console.error('Failed to upload video:', error);
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถอัพโหลดวิดีโอได้', position: 'top' });
|
||||
$q.notify({ type: 'negative', message: (error as any).data?.error?.message || (error as any).data?.message || 'ไม่สามารถอัพโหลดวิดีโอได้', position: 'top' });
|
||||
} finally {
|
||||
uploadingVideo.value = false;
|
||||
}
|
||||
|
|
@ -336,12 +339,12 @@ const handleAttachmentSelect = async (event: Event) => {
|
|||
const files = Array.from(target.files);
|
||||
uploadingAttachment.value = true;
|
||||
try {
|
||||
await instructorService.addAttachments(courseId, chapterId, lessonId, files);
|
||||
$q.notify({ type: 'positive', message: 'อัพโหลดไฟล์แนบสำเร็จ', position: 'top' });
|
||||
const response = await instructorService.addAttachments(courseId, chapterId, lessonId, files);
|
||||
$q.notify({ type: 'positive', message: response.message, position: 'top' });
|
||||
await fetchLesson();
|
||||
} catch (error) {
|
||||
console.error('Failed to upload attachments:', error);
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถอัพโหลดไฟล์แนบได้', position: 'top' });
|
||||
$q.notify({ type: 'negative', message: (error as any).data?.error?.message || (error as any).data?.message || 'ไม่สามารถอัพโหลดไฟล์แนบได้', position: 'top' });
|
||||
} finally {
|
||||
uploadingAttachment.value = false;
|
||||
target.value = '';
|
||||
|
|
@ -352,12 +355,12 @@ const handleAttachmentSelect = async (event: Event) => {
|
|||
const deleteAttachment = async (attachmentId: number) => {
|
||||
deletingAttachmentId.value = attachmentId;
|
||||
try {
|
||||
await instructorService.deleteAttachment(courseId, chapterId, lessonId, attachmentId);
|
||||
$q.notify({ type: 'positive', message: 'ลบไฟล์แนบสำเร็จ', position: 'top' });
|
||||
const response = await instructorService.deleteAttachment(courseId, chapterId, lessonId, attachmentId);
|
||||
$q.notify({ type: 'positive', message: response.message, position: 'top' });
|
||||
await fetchLesson();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete attachment:', error);
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถลบไฟล์แนบได้', position: 'top' });
|
||||
$q.notify({ type: 'negative', message: (error as any).data?.error?.message || (error as any).data?.message || 'ไม่สามารถลบไฟล์แนบได้', position: 'top' });
|
||||
} finally {
|
||||
deletingAttachmentId.value = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,12 +31,16 @@
|
|||
label="ชื่อหลักสูตร (ภาษาไทย) *"
|
||||
outlined
|
||||
:rules="[val => !!val || 'กรุณากรอกชื่อหลักสูตร']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
/>
|
||||
<q-input
|
||||
v-model="form.title.en"
|
||||
label="ชื่อหลักสูตร (English) *"
|
||||
outlined
|
||||
:rules="[val => !!val || 'Please enter course title']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -47,6 +51,8 @@
|
|||
outlined
|
||||
hint="ใช้สำหรับ URL เช่น javascript-basics"
|
||||
:rules="[val => !!val || 'กรุณากรอก slug']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
/>
|
||||
<q-select
|
||||
v-model="form.category_id"
|
||||
|
|
@ -99,6 +105,8 @@
|
|||
outlined
|
||||
prefix="฿"
|
||||
:rules="[val => form.is_free || val > 0 || 'กรุณากรอกราคา']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
/>
|
||||
<q-toggle
|
||||
v-model="form.have_certificate"
|
||||
|
|
@ -107,43 +115,6 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<!-- Thumbnail -->
|
||||
<q-separator class="my-6" />
|
||||
<h2 class="text-lg font-semibold text-primary-600 mb-4">รูปภาพปก</h2>
|
||||
|
||||
<div class="mb-6">
|
||||
<div class="flex flex-col items-center justify-center border-2 border-dashed border-gray-300 rounded-lg p-6 bg-gray-50 hover:bg-gray-100 transition-colors cursor-pointer" @click="triggerThumbnailUpload">
|
||||
<div v-if="form.thumbnail_url" class="relative group w-full max-w-md aspect-video">
|
||||
<img
|
||||
:src="form.thumbnail_url"
|
||||
:key="form.thumbnail_url"
|
||||
alt="Thumbnail preview"
|
||||
class="w-full h-full object-cover rounded-lg shadow-sm"
|
||||
@error="form.thumbnail_url = ''"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center rounded-lg">
|
||||
<q-icon name="photo_camera" color="white" size="40px" />
|
||||
<span class="text-white ml-2">เปลี่ยนรูปภาพ</span>
|
||||
</div>
|
||||
<div v-if="uploadingThumbnail" class="absolute inset-0 bg-white/80 flex items-center justify-center rounded-lg">
|
||||
<q-spinner color="primary" size="2em" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-8">
|
||||
<q-icon name="add_photo_alternate" size="48px" class="text-gray-400 mb-2" />
|
||||
<div class="text-gray-600 font-medium">คลิกเพื่ออัพโหลดรูปภาพปก</div>
|
||||
<div class="text-xs text-gray-400 mt-1">ขนาดแนะนำ 1280x720 (16:9) สูงสุด 5MB</div>
|
||||
</div>
|
||||
<input
|
||||
ref="thumbnailInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="handleThumbnailUpload"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 mt-8">
|
||||
<q-btn
|
||||
|
|
@ -180,8 +151,6 @@ const route = useRoute();
|
|||
// Data
|
||||
const initialLoading = ref(true);
|
||||
const saving = ref(false);
|
||||
const uploadingThumbnail = ref(false);
|
||||
const thumbnailInputRef = ref<HTMLInputElement | null>(null);
|
||||
const categories = ref<CategoryResponse[]>([]);
|
||||
|
||||
// Form
|
||||
|
|
@ -251,11 +220,15 @@ const handleSubmit = async () => {
|
|||
form.value.price = 0;
|
||||
}
|
||||
|
||||
await instructorService.updateCourse(courseId, form.value);
|
||||
// Create payload and remove thumbnail_url to avoid overwriting it
|
||||
const payload = { ...form.value };
|
||||
delete (payload as any).thumbnail_url;
|
||||
|
||||
const response = await instructorService.updateCourse(courseId, payload);
|
||||
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'บันทึกการแก้ไขสำเร็จ',
|
||||
message: response.message,
|
||||
position: 'top'
|
||||
});
|
||||
|
||||
|
|
@ -263,7 +236,7 @@ const handleSubmit = async () => {
|
|||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่',
|
||||
message: error.data?.error?.message || error.data?.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -271,76 +244,6 @@ const handleSubmit = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
const triggerThumbnailUpload = () => {
|
||||
thumbnailInputRef.value?.click();
|
||||
};
|
||||
|
||||
const handleThumbnailUpload = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
$q.notify({
|
||||
type: 'warning',
|
||||
message: 'กรุณาเลือกไฟล์รูปภาพเท่านั้น',
|
||||
position: 'top'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
$q.notify({
|
||||
type: 'warning',
|
||||
message: 'ไฟล์มีขนาดใหญ่เกิน 5MB',
|
||||
position: 'top'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
uploadingThumbnail.value = true;
|
||||
try {
|
||||
const courseId = parseInt(route.params.id as string);
|
||||
// Clear current thumbnail first to force complete re-mount
|
||||
form.value.thumbnail_url = null;
|
||||
|
||||
// Upload the file
|
||||
await instructorService.uploadCourseThumbnail(courseId, file);
|
||||
|
||||
// Wait for Vue to unmount old img
|
||||
await nextTick();
|
||||
|
||||
// Re-fetch course data to get fresh presigned URL from backend
|
||||
const updatedCourse = await instructorService.getCourseById(courseId);
|
||||
|
||||
// Add cache buster to force reload
|
||||
if (updatedCourse.thumbnail_url) {
|
||||
// For presigned URLs, we cannot append query parameters as it invalidates the signature
|
||||
// The presigned URL itself should be unique enough or handle caching differently
|
||||
form.value.thumbnail_url = updatedCourse.thumbnail_url;
|
||||
}
|
||||
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'อัพโหลดรูปภาพปกเรียบร้อย',
|
||||
position: 'top'
|
||||
});
|
||||
|
||||
// Clear input value to allow re-uploading same file if needed
|
||||
input.value = '';
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.message || 'เกิดข้อผิดพลาดในการอัพโหลดรูปภาพ',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
uploadingThumbnail.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
fetchData();
|
||||
|
|
|
|||
|
|
@ -10,14 +10,42 @@
|
|||
<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">
|
||||
<!-- Thumbnail -->
|
||||
<div
|
||||
class="w-full md:w-48 h-32 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0 relative group cursor-pointer overflow-hidden border border-gray-200"
|
||||
@click="triggerThumbnailUpload"
|
||||
>
|
||||
<img
|
||||
v-if="course.thumbnail_url"
|
||||
:src="course.thumbnail_url"
|
||||
:alt="course.title.th"
|
||||
class="w-full h-full object-cover rounded-lg"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
<div v-else class="flex flex-col items-center">
|
||||
<q-icon name="image" size="30px" color="grey-5" />
|
||||
<span class="text-grey-6 text-xs mt-1">อัพโหลดรูป</span>
|
||||
</div>
|
||||
|
||||
<!-- Overlay -->
|
||||
<div class="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col items-center justify-center">
|
||||
<q-icon name="photo_camera" color="white" size="24px" />
|
||||
<span class="text-white text-xs mt-1">เปลี่ยนรูป</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="uploadingThumbnail" class="absolute inset-0 bg-white/80 flex items-center justify-center">
|
||||
<q-spinner color="primary" size="2em" />
|
||||
</div>
|
||||
|
||||
<!-- Hidden Input -->
|
||||
<input
|
||||
ref="thumbnailInputRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@click.stop
|
||||
@change="handleThumbnailUpload"
|
||||
/>
|
||||
<span v-else class="text-white text-sm">รูปหลักสูตร</span>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
|
|
@ -79,6 +107,7 @@
|
|||
>
|
||||
<q-tab name="structure" icon="list" label="โครงสร้าง" />
|
||||
<q-tab name="students" icon="people" label="ผู้เรียน" />
|
||||
<q-tab name="instructors" icon="manage_accounts" label="ผู้สอน" />
|
||||
<q-tab name="quiz" icon="quiz" label="ผลการทดสอบ" />
|
||||
<q-tab name="announcements" icon="campaign" label="ประกาศ" />
|
||||
</q-tabs>
|
||||
|
|
@ -157,6 +186,65 @@
|
|||
</div>
|
||||
</q-tab-panel>
|
||||
|
||||
|
||||
|
||||
<!-- Instructors Tab -->
|
||||
<q-tab-panel name="instructors" 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" icon="person_add" label="เพิ่มผู้สอน" @click="showAddInstructorDialog = true" />
|
||||
</div>
|
||||
|
||||
<div v-if="loadingInstructors" 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>
|
||||
<q-btn flat dense round icon="more_vert">
|
||||
<q-menu>
|
||||
<q-item v-if="!instructor.is_primary" clickable v-close-popup @click="setPrimaryInstructor(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>
|
||||
</q-tab-panel>
|
||||
|
||||
<!-- Quiz Results Tab -->
|
||||
<q-tab-panel name="quiz" class="p-6">
|
||||
<div class="text-center py-10 text-gray-500">
|
||||
|
|
@ -171,7 +259,7 @@
|
|||
<h2 class="text-xl font-semibold text-gray-900">ประกาศ</h2>
|
||||
<q-btn
|
||||
color="primary"
|
||||
label="+ สร้างประกาศ"
|
||||
label="สร้างประกาศ"
|
||||
icon="add"
|
||||
@click="openAnnouncementDialog()"
|
||||
/>
|
||||
|
|
@ -243,6 +331,8 @@
|
|||
outlined
|
||||
label="หัวข้อ (ภาษาไทย) *"
|
||||
:rules="[val => !!val || 'กรุณากรอกหัวข้อ']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
/>
|
||||
<q-input
|
||||
v-model="announcementForm.title.en"
|
||||
|
|
@ -256,6 +346,8 @@
|
|||
rows="4"
|
||||
label="เนื้อหา (ภาษาไทย) *"
|
||||
:rules="[val => !!val || 'กรุณากรอกเนื้อหา']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
/>
|
||||
<q-input
|
||||
v-model="announcementForm.content.en"
|
||||
|
|
@ -301,9 +393,9 @@
|
|||
</div>
|
||||
|
||||
<!-- Existing Attachments (edit mode) -->
|
||||
<div v-if="editingAnnouncement?.attachments?.length > 0" class="space-y-2 mb-2">
|
||||
<div v-if="editingAnnouncement?.attachments && editingAnnouncement.attachments.length > 0" class="space-y-2 mb-2">
|
||||
<div
|
||||
v-for="attachment in editingAnnouncement.attachments"
|
||||
v-for="attachment in editingAnnouncement?.attachments"
|
||||
:key="attachment.id"
|
||||
class="flex items-center justify-between bg-gray-50 rounded p-2"
|
||||
>
|
||||
|
|
@ -366,6 +458,56 @@
|
|||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<!-- Add Instructor Dialog -->
|
||||
<q-dialog v-model="showAddInstructorDialog">
|
||||
<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="filteredUsers"
|
||||
option-value="id"
|
||||
option-label="email"
|
||||
label="ค้นหาผู้ใช้ (Email หรือ Username)"
|
||||
use-input
|
||||
filled
|
||||
@filter="filterUsers"
|
||||
:loading="loadingUsers"
|
||||
>
|
||||
<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>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -377,8 +519,10 @@ import {
|
|||
type CourseDetailResponse,
|
||||
type ChapterResponse,
|
||||
type AnnouncementResponse,
|
||||
type CreateAnnouncementRequest
|
||||
type CreateAnnouncementRequest,
|
||||
type CourseInstructorResponse
|
||||
} from '~/services/instructor.service';
|
||||
import { adminService, type AdminUserResponse } from '~/services/admin.service';
|
||||
|
||||
definePageMeta({
|
||||
layout: 'instructor',
|
||||
|
|
@ -393,6 +537,10 @@ const course = ref<CourseDetailResponse | null>(null);
|
|||
const loading = ref(true);
|
||||
const activeTab = ref('structure');
|
||||
|
||||
// Thumbnail upload
|
||||
const uploadingThumbnail = ref(false);
|
||||
const thumbnailInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
// Announcements data
|
||||
const announcements = ref<AnnouncementResponse[]>([]);
|
||||
const loadingAnnouncements = ref(false);
|
||||
|
|
@ -406,6 +554,16 @@ const announcementForm = ref<CreateAnnouncementRequest>({
|
|||
is_pinned: false
|
||||
});
|
||||
|
||||
// Instructors data
|
||||
const instructors = ref<CourseInstructorResponse[]>([]);
|
||||
const loadingInstructors = ref(false);
|
||||
const showAddInstructorDialog = ref(false);
|
||||
const selectedUser = ref<AdminUserResponse | null>(null);
|
||||
const users = ref<AdminUserResponse[]>([]);
|
||||
const filteredUsers = ref<AdminUserResponse[]>([]);
|
||||
const loadingUsers = ref(false);
|
||||
const addingInstructor = ref(false);
|
||||
|
||||
// Attachment handling
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const uploadingAttachment = ref(false);
|
||||
|
|
@ -441,6 +599,72 @@ const fetchCourse = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
const triggerThumbnailUpload = () => {
|
||||
thumbnailInputRef.value?.click();
|
||||
};
|
||||
|
||||
const handleThumbnailUpload = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
$q.notify({
|
||||
type: 'warning',
|
||||
message: 'กรุณาเลือกไฟล์รูปภาพเท่านั้น',
|
||||
position: 'top'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
$q.notify({
|
||||
type: 'warning',
|
||||
message: 'ไฟล์มีขนาดใหญ่เกิน 5MB',
|
||||
position: 'top'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
uploadingThumbnail.value = true;
|
||||
try {
|
||||
const courseId = parseInt(route.params.id as string);
|
||||
|
||||
// Upload the file
|
||||
const response = await instructorService.uploadCourseThumbnail(courseId, file);
|
||||
|
||||
// Wait for Vue to unmount old img
|
||||
await nextTick();
|
||||
|
||||
// Re-fetch course data to get fresh presigned URL from backend
|
||||
const updatedCourse = await instructorService.getCourseById(courseId);
|
||||
|
||||
// Update local state
|
||||
if (course.value) {
|
||||
course.value.thumbnail_url = updatedCourse.thumbnail_url;
|
||||
}
|
||||
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: response.message,
|
||||
position: 'top'
|
||||
});
|
||||
|
||||
// Clear input value to allow re-uploading same file if needed
|
||||
input.value = '';
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.data?.error?.message || error.data?.message || 'เกิดข้อผิดพลาดในการอัพโหลดรูปภาพ',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
uploadingThumbnail.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
APPROVED: 'green',
|
||||
|
|
@ -496,10 +720,10 @@ const requestApproval = () => {
|
|||
}).onOk(async () => {
|
||||
try {
|
||||
const courseId = parseInt(route.params.id as string);
|
||||
await instructorService.sendForReview(courseId);
|
||||
const response = await instructorService.sendForReview(courseId);
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'ส่งคำขออนุมัติแล้ว',
|
||||
message: response.message,
|
||||
position: 'top'
|
||||
});
|
||||
// Refresh course data
|
||||
|
|
@ -514,6 +738,135 @@ const requestApproval = () => {
|
|||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
const fetchInstructors = async () => {
|
||||
loadingInstructors.value = true;
|
||||
try {
|
||||
const courseId = parseInt(route.params.id as string);
|
||||
instructors.value = await instructorService.getCourseInstructors(courseId);
|
||||
} catch (error) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'ไม่สามารถโหลดข้อมูลผู้สอนได้',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
loadingInstructors.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const filterUsers = async (val: string, update: (callback: () => void) => void) => {
|
||||
if (users.value.length === 0) {
|
||||
loadingUsers.value = true;
|
||||
try {
|
||||
users.value = await adminService.getUsers();
|
||||
} catch (error) {
|
||||
console.error('Failed to load users', error);
|
||||
} finally {
|
||||
loadingUsers.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
update(() => {
|
||||
const needle = val.toLowerCase();
|
||||
const existingInstructorIds = instructors.value.map(i => i.user_id);
|
||||
|
||||
filteredUsers.value = users.value.filter(v => {
|
||||
// Exclude existing instructors
|
||||
if (existingInstructorIds.includes(v.id)) return false;
|
||||
|
||||
// Filter by username or email
|
||||
return v.username.toLowerCase().indexOf(needle) > -1 ||
|
||||
v.email.toLowerCase().indexOf(needle) > -1;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const addInstructor = async () => {
|
||||
if (!selectedUser.value) return;
|
||||
|
||||
addingInstructor.value = true;
|
||||
try {
|
||||
const courseId = parseInt(route.params.id as string);
|
||||
const response = await instructorService.addInstructor(courseId, selectedUser.value.id);
|
||||
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: response.message,
|
||||
position: 'top'
|
||||
});
|
||||
|
||||
showAddInstructorDialog.value = false;
|
||||
selectedUser.value = null;
|
||||
fetchInstructors();
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.data?.error?.message || error.data?.message || 'ไม่สามารถเพิ่มผู้สอนได้',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
addingInstructor.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const removeInstructor = async (userId: number) => {
|
||||
$q.dialog({
|
||||
title: 'ยืนยันการลบ',
|
||||
message: 'คุณต้องการลบผู้สอนท่านนี้ใช่หรือไม่?',
|
||||
cancel: true,
|
||||
persistent: true
|
||||
}).onOk(async () => {
|
||||
try {
|
||||
const courseId = parseInt(route.params.id as string);
|
||||
const response = await instructorService.removeInstructor(courseId, userId);
|
||||
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: response.message,
|
||||
position: 'top'
|
||||
});
|
||||
|
||||
fetchInstructors();
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.data?.error?.message || error.data?.message || 'ไม่สามารถลบผู้สอนได้',
|
||||
position: 'top'
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const setPrimaryInstructor = async (userId: number) => {
|
||||
$q.dialog({
|
||||
title: 'ยืนยันการเปลี่ยนหัวหน้าผู้สอน',
|
||||
message: 'คุณต้องการตั้งให้ผู้สอนท่านนี้เป็นหัวหน้าผู้สอนใช่หรือไม่?',
|
||||
cancel: true,
|
||||
persistent: true
|
||||
}).onOk(async () => {
|
||||
try {
|
||||
const courseId = parseInt(route.params.id as string);
|
||||
const response = await instructorService.setPrimaryInstructor(courseId, userId);
|
||||
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: response.message,
|
||||
position: 'top'
|
||||
});
|
||||
|
||||
fetchInstructors();
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.data?.error?.message || error.data?.message || 'ไม่สามารถเปลี่ยนหัวหน้าผู้สอนได้',
|
||||
position: 'top'
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Announcements methods
|
||||
const fetchAnnouncements = async () => {
|
||||
loadingAnnouncements.value = true;
|
||||
|
|
@ -531,14 +884,19 @@ const fetchAnnouncements = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString('th-TH', {
|
||||
const formatDate = (date: string, includeTime = true) => {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
year: '2-digit'
|
||||
};
|
||||
|
||||
if (includeTime) {
|
||||
options.hour = '2-digit';
|
||||
options.minute = '2-digit';
|
||||
}
|
||||
|
||||
return new Date(date).toLocaleDateString('th-TH', options);
|
||||
};
|
||||
|
||||
const openAnnouncementDialog = (announcement?: AnnouncementResponse) => {
|
||||
|
|
@ -577,15 +935,15 @@ const saveAnnouncement = async () => {
|
|||
try {
|
||||
const courseId = parseInt(route.params.id as string);
|
||||
if (editingAnnouncement.value) {
|
||||
await instructorService.updateAnnouncement(courseId, editingAnnouncement.value.id, announcementForm.value);
|
||||
const response = await instructorService.updateAnnouncement(courseId, editingAnnouncement.value.id, announcementForm.value);
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'บันทึกประกาศสำเร็จ',
|
||||
message: response.message,
|
||||
position: 'top'
|
||||
});
|
||||
} else {
|
||||
// Create announcement with files
|
||||
await instructorService.createAnnouncement(
|
||||
const response = await instructorService.createAnnouncement(
|
||||
courseId,
|
||||
announcementForm.value,
|
||||
pendingFiles.value.length > 0 ? pendingFiles.value : undefined
|
||||
|
|
@ -594,7 +952,7 @@ const saveAnnouncement = async () => {
|
|||
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'สร้างประกาศสำเร็จ',
|
||||
message: response.message,
|
||||
position: 'top'
|
||||
});
|
||||
}
|
||||
|
|
@ -603,7 +961,7 @@ const saveAnnouncement = async () => {
|
|||
} catch (error) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'เกิดข้อผิดพลาด',
|
||||
message: (error as any).data?.error?.message || (error as any).data?.message || 'เกิดข้อผิดพลาด',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -620,17 +978,17 @@ const confirmDeleteAnnouncement = (announcement: AnnouncementResponse) => {
|
|||
}).onOk(async () => {
|
||||
try {
|
||||
const courseId = parseInt(route.params.id as string);
|
||||
await instructorService.deleteAnnouncement(courseId, announcement.id);
|
||||
const response = await instructorService.deleteAnnouncement(courseId, announcement.id);
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'ลบประกาศสำเร็จ',
|
||||
message: response.message,
|
||||
position: 'top'
|
||||
});
|
||||
fetchAnnouncements();
|
||||
} catch (error) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'เกิดข้อผิดพลาดในการลบประกาศ',
|
||||
message: (error as any).data?.error?.message || (error as any).data?.message || 'เกิดข้อผิดพลาดในการลบประกาศ',
|
||||
position: 'top'
|
||||
});
|
||||
}
|
||||
|
|
@ -657,17 +1015,26 @@ const handleFileUpload = async (event: Event) => {
|
|||
editingAnnouncement.value.id,
|
||||
file
|
||||
);
|
||||
editingAnnouncement.value = updated;
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'อัพโหลดไฟล์สำเร็จ',
|
||||
message: updated.message,
|
||||
position: 'top'
|
||||
});
|
||||
fetchAnnouncements();
|
||||
|
||||
// Refresh list to get complete object including attachments
|
||||
await fetchAnnouncements();
|
||||
|
||||
// Update the editing object from the fresh list
|
||||
if (editingAnnouncement.value) {
|
||||
const fresh = announcements.value.find(a => a.id === editingAnnouncement.value!.id);
|
||||
if (fresh) {
|
||||
editingAnnouncement.value = fresh;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'เกิดข้อผิดพลาดในการอัพโหลดไฟล์',
|
||||
message: (error as any).data?.error?.message || (error as any).data?.message || 'เกิดข้อผิดพลาดในการอัพโหลดไฟล์',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -691,7 +1058,7 @@ const deleteAttachment = async (attachmentId: number) => {
|
|||
deletingAttachmentId.value = attachmentId;
|
||||
try {
|
||||
const courseId = parseInt(route.params.id as string);
|
||||
await instructorService.deleteAnnouncementAttachment(
|
||||
const response = await instructorService.deleteAnnouncementAttachment(
|
||||
courseId,
|
||||
editingAnnouncement.value.id,
|
||||
attachmentId
|
||||
|
|
@ -702,14 +1069,14 @@ const deleteAttachment = async (attachmentId: number) => {
|
|||
);
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'ลบไฟล์สำเร็จ',
|
||||
message: response.message,
|
||||
position: 'top'
|
||||
});
|
||||
fetchAnnouncements();
|
||||
} catch (error) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'เกิดข้อผิดพลาดในการลบไฟล์',
|
||||
message: (error as any).data?.error?.message || (error as any).data?.message || 'เกิดข้อผิดพลาดในการลบไฟล์',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -729,6 +1096,8 @@ const formatFileSize = (bytes: number): string => {
|
|||
watch(activeTab, (newTab) => {
|
||||
if (newTab === 'announcements' && announcements.value.length === 0) {
|
||||
fetchAnnouncements();
|
||||
} else if (newTab === 'instructors' && instructors.value.length === 0) {
|
||||
fetchInstructors();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -149,7 +149,10 @@
|
|||
v-model="chapterForm.title.th"
|
||||
label="ชื่อบท (ภาษาไทย) *"
|
||||
outlined
|
||||
class="mb-4"
|
||||
:rules="[val => !!val || 'กรุณากรอกชื่อบท']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
/>
|
||||
<q-input
|
||||
v-model="chapterForm.title.en"
|
||||
|
|
@ -199,6 +202,8 @@
|
|||
label="ชื่อบทเรียน (ภาษาไทย) *"
|
||||
outlined
|
||||
:rules="[val => !!val || 'กรุณากรอกชื่อบทเรียน']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
/>
|
||||
<q-input
|
||||
v-model="lessonForm.title.en"
|
||||
|
|
@ -298,14 +303,14 @@ const onChapterDragEnd = async (event: { oldIndex: number; newIndex: number }) =
|
|||
if (!chapter) return;
|
||||
|
||||
try {
|
||||
await instructorService.reorderChapter(courseId.value, chapter.id, newIndex + 1);
|
||||
$q.notify({ type: 'positive', message: 'เรียงลำดับบทสำเร็จ', position: 'top' });
|
||||
} catch (error) {
|
||||
const response = await instructorService.reorderChapter(courseId.value, chapter.id, newIndex + 1);
|
||||
$q.notify({ type: 'positive', message: response.message, position: 'top' });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to reorder chapter:', error);
|
||||
// Revert
|
||||
const [item] = chapters.value.splice(newIndex, 1);
|
||||
chapters.value.splice(oldIndex, 0, item);
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถเรียงลำดับได้', position: 'top' });
|
||||
$q.notify({ type: 'negative', message: error.data?.error?.message || error.data?.message || 'ไม่สามารถเรียงลำดับได้', position: 'top' });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -317,14 +322,14 @@ const onLessonDragEnd = async (chapter: ChapterResponse & { expanded?: boolean }
|
|||
if (!lesson) return;
|
||||
|
||||
try {
|
||||
await instructorService.reorderLesson(courseId.value, chapter.id, lesson.id, newIndex + 1);
|
||||
$q.notify({ type: 'positive', message: 'เรียงลำดับบทเรียนสำเร็จ', position: 'top' });
|
||||
} catch (error) {
|
||||
const response = await instructorService.reorderLesson(courseId.value, chapter.id, lesson.id, newIndex + 1);
|
||||
$q.notify({ type: 'positive', message: response.message, position: 'top' });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to reorder lesson:', error);
|
||||
// Revert
|
||||
const [item] = chapter.lessons.splice(newIndex, 1);
|
||||
chapter.lessons.splice(oldIndex, 0, item);
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถเรียงลำดับได้', position: 'top' });
|
||||
$q.notify({ type: 'negative', message: error.data?.error?.message || error.data?.message || 'ไม่สามารถเรียงลำดับได้', position: 'top' });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -429,21 +434,21 @@ const saveChapter = async () => {
|
|||
saving.value = true;
|
||||
try {
|
||||
if (editingChapter.value) {
|
||||
await instructorService.updateChapter(courseId.value, editingChapter.value.id, chapterForm.value);
|
||||
$q.notify({ type: 'positive', message: 'แก้ไขบทสำเร็จ', position: 'top' });
|
||||
const response = await instructorService.updateChapter(courseId.value, editingChapter.value.id, chapterForm.value);
|
||||
$q.notify({ type: 'positive', message: response.message, position: 'top' });
|
||||
} else {
|
||||
// Calculate sort_order for new chapter
|
||||
const sortOrder = chapters.value.length + 1;
|
||||
await instructorService.createChapter(courseId.value, {
|
||||
const response = await instructorService.createChapter(courseId.value, {
|
||||
...chapterForm.value,
|
||||
sort_order: sortOrder
|
||||
});
|
||||
$q.notify({ type: 'positive', message: 'เพิ่มบทสำเร็จ', position: 'top' });
|
||||
$q.notify({ type: 'positive', message: response.message, position: 'top' });
|
||||
}
|
||||
chapterDialog.value = false;
|
||||
fetchChapters();
|
||||
} catch (error) {
|
||||
$q.notify({ type: 'negative', message: 'เกิดข้อผิดพลาด', position: 'top' });
|
||||
} catch (error: any) {
|
||||
$q.notify({ type: 'negative', message: error.data?.error?.message || error.data?.message || 'เกิดข้อผิดพลาด', position: 'top' });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
|
|
@ -457,11 +462,11 @@ const confirmDeleteChapter = (chapter: ChapterResponse) => {
|
|||
persistent: true
|
||||
}).onOk(async () => {
|
||||
try {
|
||||
await instructorService.deleteChapter(courseId.value, chapter.id);
|
||||
$q.notify({ type: 'positive', message: 'ลบบทสำเร็จ', position: 'top' });
|
||||
const response = await instructorService.deleteChapter(courseId.value, chapter.id);
|
||||
$q.notify({ type: 'positive', message: response.message, position: 'top' });
|
||||
fetchChapters();
|
||||
} catch (error) {
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถลบได้', position: 'top' });
|
||||
} catch (error: any) {
|
||||
$q.notify({ type: 'negative', message: error.data?.error?.message || error.data?.message || 'ไม่สามารถลบได้', position: 'top' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -498,30 +503,30 @@ const saveLesson = async () => {
|
|||
title: lessonForm.value.title,
|
||||
content: lessonForm.value.content
|
||||
};
|
||||
await instructorService.updateLesson(
|
||||
const response = await instructorService.updateLesson(
|
||||
courseId.value,
|
||||
selectedChapter.value.id,
|
||||
editingLesson.value.id,
|
||||
updateData
|
||||
);
|
||||
$q.notify({ type: 'positive', message: 'แก้ไขบทเรียนสำเร็จ', position: 'top' });
|
||||
$q.notify({ type: 'positive', message: response.message, position: 'top' });
|
||||
} else {
|
||||
// Create - include type and sort_order
|
||||
const createData = {
|
||||
...lessonForm.value,
|
||||
sort_order: (selectedChapter.value.lessons?.length || 0) + 1
|
||||
};
|
||||
await instructorService.createLesson(
|
||||
const response = await instructorService.createLesson(
|
||||
courseId.value,
|
||||
selectedChapter.value.id,
|
||||
createData
|
||||
);
|
||||
$q.notify({ type: 'positive', message: 'เพิ่มบทเรียนสำเร็จ', position: 'top' });
|
||||
$q.notify({ type: 'positive', message: response.message, position: 'top' });
|
||||
}
|
||||
lessonDialog.value = false;
|
||||
fetchChapters();
|
||||
} catch (error) {
|
||||
$q.notify({ type: 'negative', message: 'เกิดข้อผิดพลาด', position: 'top' });
|
||||
} catch (error: any) {
|
||||
$q.notify({ type: 'negative', message: error.data?.error?.message || error.data?.message || 'เกิดข้อผิดพลาด', position: 'top' });
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
|
|
@ -535,11 +540,11 @@ const confirmDeleteLesson = (chapter: ChapterResponse, lesson: LessonResponse) =
|
|||
persistent: true
|
||||
}).onOk(async () => {
|
||||
try {
|
||||
await instructorService.deleteLesson(courseId.value, chapter.id, lesson.id);
|
||||
$q.notify({ type: 'positive', message: 'ลบบทเรียนสำเร็จ', position: 'top' });
|
||||
const response = await instructorService.deleteLesson(courseId.value, chapter.id, lesson.id);
|
||||
$q.notify({ type: 'positive', message: response.message, position: 'top' });
|
||||
fetchChapters();
|
||||
} catch (error) {
|
||||
$q.notify({ type: 'negative', message: 'ไม่สามารถลบได้', position: 'top' });
|
||||
} catch (error: any) {
|
||||
$q.notify({ type: 'negative', message: error.data?.error?.message || error.data?.message || 'ไม่สามารถลบได้', position: 'top' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,12 +26,16 @@
|
|||
label="ชื่อหลักสูตร (ภาษาไทย) *"
|
||||
outlined
|
||||
:rules="[val => !!val || 'กรุณากรอกชื่อหลักสูตร']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
/>
|
||||
<q-input
|
||||
v-model="form.title.en"
|
||||
label="ชื่อหลักสูตร (English) *"
|
||||
outlined
|
||||
:rules="[val => !!val || 'Please enter course title']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -42,6 +46,8 @@
|
|||
outlined
|
||||
hint="ใช้สำหรับ URL เช่น javascript-basics"
|
||||
:rules="[val => !!val || 'กรุณากรอก slug']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
/>
|
||||
<q-select
|
||||
v-model="form.category_id"
|
||||
|
|
@ -94,6 +100,8 @@
|
|||
outlined
|
||||
prefix="฿"
|
||||
:rules="[val => form.is_free || val > 0 || 'กรุณากรอกราคา']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
/>
|
||||
<q-toggle
|
||||
v-model="form.have_certificate"
|
||||
|
|
@ -102,32 +110,6 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<!-- Thumbnail -->
|
||||
<q-separator class="my-6" />
|
||||
<h2 class="text-lg font-semibold text-primary-600 mb-4">รูปภาพปก</h2>
|
||||
|
||||
<div class="mb-6">
|
||||
<q-input
|
||||
v-model="form.thumbnail_url"
|
||||
label="URL รูปภาพปก *"
|
||||
outlined
|
||||
:rules="[val => !!val || 'กรุณากรอก URL รูปภาพปก']"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="image" />
|
||||
</template>
|
||||
</q-input>
|
||||
</div>
|
||||
|
||||
<!-- Preview thumbnail if exists -->
|
||||
<div v-if="form.thumbnail_url" class="mb-6">
|
||||
<img
|
||||
:src="form.thumbnail_url"
|
||||
alt="Thumbnail preview"
|
||||
class="max-w-xs rounded-lg shadow"
|
||||
@error="form.thumbnail_url = ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3 mt-8">
|
||||
|
|
@ -172,7 +154,6 @@ const form = ref<CreateCourseRequest>({
|
|||
title: { th: '', en: '' },
|
||||
slug: '',
|
||||
description: { th: '', en: '' },
|
||||
thumbnail_url: null,
|
||||
price: 0,
|
||||
is_free: true,
|
||||
have_certificate: true
|
||||
|
|
@ -214,19 +195,25 @@ const handleSubmit = async () => {
|
|||
form.value.price = 0;
|
||||
}
|
||||
|
||||
await instructorService.createCourse(form.value);
|
||||
const response = await instructorService.createCourse(form.value);
|
||||
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: 'สร้างหลักสูตรสำเร็จ',
|
||||
message: response.message,
|
||||
position: 'top'
|
||||
});
|
||||
|
||||
navigateTo('/instructor/courses');
|
||||
// Redirect to course edit page
|
||||
// Note: Assuming response.data contains the created course with ID
|
||||
if (response.data && response.data.id) {
|
||||
router.push(`/instructor/courses/${response.data.id}/edit`);
|
||||
} else {
|
||||
navigateTo('/instructor/courses');
|
||||
}
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่',
|
||||
message: error.data?.error?.message || error.data?.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@
|
|||
outlined
|
||||
class="mb-4"
|
||||
:rules="[val => !!val || 'กรุณากรอกชื่อ']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="person" />
|
||||
|
|
@ -140,6 +142,8 @@
|
|||
outlined
|
||||
class="mb-4"
|
||||
:rules="[val => !!val || 'กรุณากรอกนามสกุล']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="person" />
|
||||
|
|
@ -151,6 +155,8 @@
|
|||
label="เบอร์โทร"
|
||||
outlined
|
||||
class="mb-4"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="phone" />
|
||||
|
|
@ -194,6 +200,8 @@
|
|||
outlined
|
||||
class="mb-4"
|
||||
:rules="[val => !!val || 'กรุณากรอกรหัสผ่านปัจจุบัน']"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="lock" />
|
||||
|
|
@ -217,6 +225,8 @@
|
|||
val => !!val || 'กรุณากรอกรหัสผ่านใหม่',
|
||||
val => val.length >= 6 || 'รหัสผ่านต้องมีอย่างน้อย 6 ตัวอักษร'
|
||||
]"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="lock" />
|
||||
|
|
@ -240,6 +250,8 @@
|
|||
val => !!val || 'กรุณายืนยันรหัสผ่าน',
|
||||
val => val === passwordForm.newPassword || 'รหัสผ่านไม่ตรงกัน'
|
||||
]"
|
||||
lazy-rules="ondemand"
|
||||
hide-bottom-space
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="lock" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue