445 lines
15 KiB
Vue
445 lines
15 KiB
Vue
<template>
|
|
<div>
|
|
<!-- Loading State -->
|
|
<div v-if="loading" class="flex justify-center py-12">
|
|
<q-spinner color="primary" size="64px" />
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div v-else-if="error" class="bg-white rounded-xl shadow-sm p-12 text-center">
|
|
<q-icon name="error_outline" size="64px" color="negative" />
|
|
<p class="text-gray-500 mt-4">{{ error }}</p>
|
|
<q-btn color="primary" label="กลับ" class="mt-4" @click="router.back()" />
|
|
</div>
|
|
|
|
<!-- Course Detail -->
|
|
<div v-else-if="course">
|
|
<!-- Header with Actions -->
|
|
<div class="flex flex-col md:flex-row md:justify-between md:items-start gap-4 mb-6">
|
|
<div>
|
|
<q-btn flat icon="arrow_back" label="กลับ" @click="router.back()" class="mb-2" />
|
|
<h1 class="text-2xl font-bold text-gray-900">{{ course.title.th }}</h1>
|
|
<p class="text-gray-500">{{ course.title.en }}</p>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<q-btn
|
|
color="positive"
|
|
label="อนุมัติคอร์ส"
|
|
icon="check_circle"
|
|
@click="confirmApprove"
|
|
/>
|
|
<q-btn
|
|
color="negative"
|
|
label="ปฏิเสธ"
|
|
icon="cancel"
|
|
outline
|
|
@click="showRejectModal = true"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Course Info Cards -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
|
<!-- Main Info -->
|
|
<div class="lg:col-span-2 bg-white rounded-xl shadow-sm overflow-hidden">
|
|
<!-- Thumbnail -->
|
|
<div class="h-48 bg-gray-100 flex items-center justify-center">
|
|
<img
|
|
v-if="course.thumbnail_url"
|
|
:src="course.thumbnail_url"
|
|
:alt="course.title.th"
|
|
class="w-full h-full object-cover"
|
|
/>
|
|
<q-icon v-else name="school" size="80px" color="grey-4" />
|
|
</div>
|
|
|
|
<div class="p-6">
|
|
<div class="flex flex-wrap gap-2 mb-4">
|
|
<q-badge :color="getStatusColor(course.status)" :label="getStatusLabel(course.status)" />
|
|
<q-badge color="grey" :label="course.category.name.th" />
|
|
<q-badge v-if="course.is_free" color="green" label="ฟรี" />
|
|
<q-badge v-else color="blue" :label="`฿${course.price.toLocaleString()}`" />
|
|
<q-badge v-if="course.have_certificate" color="purple" label="มีใบประกาศนียบัตร" />
|
|
</div>
|
|
|
|
<h3 class="font-semibold text-gray-700 mb-2">รายละเอียด</h3>
|
|
<p class="text-gray-600 whitespace-pre-line">{{ course.description.th }}</p>
|
|
|
|
<div class="border-t mt-4 pt-4">
|
|
<p class="text-sm text-gray-500">{{ course.description.en }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Side Info -->
|
|
<div class="space-y-4">
|
|
<!-- Stats -->
|
|
<div class="bg-white rounded-xl shadow-sm p-6">
|
|
<h3 class="font-semibold text-gray-700 mb-4">สถิติ</h3>
|
|
<div class="space-y-3">
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-500">จำนวนบท</span>
|
|
<span class="font-medium">{{ course.chapters.length }}</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-500">จำนวนบทเรียน</span>
|
|
<span class="font-medium">{{ totalLessons }}</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-500">วิดีโอ</span>
|
|
<span class="font-medium">{{ videoCount }}</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-500">แบบทดสอบ</span>
|
|
<span class="font-medium">{{ quizCount }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Instructors -->
|
|
<div class="bg-white rounded-xl shadow-sm p-6">
|
|
<h3 class="font-semibold text-gray-700 mb-4">ผู้สอน</h3>
|
|
<div class="space-y-3">
|
|
<div
|
|
v-for="instructor in course.instructors"
|
|
:key="instructor.user_id"
|
|
class="flex items-center gap-3"
|
|
>
|
|
<div class="w-10 h-10 bg-primary-100 rounded-full flex items-center justify-center">
|
|
<q-icon name="person" color="primary" />
|
|
</div>
|
|
<div>
|
|
<div class="font-medium">{{ instructor.user.username }}</div>
|
|
<div class="text-sm text-gray-500">{{ instructor.user.email }}</div>
|
|
<q-badge v-if="instructor.is_primary" color="primary" label="หลัก" size="sm" class="mt-1" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Timeline -->
|
|
<div class="bg-white rounded-xl shadow-sm p-6">
|
|
<h3 class="font-semibold text-gray-700 mb-4">ไทม์ไลน์</h3>
|
|
<div class="space-y-3 text-sm">
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-500">สร้างเมื่อ</span>
|
|
<span>{{ formatDate(course.created_at) }}</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-gray-500">อัพเดทล่าสุด</span>
|
|
<span>{{ formatDate(course.updated_at) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Course Structure -->
|
|
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
|
<h3 class="font-semibold text-gray-700 mb-4">โครงสร้างหลักสูตร</h3>
|
|
|
|
<div class="space-y-4">
|
|
<q-expansion-item
|
|
v-for="(chapter, index) in course.chapters"
|
|
:key="chapter.id"
|
|
:label="`บทที่ ${index + 1}: ${chapter.title.th}`"
|
|
:caption="`${chapter.lessons.length} บทเรียน`"
|
|
header-class="bg-gray-50 rounded-lg"
|
|
expand-icon-class="text-primary"
|
|
>
|
|
<div class="pl-4 pt-2">
|
|
<div
|
|
v-for="(lesson, lessonIndex) in chapter.lessons"
|
|
:key="lesson.id"
|
|
class="flex items-center gap-3 py-2 border-b last:border-b-0"
|
|
>
|
|
<q-icon
|
|
:name="getLessonIcon(lesson.type)"
|
|
:color="lesson.is_published ? 'primary' : 'grey'"
|
|
size="24px"
|
|
/>
|
|
<div class="flex-1">
|
|
<span class="text-gray-600">{{ lessonIndex + 1 }}. {{ lesson.title.th }}</span>
|
|
<q-badge
|
|
v-if="!lesson.is_published"
|
|
color="grey"
|
|
label="Draft"
|
|
size="sm"
|
|
class="ml-2"
|
|
/>
|
|
</div>
|
|
<q-badge :color="getLessonTypeColor(lesson.type)" :label="lesson.type" size="sm" />
|
|
</div>
|
|
</div>
|
|
</q-expansion-item>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Approval History -->
|
|
<div class="bg-white rounded-xl shadow-sm p-6">
|
|
<h3 class="font-semibold text-gray-700 mb-4">ประวัติการอนุมัติ</h3>
|
|
|
|
<q-timeline color="primary">
|
|
<q-timeline-entry
|
|
v-for="history in course.approval_history"
|
|
:key="history.id"
|
|
:title="getActionLabel(history.action)"
|
|
:subtitle="formatDateTime(history.created_at)"
|
|
:icon="getActionIcon(history.action)"
|
|
:color="getActionColor(history.action)"
|
|
>
|
|
<div class="text-sm text-gray-600">
|
|
<p>โดย: {{ history.submitter.username }}</p>
|
|
<p v-if="history.reviewer">ผู้ตรวจสอบ: {{ history.reviewer.username }}</p>
|
|
<p v-if="history.comment" class="mt-2 p-2 bg-gray-50 rounded">{{ history.comment }}</p>
|
|
</div>
|
|
</q-timeline-entry>
|
|
</q-timeline>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Reject Dialog -->
|
|
<q-dialog v-model="showRejectModal" persistent>
|
|
<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 @click="showRejectModal = false" />
|
|
</q-card-section>
|
|
|
|
<q-card-section>
|
|
<p class="text-gray-600 mb-4">
|
|
กรุณาระบุเหตุผลในการปฏิเสธคอร์ส "{{ course?.title.th }}"
|
|
</p>
|
|
<q-input
|
|
v-model="rejectReason"
|
|
type="textarea"
|
|
outlined
|
|
rows="4"
|
|
label="เหตุผล *"
|
|
:rules="[val => !!val || 'กรุณาระบุเหตุผล']"
|
|
hide-bottom-space
|
|
lazy-rules="ondemand"
|
|
/>
|
|
</q-card-section>
|
|
|
|
<q-card-actions align="right" class="q-pa-md">
|
|
<q-btn flat label="ยกเลิก" color="grey-7" @click="showRejectModal = false" />
|
|
<q-btn
|
|
label="ยืนยันการปฏิเสธ"
|
|
color="negative"
|
|
:loading="actionLoading"
|
|
:disable="!rejectReason.trim()"
|
|
@click="confirmReject"
|
|
/>
|
|
</q-card-actions>
|
|
</q-card>
|
|
</q-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useQuasar } from 'quasar';
|
|
import { adminService, type CourseDetailForReview } from '~/services/admin.service';
|
|
|
|
definePageMeta({
|
|
layout: 'admin',
|
|
middleware: ['auth', 'admin']
|
|
});
|
|
|
|
const $q = useQuasar();
|
|
const router = useRouter();
|
|
const route = useRoute();
|
|
|
|
// Data
|
|
const course = ref<CourseDetailForReview | null>(null);
|
|
const loading = ref(true);
|
|
const error = ref('');
|
|
const actionLoading = ref(false);
|
|
const showRejectModal = ref(false);
|
|
const rejectReason = ref('');
|
|
|
|
// Computed
|
|
const totalLessons = computed(() =>
|
|
course.value?.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0) || 0
|
|
);
|
|
|
|
const videoCount = computed(() =>
|
|
course.value?.chapters.reduce((sum, ch) =>
|
|
sum + ch.lessons.filter(l => l.type === 'VIDEO').length, 0
|
|
) || 0
|
|
);
|
|
|
|
const quizCount = computed(() =>
|
|
course.value?.chapters.reduce((sum, ch) =>
|
|
sum + ch.lessons.filter(l => l.type === 'QUIZ').length, 0
|
|
) || 0
|
|
);
|
|
|
|
// Methods
|
|
const fetchCourse = async () => {
|
|
loading.value = true;
|
|
error.value = '';
|
|
|
|
try {
|
|
const courseId = Number(route.params.id);
|
|
course.value = await adminService.getCourseForReview(courseId);
|
|
} catch (err) {
|
|
error.value = (err as any).data?.message || 'ไม่สามารถโหลดข้อมูลคอร์สได้';
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
const getStatusColor = (status: string) => {
|
|
const colors: Record<string, string> = {
|
|
DRAFT: 'grey',
|
|
PENDING: 'orange',
|
|
APPROVED: 'positive',
|
|
REJECTED: 'negative'
|
|
};
|
|
return colors[status] || 'grey';
|
|
};
|
|
|
|
const getStatusLabel = (status: string) => {
|
|
const labels: Record<string, string> = {
|
|
DRAFT: 'ฉบับร่าง',
|
|
PENDING: 'รอตรวจสอบ',
|
|
APPROVED: 'อนุมัติแล้ว',
|
|
REJECTED: 'ถูกปฏิเสธ'
|
|
};
|
|
return labels[status] || status;
|
|
};
|
|
|
|
const getLessonIcon = (type: string) => {
|
|
const icons: Record<string, string> = {
|
|
VIDEO: 'play_circle',
|
|
QUIZ: 'quiz',
|
|
DOCUMENT: 'description'
|
|
};
|
|
return icons[type] || 'article';
|
|
};
|
|
|
|
const getLessonTypeColor = (type: string) => {
|
|
const colors: Record<string, string> = {
|
|
VIDEO: 'blue',
|
|
QUIZ: 'purple',
|
|
DOCUMENT: 'teal'
|
|
};
|
|
return colors[type] || 'grey';
|
|
};
|
|
|
|
const getActionLabel = (action: string) => {
|
|
const labels: Record<string, string> = {
|
|
SUBMITTED: 'ส่งเพื่อตรวจสอบ',
|
|
APPROVED: 'อนุมัติ',
|
|
REJECTED: 'ปฏิเสธ',
|
|
RESUBMITTED: 'ส่งใหม่'
|
|
};
|
|
return labels[action] || action;
|
|
};
|
|
|
|
const getActionIcon = (action: string) => {
|
|
const icons: Record<string, string> = {
|
|
SUBMITTED: 'send',
|
|
APPROVED: 'check_circle',
|
|
REJECTED: 'cancel',
|
|
RESUBMITTED: 'replay'
|
|
};
|
|
return icons[action] || 'info';
|
|
};
|
|
|
|
const getActionColor = (action: string) => {
|
|
const colors: Record<string, string> = {
|
|
SUBMITTED: 'primary',
|
|
APPROVED: 'positive',
|
|
REJECTED: 'negative',
|
|
RESUBMITTED: 'orange'
|
|
};
|
|
return colors[action] || 'grey';
|
|
};
|
|
|
|
const formatDate = (date: string) => {
|
|
return new Date(date).toLocaleDateString('th-TH', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: '2-digit'
|
|
});
|
|
};
|
|
|
|
const formatDateTime = (date: string) => {
|
|
return new Date(date).toLocaleDateString('th-TH', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
};
|
|
|
|
const confirmApprove = () => {
|
|
if (!course.value) return;
|
|
|
|
$q.dialog({
|
|
title: 'ยืนยันการอนุมัติ',
|
|
message: `คุณต้องการอนุมัติคอร์ส "${course.value.title.th}" หรือไม่?`,
|
|
persistent: true,
|
|
ok: {
|
|
label: 'อนุมัติ',
|
|
color: 'positive'
|
|
},
|
|
cancel: {
|
|
label: 'ยกเลิก',
|
|
flat: true
|
|
}
|
|
}).onOk(async () => {
|
|
actionLoading.value = true;
|
|
try {
|
|
const response = await adminService.approveCourse(course.value!.id);
|
|
$q.notify({
|
|
type: 'positive',
|
|
message: response.message || 'อนุมัติคอร์สสำเร็จ',
|
|
position: 'top'
|
|
});
|
|
router.push('/admin/courses/pending');
|
|
} catch (err: any) {
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: err.data?.message || 'เกิดข้อผิดพลาดในการอนุมัติคอร์ส',
|
|
position: 'top'
|
|
});
|
|
} finally {
|
|
actionLoading.value = false;
|
|
}
|
|
});
|
|
};
|
|
|
|
const confirmReject = async () => {
|
|
if (!course.value || !rejectReason.value.trim()) return;
|
|
|
|
actionLoading.value = true;
|
|
try {
|
|
const response = await adminService.rejectCourse(course.value.id, rejectReason.value);
|
|
$q.notify({
|
|
type: 'positive',
|
|
message: response.message || 'ปฏิเสธคอร์สสำเร็จ',
|
|
position: 'top'
|
|
});
|
|
showRejectModal.value = false;
|
|
router.push('/admin/courses/pending');
|
|
} catch (err: any) {
|
|
$q.notify({
|
|
type: 'negative',
|
|
message: err.data?.message || 'เกิดข้อผิดพลาดในการปฏิเสธคอร์ส',
|
|
position: 'top'
|
|
});
|
|
} finally {
|
|
actionLoading.value = false;
|
|
}
|
|
};
|
|
|
|
// Lifecycle
|
|
onMounted(() => {
|
|
fetchCourse();
|
|
});
|
|
</script>
|