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

This commit is contained in:
Missez 2026-02-20 14:33:08 +07:00
parent 0f92f0d00c
commit f26a94076c
6 changed files with 141 additions and 23 deletions

View file

@ -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 {

View file

@ -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' }
];

View file

@ -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'
});
}
};

View file

@ -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) => {
$q.notify({
type: 'info',
message: `กำลังทำสำเนา "${course.title.th}"...`,
position: 'top'
});
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: '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: 'ยืนยันการลบ',