feat: Introduce comprehensive course management features for admin, including recommended, pending, and detailed course views, and instructor course listing with a lesson preview component.
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 46s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 4s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 2s
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 46s
Build and Deploy Frontend Management to Dev Server / Deploy E-learning Frontend Management to Dev Server (push) Successful in 4s
Build and Deploy Frontend Management to Dev Server / Notify Deployment Status (push) Successful in 2s
This commit is contained in:
parent
0f92f0d00c
commit
f26a94076c
6 changed files with 141 additions and 23 deletions
|
|
@ -18,7 +18,16 @@
|
|||
<div v-else-if="lessonDetail" class="p-6 space-y-6">
|
||||
<!-- Video Player -->
|
||||
<div v-if="lesson.type === 'VIDEO' && lessonDetail.video_url" class="aspect-video bg-black rounded-lg overflow-hidden">
|
||||
<iframe
|
||||
v-if="isYoutubeUrl(lessonDetail.video_url)"
|
||||
:src="getYoutubeEmbedUrl(lessonDetail.video_url)"
|
||||
class="w-full h-full"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
<video
|
||||
v-else
|
||||
:src="lessonDetail.video_url"
|
||||
controls
|
||||
class="w-full h-full object-contain"
|
||||
|
|
@ -38,7 +47,7 @@
|
|||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<a
|
||||
v-for="file in lessonDetail.attachments"
|
||||
v-for="file in lessonDetail.attachments.filter(f => !['video/mp4', 'video/youtube'].includes(f.mime_type))"
|
||||
:key="file.id"
|
||||
:href="file.file_path"
|
||||
target="_blank"
|
||||
|
|
@ -175,6 +184,23 @@ const formatFileSize = (bytes: number) => {
|
|||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const isYoutubeUrl = (url: string) => {
|
||||
return url.includes('youtube.com') || url.includes('youtu.be');
|
||||
};
|
||||
|
||||
const getYoutubeEmbedUrl = (url: string) => {
|
||||
let videoId = '';
|
||||
|
||||
if (url.includes('youtu.be')) {
|
||||
videoId = url.split('/').pop()?.split('?')[0] || '';
|
||||
} else if (url.includes('youtube.com')) {
|
||||
const params = new URLSearchParams(url.split('?')[1]);
|
||||
videoId = params.get('v') || '';
|
||||
}
|
||||
|
||||
return `https://www.youtube.com/embed/${videoId}`;
|
||||
};
|
||||
|
||||
const fetchLessonDetail = async () => {
|
||||
// Always verify lesson and courseId exist
|
||||
if (!props.lesson || !props.courseId) return;
|
||||
|
|
|
|||
|
|
@ -212,12 +212,16 @@
|
|||
กรุณาระบุเหตุผลในการปฏิเสธคอร์ส "{{ course?.title.th }}"
|
||||
</p>
|
||||
<q-input
|
||||
ref="rejectInputRef"
|
||||
v-model="rejectReason"
|
||||
type="textarea"
|
||||
outlined
|
||||
rows="4"
|
||||
label="เหตุผล *"
|
||||
:rules="[val => !!val || 'กรุณาระบุเหตุผล']"
|
||||
:rules="[
|
||||
val => !!val || 'กรุณาระบุเหตุผล',
|
||||
val => (val && val.length >= 10) || 'ระบุเหตุผลอย่างน้อย 10 ตัวอักษร'
|
||||
]"
|
||||
hide-bottom-space
|
||||
lazy-rules="ondemand"
|
||||
/>
|
||||
|
|
@ -229,7 +233,6 @@
|
|||
label="ยืนยันการปฏิเสธ"
|
||||
color="negative"
|
||||
:loading="actionLoading"
|
||||
:disable="!rejectReason.trim()"
|
||||
@click="confirmReject"
|
||||
/>
|
||||
</q-card-actions>
|
||||
|
|
@ -239,7 +242,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuasar } from 'quasar';
|
||||
import { useQuasar, QInput } from 'quasar';
|
||||
import { adminService, type CourseDetailForReview } from '~/services/admin.service';
|
||||
|
||||
definePageMeta({
|
||||
|
|
@ -258,6 +261,7 @@ const error = ref('');
|
|||
const actionLoading = ref(false);
|
||||
const showRejectModal = ref(false);
|
||||
const rejectReason = ref('');
|
||||
const rejectInputRef = ref<QInput | null>(null);
|
||||
|
||||
// Computed
|
||||
const totalLessons = computed(() =>
|
||||
|
|
@ -415,7 +419,8 @@ const confirmApprove = () => {
|
|||
};
|
||||
|
||||
const confirmReject = async () => {
|
||||
if (!course.value || !rejectReason.value.trim()) return;
|
||||
rejectInputRef.value?.validate();
|
||||
if (rejectInputRef.value?.hasError || !course.value) return;
|
||||
|
||||
actionLoading.value = true;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -215,7 +215,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQuasar } from 'quasar';
|
||||
import { useQuasar, type QTableColumn } from 'quasar';
|
||||
import { adminService, type PendingCourse } from '~/services/admin.service';
|
||||
|
||||
definePageMeta({
|
||||
|
|
@ -232,12 +232,12 @@ const loading = ref(true);
|
|||
const searchQuery = ref('');
|
||||
const viewMode = ref('table');
|
||||
|
||||
const columns = [
|
||||
const columns: QTableColumn[] = [
|
||||
{ name: 'thumbnail', label: 'รูปปก', field: 'thumbnail', align: 'left' },
|
||||
{ name: 'title', label: 'ชื่อคอร์ส', field: (row: PendingCourse) => row.title.th, align: 'left', sortable: true },
|
||||
{ name: 'instructor', label: 'ผู้สอน', field: (row: PendingCourse) => getPrimaryInstructor(row), align: 'left', sortable: true },
|
||||
{ name: 'title', label: 'ชื่อคอร์ส', field: (row: any) => row.title.th, align: 'left', sortable: true },
|
||||
{ name: 'instructor', label: 'ผู้สอน', field: (row: any) => getPrimaryInstructor(row), align: 'left', sortable: true },
|
||||
{ name: 'stats', label: 'จำนวนบท', field: 'stats', align: 'center' },
|
||||
{ name: 'submitted_at', label: 'วันที่ส่ง', field: (row: PendingCourse) => row.latest_submission?.created_at, align: 'left', sortable: true },
|
||||
{ name: 'submitted_at', label: 'วันที่ส่ง', field: (row: any) => row.latest_submission?.created_at, align: 'left', sortable: true },
|
||||
{ name: 'actions', label: '', field: 'actions', align: 'center' }
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@
|
|||
<!-- View Details Dialog -->
|
||||
<q-dialog v-model="showDialog" maximized transition-show="slide-up" transition-hide="slide-down">
|
||||
<q-card>
|
||||
<q-bar class="bg-primary text-white">
|
||||
<q-bar class="bg-primary-500 text-white">
|
||||
<q-space />
|
||||
<q-btn dense flat icon="close" v-close-popup>
|
||||
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||
|
|
@ -153,7 +153,7 @@
|
|||
<!-- Category -->
|
||||
<div class="bg-gray-50 p-4 rounded-lg gap-2">
|
||||
<div class="font-bold mb-2">หมวดหมู่ (Category):</div>
|
||||
<div class="mb-2">{{ selectedCourse.category.name.th }} ({{ selectedCourse.category.name.en }})</div>
|
||||
<div class="text-gray-700 mb-2">{{ selectedCourse.category.name.th }} ({{ selectedCourse.category.name.en }})</div>
|
||||
</div>
|
||||
|
||||
<!-- Instructors -->
|
||||
|
|
@ -164,7 +164,7 @@
|
|||
<q-icon name="person" />
|
||||
</q-avatar>
|
||||
<div>
|
||||
<div class="font-medium">{{ inst.user.username }}</div>
|
||||
<div class="font-medium text-gray-700">{{ inst.user.username }}</div>
|
||||
<div class="text-xs text-gray-500" v-if="inst.is_primary">(Primary)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -237,7 +237,7 @@ const columns = [
|
|||
{ name: 'category', label: 'Category', field: (row: RecommendedCourse) => row.category.name.th, sortable: true, align: 'left' as const },
|
||||
{ name: 'price', label: 'Price', field: 'price', sortable: true, align: 'right' as const },
|
||||
{ name: 'is_recommended', label: 'Recommended', field: 'is_recommended', sortable: true, align: 'center' as const },
|
||||
//{ name: 'actions', label: 'Actions', field: 'actions', align: 'center' as const },
|
||||
{ name: 'actions', label: 'Actions', field: 'actions', align: 'center' as const },
|
||||
];
|
||||
|
||||
const fetchCourses = async () => {
|
||||
|
|
@ -248,7 +248,8 @@ const fetchCourses = async () => {
|
|||
console.error('Error fetching courses:', error);
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'ไม่สามารถโหลดข้อมูลคอร์สได้'
|
||||
message: 'ไม่สามารถโหลดข้อมูลคอร์สได้',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
|
|
@ -265,7 +266,8 @@ const viewCourse = async (id: number) => {
|
|||
console.error('Error fetching course details:', error);
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'ไม่สามารถโหลดข้อมูลรายละเอียดคอร์สได้'
|
||||
message: 'ไม่สามารถโหลดข้อมูลรายละเอียดคอร์สได้',
|
||||
position: 'top'
|
||||
});
|
||||
showDialog.value = false;
|
||||
} finally {
|
||||
|
|
@ -278,7 +280,8 @@ const handleToggleRecommendation = async (course: RecommendedCourse, isRecommend
|
|||
await adminService.toggleCourseRecommendation(course.id, isRecommended);
|
||||
$q.notify({
|
||||
type: 'positive',
|
||||
message: isRecommended ? 'เพิ่มคอร์สแนะนำสำร็จ' : 'ยกเลิกคอร์สแนะนำสำเร็จ'
|
||||
message: isRecommended ? 'เพิ่มคอร์สแนะนำสำร็จ' : 'ยกเลิกคอร์สแนะนำสำเร็จ',
|
||||
position: 'top'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error toggling recommendation:', error);
|
||||
|
|
@ -286,7 +289,8 @@ const handleToggleRecommendation = async (course: RecommendedCourse, isRecommend
|
|||
course.is_recommended = !isRecommended;
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: 'เกิดข้อผิดพลาดในการบันทึกข้อมูล'
|
||||
message: 'เกิดข้อผิดพลาดในการบันทึกข้อมูล',
|
||||
position: 'top'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -196,6 +196,47 @@
|
|||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Clone Course Dialog -->
|
||||
<q-dialog v-model="cloneDialog">
|
||||
<q-card style="min-width: 400px">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<div class="text-h6">ทำสำเนาหลักสูตร</div>
|
||||
<q-space />
|
||||
<q-btn icon="close" flat round dense v-close-popup />
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<div class="mb-4">
|
||||
กรุณาระบุชื่อสำหรับหลักสูตรใหม่
|
||||
</div>
|
||||
<q-input
|
||||
v-model="cloneCourseTitleTh"
|
||||
label="ชื่อหลักสูตร (ภาษาไทย)"
|
||||
outlined
|
||||
autofocus
|
||||
class="mb-4"
|
||||
:rules="[val => !!val || 'กรุณากรอกชื่อหลักสูตรภาษาไทย']"
|
||||
/>
|
||||
<q-input
|
||||
v-model="cloneCourseTitleEn"
|
||||
label="Course Name (English)"
|
||||
outlined
|
||||
:rules="[val => !!val || 'Please enter course name in English']"
|
||||
/>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right" class="text-primary q-pt-none q-pb-md q-px-md">
|
||||
<q-btn flat label="ยกเลิก" v-close-popup color="grey" />
|
||||
<q-btn
|
||||
label="ยืนยันการทำสำเนา"
|
||||
color="primary"
|
||||
@click="confirmClone"
|
||||
:loading="cloneLoading"
|
||||
/>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -296,15 +337,45 @@ const formatDate = (date: string) => {
|
|||
year: '2-digit'
|
||||
});
|
||||
};
|
||||
// Clone Dialog
|
||||
const cloneDialog = ref(false);
|
||||
const cloneLoading = ref(false);
|
||||
const cloneCourseTitleTh = ref('');
|
||||
const cloneCourseTitleEn = ref('');
|
||||
const courseToClone = ref<CourseResponse | null>(null);
|
||||
|
||||
const duplicateCourse = (course: CourseResponse) => {
|
||||
courseToClone.value = course;
|
||||
cloneCourseTitleTh.value = `${course.title.th} (Copy)`;
|
||||
cloneCourseTitleEn.value = `${course.title.en} (Copy)`;
|
||||
cloneDialog.value = true;
|
||||
};
|
||||
|
||||
const confirmClone = async () => {
|
||||
if (!courseToClone.value || !cloneCourseTitleTh.value || !cloneCourseTitleEn.value) return;
|
||||
|
||||
cloneLoading.value = true;
|
||||
try {
|
||||
const response = await instructorService.cloneCourse(courseToClone.value.id, cloneCourseTitleTh.value, cloneCourseTitleEn.value);
|
||||
$q.notify({
|
||||
type: 'info',
|
||||
message: `กำลังทำสำเนา "${course.title.th}"...`,
|
||||
type: 'positive',
|
||||
message: response.message || 'ทำสำเนาหลักสูตรสำเร็จ',
|
||||
position: 'top'
|
||||
});
|
||||
cloneDialog.value = false;
|
||||
fetchCourses(); // Refresh list
|
||||
} catch (error: any) {
|
||||
$q.notify({
|
||||
type: 'negative',
|
||||
message: error.data?.message || 'ไม่สามารถทำสำเนาหลักสูตรได้',
|
||||
position: 'top'
|
||||
});
|
||||
} finally {
|
||||
cloneLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const confirmDelete = (course: CourseResponse) => {
|
||||
$q.dialog({
|
||||
title: 'ยืนยันการลบ',
|
||||
|
|
|
|||
|
|
@ -305,6 +305,18 @@ export const instructorService = {
|
|||
return await authRequest<ApiResponse<void>>(`/api/instructors/courses/set-draft/${courseId}`, { method: 'POST' });
|
||||
},
|
||||
|
||||
async cloneCourse(courseId: number, titleTh: string, titleEn: string): Promise<ApiResponse<CourseResponse>> {
|
||||
return await authRequest<ApiResponse<CourseResponse>>(`/api/instructors/courses/${courseId}/clone`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
title: {
|
||||
en: titleEn,
|
||||
th: titleTh
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async getEnrolledStudents(
|
||||
courseId: number,
|
||||
page: number = 1,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue