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">
|
<div v-else-if="lessonDetail" class="p-6 space-y-6">
|
||||||
<!-- Video Player -->
|
<!-- Video Player -->
|
||||||
<div v-if="lesson.type === 'VIDEO' && lessonDetail.video_url" class="aspect-video bg-black rounded-lg overflow-hidden">
|
<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
|
<video
|
||||||
|
v-else
|
||||||
:src="lessonDetail.video_url"
|
:src="lessonDetail.video_url"
|
||||||
controls
|
controls
|
||||||
class="w-full h-full object-contain"
|
class="w-full h-full object-contain"
|
||||||
|
|
@ -38,7 +47,7 @@
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
<a
|
<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"
|
:key="file.id"
|
||||||
:href="file.file_path"
|
:href="file.file_path"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|
@ -175,6 +184,23 @@ const formatFileSize = (bytes: number) => {
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
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 () => {
|
const fetchLessonDetail = async () => {
|
||||||
// Always verify lesson and courseId exist
|
// Always verify lesson and courseId exist
|
||||||
if (!props.lesson || !props.courseId) return;
|
if (!props.lesson || !props.courseId) return;
|
||||||
|
|
|
||||||
|
|
@ -212,12 +212,16 @@
|
||||||
กรุณาระบุเหตุผลในการปฏิเสธคอร์ส "{{ course?.title.th }}"
|
กรุณาระบุเหตุผลในการปฏิเสธคอร์ส "{{ course?.title.th }}"
|
||||||
</p>
|
</p>
|
||||||
<q-input
|
<q-input
|
||||||
|
ref="rejectInputRef"
|
||||||
v-model="rejectReason"
|
v-model="rejectReason"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
outlined
|
outlined
|
||||||
rows="4"
|
rows="4"
|
||||||
label="เหตุผล *"
|
label="เหตุผล *"
|
||||||
:rules="[val => !!val || 'กรุณาระบุเหตุผล']"
|
:rules="[
|
||||||
|
val => !!val || 'กรุณาระบุเหตุผล',
|
||||||
|
val => (val && val.length >= 10) || 'ระบุเหตุผลอย่างน้อย 10 ตัวอักษร'
|
||||||
|
]"
|
||||||
hide-bottom-space
|
hide-bottom-space
|
||||||
lazy-rules="ondemand"
|
lazy-rules="ondemand"
|
||||||
/>
|
/>
|
||||||
|
|
@ -229,7 +233,6 @@
|
||||||
label="ยืนยันการปฏิเสธ"
|
label="ยืนยันการปฏิเสธ"
|
||||||
color="negative"
|
color="negative"
|
||||||
:loading="actionLoading"
|
:loading="actionLoading"
|
||||||
:disable="!rejectReason.trim()"
|
|
||||||
@click="confirmReject"
|
@click="confirmReject"
|
||||||
/>
|
/>
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
|
|
@ -239,7 +242,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar, QInput } from 'quasar';
|
||||||
import { adminService, type CourseDetailForReview } from '~/services/admin.service';
|
import { adminService, type CourseDetailForReview } from '~/services/admin.service';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
|
|
@ -258,6 +261,7 @@ const error = ref('');
|
||||||
const actionLoading = ref(false);
|
const actionLoading = ref(false);
|
||||||
const showRejectModal = ref(false);
|
const showRejectModal = ref(false);
|
||||||
const rejectReason = ref('');
|
const rejectReason = ref('');
|
||||||
|
const rejectInputRef = ref<QInput | null>(null);
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const totalLessons = computed(() =>
|
const totalLessons = computed(() =>
|
||||||
|
|
@ -415,7 +419,8 @@ const confirmApprove = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmReject = async () => {
|
const confirmReject = async () => {
|
||||||
if (!course.value || !rejectReason.value.trim()) return;
|
rejectInputRef.value?.validate();
|
||||||
|
if (rejectInputRef.value?.hasError || !course.value) return;
|
||||||
|
|
||||||
actionLoading.value = true;
|
actionLoading.value = true;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useQuasar } from 'quasar';
|
import { useQuasar, type QTableColumn } from 'quasar';
|
||||||
import { adminService, type PendingCourse } from '~/services/admin.service';
|
import { adminService, type PendingCourse } from '~/services/admin.service';
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
|
|
@ -232,12 +232,12 @@ const loading = ref(true);
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const viewMode = ref('table');
|
const viewMode = ref('table');
|
||||||
|
|
||||||
const columns = [
|
const columns: QTableColumn[] = [
|
||||||
{ name: 'thumbnail', label: 'รูปปก', field: 'thumbnail', align: 'left' },
|
{ name: 'thumbnail', label: 'รูปปก', field: 'thumbnail', align: 'left' },
|
||||||
{ name: 'title', label: 'ชื่อคอร์ส', field: (row: PendingCourse) => row.title.th, align: 'left', sortable: true },
|
{ name: 'title', label: 'ชื่อคอร์ส', field: (row: any) => row.title.th, align: 'left', sortable: true },
|
||||||
{ name: 'instructor', label: 'ผู้สอน', field: (row: PendingCourse) => getPrimaryInstructor(row), align: 'left', sortable: true },
|
{ name: 'instructor', label: 'ผู้สอน', field: (row: any) => getPrimaryInstructor(row), align: 'left', sortable: true },
|
||||||
{ name: 'stats', label: 'จำนวนบท', field: 'stats', align: 'center' },
|
{ 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' }
|
{ name: 'actions', label: '', field: 'actions', align: 'center' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@
|
||||||
<!-- View Details Dialog -->
|
<!-- View Details Dialog -->
|
||||||
<q-dialog v-model="showDialog" maximized transition-show="slide-up" transition-hide="slide-down">
|
<q-dialog v-model="showDialog" maximized transition-show="slide-up" transition-hide="slide-down">
|
||||||
<q-card>
|
<q-card>
|
||||||
<q-bar class="bg-primary text-white">
|
<q-bar class="bg-primary-500 text-white">
|
||||||
<q-space />
|
<q-space />
|
||||||
<q-btn dense flat icon="close" v-close-popup>
|
<q-btn dense flat icon="close" v-close-popup>
|
||||||
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
|
@ -153,7 +153,7 @@
|
||||||
<!-- Category -->
|
<!-- Category -->
|
||||||
<div class="bg-gray-50 p-4 rounded-lg gap-2">
|
<div class="bg-gray-50 p-4 rounded-lg gap-2">
|
||||||
<div class="font-bold mb-2">หมวดหมู่ (Category):</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Instructors -->
|
<!-- Instructors -->
|
||||||
|
|
@ -164,7 +164,7 @@
|
||||||
<q-icon name="person" />
|
<q-icon name="person" />
|
||||||
</q-avatar>
|
</q-avatar>
|
||||||
<div>
|
<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 class="text-xs text-gray-500" v-if="inst.is_primary">(Primary)</div>
|
||||||
</div>
|
</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: '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: '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: '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 () => {
|
const fetchCourses = async () => {
|
||||||
|
|
@ -248,7 +248,8 @@ const fetchCourses = async () => {
|
||||||
console.error('Error fetching courses:', error);
|
console.error('Error fetching courses:', error);
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: 'ไม่สามารถโหลดข้อมูลคอร์สได้'
|
message: 'ไม่สามารถโหลดข้อมูลคอร์สได้',
|
||||||
|
position: 'top'
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
|
|
@ -265,7 +266,8 @@ const viewCourse = async (id: number) => {
|
||||||
console.error('Error fetching course details:', error);
|
console.error('Error fetching course details:', error);
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: 'ไม่สามารถโหลดข้อมูลรายละเอียดคอร์สได้'
|
message: 'ไม่สามารถโหลดข้อมูลรายละเอียดคอร์สได้',
|
||||||
|
position: 'top'
|
||||||
});
|
});
|
||||||
showDialog.value = false;
|
showDialog.value = false;
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -278,7 +280,8 @@ const handleToggleRecommendation = async (course: RecommendedCourse, isRecommend
|
||||||
await adminService.toggleCourseRecommendation(course.id, isRecommended);
|
await adminService.toggleCourseRecommendation(course.id, isRecommended);
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: isRecommended ? 'เพิ่มคอร์สแนะนำสำร็จ' : 'ยกเลิกคอร์สแนะนำสำเร็จ'
|
message: isRecommended ? 'เพิ่มคอร์สแนะนำสำร็จ' : 'ยกเลิกคอร์สแนะนำสำเร็จ',
|
||||||
|
position: 'top'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling recommendation:', error);
|
console.error('Error toggling recommendation:', error);
|
||||||
|
|
@ -286,7 +289,8 @@ const handleToggleRecommendation = async (course: RecommendedCourse, isRecommend
|
||||||
course.is_recommended = !isRecommended;
|
course.is_recommended = !isRecommended;
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'negative',
|
type: 'negative',
|
||||||
message: 'เกิดข้อผิดพลาดในการบันทึกข้อมูล'
|
message: 'เกิดข้อผิดพลาดในการบันทึกข้อมูล',
|
||||||
|
position: 'top'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,47 @@
|
||||||
</q-card-actions>
|
</q-card-actions>
|
||||||
</q-card>
|
</q-card>
|
||||||
</q-dialog>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -296,15 +337,45 @@ const formatDate = (date: string) => {
|
||||||
year: '2-digit'
|
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) => {
|
const duplicateCourse = (course: CourseResponse) => {
|
||||||
$q.notify({
|
courseToClone.value = course;
|
||||||
type: 'info',
|
cloneCourseTitleTh.value = `${course.title.th} (Copy)`;
|
||||||
message: `กำลังทำสำเนา "${course.title.th}"...`,
|
cloneCourseTitleEn.value = `${course.title.en} (Copy)`;
|
||||||
position: 'top'
|
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: '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) => {
|
const confirmDelete = (course: CourseResponse) => {
|
||||||
$q.dialog({
|
$q.dialog({
|
||||||
title: 'ยืนยันการลบ',
|
title: 'ยืนยันการลบ',
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,18 @@ export const instructorService = {
|
||||||
return await authRequest<ApiResponse<void>>(`/api/instructors/courses/set-draft/${courseId}`, { method: 'POST' });
|
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(
|
async getEnrolledStudents(
|
||||||
courseId: number,
|
courseId: number,
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue