feat: Introduce core admin and instructor dashboards with dedicated services, pages, and layouts.
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 34s
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 1s
All checks were successful
Build and Deploy Frontend Management to Dev Server / Build Frontend Management Docker Image (push) Successful in 34s
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 1s
This commit is contained in:
parent
9e7b6be831
commit
c362fa284a
7 changed files with 497 additions and 7 deletions
|
|
@ -53,6 +53,15 @@
|
||||||
<span>หมวดหมู่</span>
|
<span>หมวดหมู่</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin/recommended-courses"
|
||||||
|
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
|
||||||
|
active-class="bg-primary-500 text-white hover:bg-primary-600"
|
||||||
|
>
|
||||||
|
<q-icon name="recommend" size="24px" />
|
||||||
|
<span>คอร์สแนะนำ</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/admin/audit-logs"
|
to="/admin/audit-logs"
|
||||||
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
|
class="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-primary-100 transition mb-2"
|
||||||
|
|
|
||||||
|
|
@ -297,7 +297,7 @@ const columns = [
|
||||||
|
|
||||||
// Actions options (for filtering)
|
// Actions options (for filtering)
|
||||||
const actionOptionsList = [
|
const actionOptionsList = [
|
||||||
'CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT',
|
'CREATE', 'UPDATE', 'DELETE', 'LOGIN', 'LOGOUT', 'ERROR',
|
||||||
'ENROLL', 'UNENROLL', 'SUBMIT_QUIZ',
|
'ENROLL', 'UNENROLL', 'SUBMIT_QUIZ',
|
||||||
'APPROVE_COURSE', 'REJECT_COURSE',
|
'APPROVE_COURSE', 'REJECT_COURSE',
|
||||||
'UPLOAD_FILE', 'DELETE_FILE',
|
'UPLOAD_FILE', 'DELETE_FILE',
|
||||||
|
|
@ -423,7 +423,7 @@ const formatDate = (date: string) => {
|
||||||
|
|
||||||
const getActionColor = (action: string) => {
|
const getActionColor = (action: string) => {
|
||||||
if (!action) return 'grey';
|
if (!action) return 'grey';
|
||||||
if (action.includes('DELETE') || action.includes('REJECT') || action.includes('DEACTIVATE')) return 'negative';
|
if (action.includes('DELETE') || action.includes('REJECT') || action.includes('DEACTIVATE') || action.includes('ERROR')) return 'negative';
|
||||||
if (action.includes('UPDATE') || action.includes('CHANGE')) return 'warning';
|
if (action.includes('UPDATE') || action.includes('CHANGE')) return 'warning';
|
||||||
if (action.includes('CREATE') || action.includes('APPROVE') || action.includes('ACTIVATE')) return 'positive';
|
if (action.includes('CREATE') || action.includes('APPROVE') || action.includes('ACTIVATE')) return 'positive';
|
||||||
if (action.includes('LOGIN')) return 'info';
|
if (action.includes('LOGIN')) return 'info';
|
||||||
|
|
|
||||||
314
frontend_management/pages/admin/recommended-courses/index.vue
Normal file
314
frontend_management/pages/admin/recommended-courses/index.vue
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
<template>
|
||||||
|
<NuxtLayout name="admin">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-800">จัดการคอร์สแนะนำ</h1>
|
||||||
|
<p class="text-gray-600">Recommended Courses Management</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search & Filter -->
|
||||||
|
<!-- <div class="bg-white p-4 rounded-lg shadow-sm mb-6">
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<q-input
|
||||||
|
v-model="search"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
placeholder="ค้นหาคอร์ส..."
|
||||||
|
class="w-full max-w-md"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="search" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
|
||||||
|
<q-table
|
||||||
|
:rows="courses"
|
||||||
|
:columns="columns"
|
||||||
|
row-key="id"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="initialPagination"
|
||||||
|
>
|
||||||
|
<!-- Thumbnail Column -->
|
||||||
|
<template v-slot:body-cell-thumbnail="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-img
|
||||||
|
:src="props.row.thumbnail_url || '/placeholder-course.jpg'"
|
||||||
|
class="w-16 h-10 rounded object-cover"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Title Column -->
|
||||||
|
<template v-slot:body-cell-title="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<div class="font-medium text-gray-900">{{ props.row.title.th }}</div>
|
||||||
|
<div class="text-xs text-gray-500">{{ props.row.title.en }}</div>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Instructor Column -->
|
||||||
|
<template v-slot:body-cell-instructor="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<q-avatar size="24px" class="bg-primary-100 text-primary">
|
||||||
|
<!-- <img :src="props.row.instructor.user.avatar_url || '/default-avatar.png'"> -->
|
||||||
|
<q-icon name="person" size="16px" />
|
||||||
|
</q-avatar>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium">
|
||||||
|
{{ (props.row.instructors && props.row.instructors.length > 0) ? props.row.instructors.find((i: any) => i.is_primary)?.user.username : 'Unknown' }}
|
||||||
|
</div>
|
||||||
|
<!-- <div class="text-xs text-gray-500">{{ props.row.instructor.user.username }}</div> -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Price Column -->
|
||||||
|
<template v-slot:body-cell-price="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<div v-if="props.row.is_free" class="text-green-600 font-medium">Free</div>
|
||||||
|
<div v-else class="font-medium">{{ formatPrice(props.row.price) }}</div>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Recommended Toggle Column -->
|
||||||
|
<template v-slot:body-cell-is_recommended="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-toggle
|
||||||
|
v-model="props.row.is_recommended"
|
||||||
|
color="green"
|
||||||
|
@update:model-value="(val) => handleToggleRecommendation(props.row, val)"
|
||||||
|
/>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Actions Column -->
|
||||||
|
<template v-slot:body-cell-actions="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-btn flat round dense icon="visibility" color="primary" @click="viewCourse(props.row.id)">
|
||||||
|
<q-tooltip>ดูรายละเอียด</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</q-table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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-space />
|
||||||
|
<q-btn dense flat icon="close" v-close-popup>
|
||||||
|
<q-tooltip class="bg-white text-primary">Close</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</q-bar>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-h6">รายละเอียดคอร์ส (Course Details)</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section v-if="selectedCourse" class="q-pt-none">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Left Column: Image & Basic Info -->
|
||||||
|
<div>
|
||||||
|
<q-img
|
||||||
|
:src="selectedCourse.thumbnail_url || '/placeholder-course.jpg'"
|
||||||
|
class="rounded-lg shadow-md mb-4"
|
||||||
|
style="max-height: 300px; object-fit: cover;"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="text-2xl font-bold text-gray-800 mb-2">{{ selectedCourse.title.th }}</div>
|
||||||
|
<div class="text-lg text-gray-600 mb-4">{{ selectedCourse.title.en }}</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mb-4">
|
||||||
|
<q-badge :color="selectedCourse.is_free ? 'green' : 'blue'" class="text-base p-2">
|
||||||
|
{{ selectedCourse.is_free ? 'FREE' : formatPrice(selectedCourse.price) }}
|
||||||
|
</q-badge>
|
||||||
|
<q-badge :color="getStatusColor(selectedCourse.status)" class="text-base p-2">
|
||||||
|
{{ selectedCourse.status }}
|
||||||
|
</q-badge>
|
||||||
|
<q-badge v-if="selectedCourse.have_certificate" color="orange" class="text-base p-2">
|
||||||
|
Certificate
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Details -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div class="font-bold mb-2">รายละเอียด (Description)</div>
|
||||||
|
<div class="text-gray-700 whitespace-pre-wrap">{{ selectedCourse.description.th }}</div>
|
||||||
|
<div class="text-gray-500 mt-2 text-sm">{{ selectedCourse.description.en }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- Instructors -->
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div class="font-bold mb-2">ผู้สอน (Instructors)</div>
|
||||||
|
<div v-for="inst in selectedCourse.instructors" :key="inst.user_id" class="flex items-center gap-2 mb-2">
|
||||||
|
<q-avatar size="32px" class="bg-primary-100 text-primary">
|
||||||
|
<q-icon name="person" />
|
||||||
|
</q-avatar>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">{{ inst.user.username }}</div>
|
||||||
|
<div class="text-xs text-gray-500" v-if="inst.is_primary">(Primary)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
<div class="bg-blue-50 p-2 rounded-lg text-center">
|
||||||
|
<div class="text-2xl font-bold text-blue-800">{{ selectedCourse.chapters_count || 0 }}</div>
|
||||||
|
<div class="text-blue-600 text-sm">Chapters</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-purple-50 p-2 rounded-lg text-center">
|
||||||
|
<div class="text-2xl font-bold text-purple-800">{{ selectedCourse.lessons_count || 0 }}</div>
|
||||||
|
<div class="text-purple-600 text-sm">Lessons</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Inner Loading -->
|
||||||
|
<q-inner-loading :showing="dialogLoading">
|
||||||
|
<q-spinner-gears size="50px" color="primary" />
|
||||||
|
</q-inner-loading>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { adminService, type RecommendedCourse } from '~/services/admin.service';
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
const loading = ref(false);
|
||||||
|
const courses = ref<RecommendedCourse[]>([]);
|
||||||
|
const search = ref('');
|
||||||
|
|
||||||
|
// Dialog state
|
||||||
|
const showDialog = ref(false);
|
||||||
|
const dialogLoading = ref(false);
|
||||||
|
const selectedCourse = ref<RecommendedCourse | null>(null);
|
||||||
|
|
||||||
|
const initialPagination = {
|
||||||
|
sortBy: 'desc',
|
||||||
|
descending: false,
|
||||||
|
page: 1,
|
||||||
|
rowsPerPage: 10
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ name: 'id', label: 'ID', field: 'id', sortable: true, align: 'left' as const },
|
||||||
|
{ name: 'thumbnail', label: 'Image', field: 'thumbnail_url', align: 'left' as const },
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
label: 'Course Name',
|
||||||
|
field: (row: RecommendedCourse) => row.title.th,
|
||||||
|
sortable: true,
|
||||||
|
align: 'left' as const
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'instructor',
|
||||||
|
label: 'Instructor',
|
||||||
|
field: (row: RecommendedCourse) => row.instructors?.find((i: any) => i.is_primary)?.user.username || '',
|
||||||
|
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: 'is_recommended', label: 'Recommended', field: 'is_recommended', sortable: true, align: 'center' as const },
|
||||||
|
//{ name: 'actions', label: 'Actions', field: 'actions', align: 'center' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchCourses = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
courses.value = await adminService.getRecommendedCourses();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching courses:', error);
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'ไม่สามารถโหลดข้อมูลคอร์สได้'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewCourse = async (id: number) => {
|
||||||
|
showDialog.value = true;
|
||||||
|
dialogLoading.value = true;
|
||||||
|
selectedCourse.value = null; // Clear previous data
|
||||||
|
try {
|
||||||
|
selectedCourse.value = await adminService.getRecommendedCourseById(id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching course details:', error);
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'ไม่สามารถโหลดข้อมูลรายละเอียดคอร์สได้'
|
||||||
|
});
|
||||||
|
showDialog.value = false;
|
||||||
|
} finally {
|
||||||
|
dialogLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleRecommendation = async (course: RecommendedCourse, isRecommended: boolean) => {
|
||||||
|
try {
|
||||||
|
await adminService.toggleCourseRecommendation(course.id, isRecommended);
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: isRecommended ? 'เพิ่มคอร์สแนะนำสำร็จ' : 'ยกเลิกคอร์สแนะนำสำเร็จ'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling recommendation:', error);
|
||||||
|
// Revert the toggle if API fails
|
||||||
|
course.is_recommended = !isRecommended;
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'เกิดข้อผิดพลาดในการบันทึกข้อมูล'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPrice = (price: number) => {
|
||||||
|
return new Intl.NumberFormat('th-TH', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'THB'
|
||||||
|
}).format(price);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'PUBLISHED': return 'positive';
|
||||||
|
case 'DRAFT': return 'grey';
|
||||||
|
case 'PENDING': return 'warning';
|
||||||
|
case 'REJECTED': return 'negative';
|
||||||
|
default: return 'grey';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchCourses();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -63,7 +63,7 @@
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<q-input
|
<q-input
|
||||||
v-model="form.description.th"
|
v-model="form.description.th"
|
||||||
label="คำอธิบาย (ภาษาไทย)"
|
label="คำอธิบาย (ภาษาไทย) *"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
outlined
|
outlined
|
||||||
autogrow
|
autogrow
|
||||||
|
|
@ -77,7 +77,7 @@
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<q-input
|
<q-input
|
||||||
v-model="form.description.en"
|
v-model="form.description.en"
|
||||||
label="คำอธิบาย (English)"
|
label="คำอธิบาย (English) *"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
outlined
|
outlined
|
||||||
autogrow
|
autogrow
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats Cards -->
|
<!-- Stats Cards -->
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||||
<div class="bg-white rounded-xl shadow-sm p-5 text-center">
|
<div class="bg-white rounded-xl shadow-sm p-5 text-center">
|
||||||
<div class="text-3xl font-bold text-primary-600">{{ stats.total }}</div>
|
<div class="text-3xl font-bold text-primary-600">{{ stats.total }}</div>
|
||||||
<div class="text-gray-500 text-sm mt-1">หลักสูตรทั้งหมด</div>
|
<div class="text-gray-500 text-sm mt-1">หลักสูตรทั้งหมด</div>
|
||||||
|
|
@ -31,6 +31,10 @@
|
||||||
<div class="text-3xl font-bold text-gray-500">{{ stats.draft }}</div>
|
<div class="text-3xl font-bold text-gray-500">{{ stats.draft }}</div>
|
||||||
<div class="text-gray-500 text-sm mt-1">แบบร่าง</div>
|
<div class="text-gray-500 text-sm mt-1">แบบร่าง</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-5 text-center">
|
||||||
|
<div class="text-3xl font-bold text-red-600">{{ stats.rejected }}</div>
|
||||||
|
<div class="text-gray-500 text-sm mt-1">ถูกปฏิเสธ</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter Bar -->
|
<!-- Filter Bar -->
|
||||||
|
|
@ -126,7 +130,7 @@
|
||||||
dense
|
dense
|
||||||
icon="visibility"
|
icon="visibility"
|
||||||
color="grey"
|
color="grey"
|
||||||
@click="navigateTo(`/instructor/courses/${course.id}`)"
|
@click="handleViewDetails(course)"
|
||||||
>
|
>
|
||||||
<q-tooltip>ดูรายละเอียด</q-tooltip>
|
<q-tooltip>ดูรายละเอียด</q-tooltip>
|
||||||
</q-btn>
|
</q-btn>
|
||||||
|
|
@ -162,6 +166,36 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Rejection Details Dialog -->
|
||||||
|
<q-dialog v-model="rejectionDialog">
|
||||||
|
<q-card style="min-width: 400px">
|
||||||
|
<q-card-section class="row items-center q-pb-none">
|
||||||
|
<div class="text-h6 text-red">หลักสูตรถูกปฏิเสธ</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn icon="close" flat round dense v-close-popup />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section>
|
||||||
|
<div class="text-subtitle1 font-bold mb-2">เหตุผลการปฏิเสธ:</div>
|
||||||
|
<div class="bg-red-50 p-4 rounded-lg text-red-800 border border-red-100">
|
||||||
|
{{ selectedRejectionCourse?.rejection_reason || 'ไม่ระบุเหตุผล' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-500 text-sm mt-4">
|
||||||
|
คุณสามารถแก้ไขหลักสูตรและส่งขออนุมัติใหม่ได้ โดยการคืนสถานะเป็นแบบร่าง
|
||||||
|
</div>
|
||||||
|
</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="returnToDraft"
|
||||||
|
/>
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -196,7 +230,8 @@ const stats = computed(() => ({
|
||||||
total: courses.value.length,
|
total: courses.value.length,
|
||||||
approved: courses.value.filter(c => c.status === 'APPROVED').length,
|
approved: courses.value.filter(c => c.status === 'APPROVED').length,
|
||||||
pending: courses.value.filter(c => c.status === 'PENDING').length,
|
pending: courses.value.filter(c => c.status === 'PENDING').length,
|
||||||
draft: courses.value.filter(c => c.status === 'DRAFT').length
|
draft: courses.value.filter(c => c.status === 'DRAFT').length,
|
||||||
|
rejected: courses.value.filter(c => c.status === 'REJECTED').length
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Filtered courses
|
// Filtered courses
|
||||||
|
|
@ -296,6 +331,41 @@ const confirmDelete = (course: CourseResponse) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Rejection Dialog
|
||||||
|
const rejectionDialog = ref(false);
|
||||||
|
const selectedRejectionCourse = ref<CourseResponse | null>(null);
|
||||||
|
|
||||||
|
const handleViewDetails = (course: CourseResponse) => {
|
||||||
|
if (course.status === 'REJECTED') {
|
||||||
|
selectedRejectionCourse.value = course;
|
||||||
|
rejectionDialog.value = true;
|
||||||
|
} else {
|
||||||
|
navigateTo(`/instructor/courses/${course.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const returnToDraft = async () => {
|
||||||
|
if (!selectedRejectionCourse.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await instructorService.setCourseDraft(selectedRejectionCourse.value.id);
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: response.message || 'คืนสถานะเป็นแบบร่างสำเร็จ',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
rejectionDialog.value = false;
|
||||||
|
selectedRejectionCourse.value = null;
|
||||||
|
fetchCourses(); // Refresh list
|
||||||
|
} catch (error: any) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: error.data?.message || 'ไม่สามารถคืนสถานะได้',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchCourses();
|
fetchCourses();
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,57 @@ export interface AuditLogStats {
|
||||||
recentActivity: AuditLog[];
|
recentActivity: AuditLog[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RecommendedCourse {
|
||||||
|
id: number;
|
||||||
|
title: {
|
||||||
|
th: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
slug: string;
|
||||||
|
description: {
|
||||||
|
th: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
thumbnail_url: string | null;
|
||||||
|
price: number;
|
||||||
|
is_free: boolean;
|
||||||
|
have_certificate: boolean;
|
||||||
|
is_recommended: boolean;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
category: {
|
||||||
|
id: number;
|
||||||
|
name: {
|
||||||
|
th: string;
|
||||||
|
en: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
instructors: {
|
||||||
|
user_id: number;
|
||||||
|
is_primary: boolean;
|
||||||
|
user: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
creator: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
chapters_count: number;
|
||||||
|
lessons_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecommendedCoursesListResponse {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: RecommendedCourse[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to get auth token from cookie
|
// Helper function to get auth token from cookie
|
||||||
const getAuthToken = (): string => {
|
const getAuthToken = (): string => {
|
||||||
const tokenCookie = useCookie('token');
|
const tokenCookie = useCookie('token');
|
||||||
|
|
@ -517,6 +568,48 @@ export const adminService = {
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
query: { days }
|
query: { days }
|
||||||
});
|
});
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ============ Recommended Courses ============
|
||||||
|
async getRecommendedCourses(): Promise<RecommendedCourse[]> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await $fetch<RecommendedCoursesListResponse>('/api/admin/recommended-courses', {
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRecommendedCourseById(id: number): Promise<RecommendedCourse> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await $fetch<ApiResponse<RecommendedCourse>>(`/api/admin/recommended-courses/${id}`, {
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async toggleCourseRecommendation(courseId: number, isRecommended: boolean): Promise<ApiResponse<void>> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await $fetch<ApiResponse<void>>(`/api/admin/recommended-courses/${courseId}/toggle`, {
|
||||||
|
method: 'PUT',
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
|
query: { is_recommended: isRecommended }
|
||||||
|
});
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -301,6 +301,10 @@ export const instructorService = {
|
||||||
return await authRequest<ApiResponse<void>>(`/api/instructors/courses/send-review/${courseId}`, { method: 'POST' });
|
return await authRequest<ApiResponse<void>>(`/api/instructors/courses/send-review/${courseId}`, { method: 'POST' });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async setCourseDraft(courseId: number): Promise<ApiResponse<void>> {
|
||||||
|
return await authRequest<ApiResponse<void>>(`/api/instructors/courses/set-draft/${courseId}`, { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
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