feat: Implement instructor course management pages and service for listing, creating, viewing, and editing courses.
This commit is contained in:
parent
cad3f276f5
commit
1e06041769
5 changed files with 838 additions and 9 deletions
266
frontend_management/pages/instructor/courses/[id]/edit.vue
Normal file
266
frontend_management/pages/instructor/courses/[id]/edit.vue
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
icon="arrow_back"
|
||||||
|
@click="navigateTo(`/instructor/courses/${route.params.id}`)"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-primary-600">แก้ไขหลักสูตร</h1>
|
||||||
|
<p class="text-gray-600 mt-1">แก้ไขข้อมูลหลักสูตรของคุณ</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="initialLoading" class="flex justify-center py-20">
|
||||||
|
<q-spinner-dots size="50px" color="primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
<div v-else class="bg-white rounded-xl shadow-sm p-6">
|
||||||
|
<q-form @submit="handleSubmit">
|
||||||
|
<!-- Basic Info -->
|
||||||
|
<h2 class="text-lg font-semibold text-primary-600 mb-4">ข้อมูลพื้นฐาน</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||||
|
<q-input
|
||||||
|
v-model="form.title.th"
|
||||||
|
label="ชื่อหลักสูตร (ภาษาไทย) *"
|
||||||
|
outlined
|
||||||
|
:rules="[val => !!val || 'กรุณากรอกชื่อหลักสูตร']"
|
||||||
|
/>
|
||||||
|
<q-input
|
||||||
|
v-model="form.title.en"
|
||||||
|
label="ชื่อหลักสูตร (English) *"
|
||||||
|
outlined
|
||||||
|
:rules="[val => !!val || 'Please enter course title']"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||||
|
<q-input
|
||||||
|
v-model="form.slug"
|
||||||
|
label="Slug (URL) *"
|
||||||
|
outlined
|
||||||
|
hint="ใช้สำหรับ URL เช่น javascript-basics"
|
||||||
|
:rules="[val => !!val || 'กรุณากรอก slug']"
|
||||||
|
/>
|
||||||
|
<q-select
|
||||||
|
v-model="form.category_id"
|
||||||
|
:options="categoryOptions"
|
||||||
|
label="หมวดหมู่ *"
|
||||||
|
outlined
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
:rules="[val => !!val || 'กรุณาเลือกหมวดหมู่']"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<q-input
|
||||||
|
v-model="form.description.th"
|
||||||
|
label="คำอธิบาย (ภาษาไทย)"
|
||||||
|
type="textarea"
|
||||||
|
outlined
|
||||||
|
autogrow
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<q-input
|
||||||
|
v-model="form.description.en"
|
||||||
|
label="คำอธิบาย (English)"
|
||||||
|
type="textarea"
|
||||||
|
outlined
|
||||||
|
autogrow
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pricing -->
|
||||||
|
<q-separator class="my-6" />
|
||||||
|
<h2 class="text-lg font-semibold text-primary-600 mb-4">ราคาและการตั้งค่า</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
|
<q-toggle
|
||||||
|
v-model="form.is_free"
|
||||||
|
label="หลักสูตรฟรี"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
<q-input
|
||||||
|
v-if="!form.is_free"
|
||||||
|
v-model.number="form.price"
|
||||||
|
label="ราคา (บาท)"
|
||||||
|
type="number"
|
||||||
|
outlined
|
||||||
|
prefix="฿"
|
||||||
|
:rules="[val => form.is_free || val > 0 || 'กรุณากรอกราคา']"
|
||||||
|
/>
|
||||||
|
<q-toggle
|
||||||
|
v-model="form.have_certificate"
|
||||||
|
label="มีใบประกาศนียบัตร"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Thumbnail -->
|
||||||
|
<q-separator class="my-6" />
|
||||||
|
<h2 class="text-lg font-semibold text-primary-600 mb-4">รูปภาพปก</h2>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<q-input
|
||||||
|
v-model="form.thumbnail_url"
|
||||||
|
label="URL รูปภาพปก"
|
||||||
|
outlined
|
||||||
|
hint="ใส่ URL รูปภาพ (optional)"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<q-icon name="image" />
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview thumbnail if exists -->
|
||||||
|
<div v-if="form.thumbnail_url" class="mb-6">
|
||||||
|
<img
|
||||||
|
:src="form.thumbnail_url"
|
||||||
|
alt="Thumbnail preview"
|
||||||
|
class="max-w-xs rounded-lg shadow"
|
||||||
|
@error="form.thumbnail_url = ''"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex justify-end gap-3 mt-8">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
label="ยกเลิก"
|
||||||
|
color="grey-7"
|
||||||
|
@click="navigateTo(`/instructor/courses/${route.params.id}`)"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
type="submit"
|
||||||
|
label="บันทึกการแก้ไข"
|
||||||
|
color="primary"
|
||||||
|
:loading="saving"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { instructorService, type CreateCourseRequest } from '~/services/instructor.service';
|
||||||
|
import { adminService, type CategoryResponse } from '~/services/admin.service';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'instructor',
|
||||||
|
middleware: ['auth']
|
||||||
|
});
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
// Data
|
||||||
|
const initialLoading = ref(true);
|
||||||
|
const saving = ref(false);
|
||||||
|
const categories = ref<CategoryResponse[]>([]);
|
||||||
|
|
||||||
|
// Form
|
||||||
|
const form = ref<CreateCourseRequest>({
|
||||||
|
category_id: 0,
|
||||||
|
title: { th: '', en: '' },
|
||||||
|
slug: '',
|
||||||
|
description: { th: '', en: '' },
|
||||||
|
thumbnail_url: null,
|
||||||
|
price: 0,
|
||||||
|
is_free: true,
|
||||||
|
have_certificate: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Category options
|
||||||
|
const categoryOptions = computed(() =>
|
||||||
|
categories.value.map(cat => ({
|
||||||
|
label: cat.name.th,
|
||||||
|
value: cat.id
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const fetchData = async () => {
|
||||||
|
initialLoading.value = true;
|
||||||
|
try {
|
||||||
|
const courseId = parseInt(route.params.id as string);
|
||||||
|
|
||||||
|
// Fetch course and categories in parallel
|
||||||
|
const [course, cats] = await Promise.all([
|
||||||
|
instructorService.getCourseById(courseId),
|
||||||
|
adminService.getCategories()
|
||||||
|
]);
|
||||||
|
|
||||||
|
categories.value = cats;
|
||||||
|
|
||||||
|
// Populate form with course data
|
||||||
|
form.value = {
|
||||||
|
category_id: course.category_id,
|
||||||
|
title: { ...course.title },
|
||||||
|
slug: course.slug,
|
||||||
|
description: { ...course.description },
|
||||||
|
thumbnail_url: course.thumbnail_url,
|
||||||
|
price: parseFloat(course.price),
|
||||||
|
is_free: course.is_free,
|
||||||
|
have_certificate: course.have_certificate
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'ไม่สามารถโหลดข้อมูลหลักสูตรได้',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
navigateTo('/instructor/courses');
|
||||||
|
} finally {
|
||||||
|
initialLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const courseId = parseInt(route.params.id as string);
|
||||||
|
|
||||||
|
// Set price to 0 if free
|
||||||
|
if (form.value.is_free) {
|
||||||
|
form.value.price = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await instructorService.updateCourse(courseId, form.value);
|
||||||
|
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'บันทึกการแก้ไขสำเร็จ',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
|
||||||
|
navigateTo(`/instructor/courses/${courseId}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: error.message || 'เกิดข้อผิดพลาด กรุณาลองใหม่',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
fetchData();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
298
frontend_management/pages/instructor/courses/[id]/index.vue
Normal file
298
frontend_management/pages/instructor/courses/[id]/index.vue
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="flex justify-center py-20">
|
||||||
|
<q-spinner-dots size="50px" color="primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="course">
|
||||||
|
<!-- Course Header -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm p-6 mb-6">
|
||||||
|
<div class="flex flex-col md:flex-row gap-6">
|
||||||
|
<!-- Thumbnail -->
|
||||||
|
<div class="w-full md:w-48 h-32 bg-gradient-to-br from-primary-400 to-primary-600 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<img
|
||||||
|
v-if="course.thumbnail_url"
|
||||||
|
:src="course.thumbnail_url"
|
||||||
|
:alt="course.title.th"
|
||||||
|
class="w-full h-full object-cover rounded-lg"
|
||||||
|
/>
|
||||||
|
<span v-else class="text-white text-sm">รูปหลักสูตร</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">{{ course.title.th }}</h1>
|
||||||
|
<p class="text-gray-600 mt-1">{{ course.description.th }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Badges -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<q-badge v-if="course.is_free" color="purple">เผยแพร่</q-badge>
|
||||||
|
<q-badge :color="getStatusColor(course.status)">
|
||||||
|
{{ getStatusLabel(course.status) }}
|
||||||
|
</q-badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="flex items-center gap-6 mt-4 text-gray-600">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<q-icon name="menu_book" size="20px" />
|
||||||
|
<span>{{ totalLessons }} บทเรียน</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<q-icon name="people" size="20px" />
|
||||||
|
<span>0 ผู้เรียน</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
color="primary"
|
||||||
|
label="แก้ไข"
|
||||||
|
icon="edit"
|
||||||
|
@click="navigateTo(`/instructor/courses/${course.id}/edit`)"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
v-if="course.status === 'DRAFT'"
|
||||||
|
color="primary"
|
||||||
|
label="ขออนุมัติหลักสูตร"
|
||||||
|
@click="requestApproval"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<q-tabs
|
||||||
|
v-model="activeTab"
|
||||||
|
class="bg-white rounded-t-xl shadow-sm text-primary-600"
|
||||||
|
active-color="primary"
|
||||||
|
indicator-color="primary"
|
||||||
|
align="left"
|
||||||
|
>
|
||||||
|
<q-tab name="structure" icon="list" label="โครงสร้าง" />
|
||||||
|
<q-tab name="students" icon="people" label="ผู้เรียน" />
|
||||||
|
<q-tab name="quiz" icon="quiz" label="ผลการทดสอบ" />
|
||||||
|
<q-tab name="announcements" icon="campaign" label="ประกาศ" />
|
||||||
|
</q-tabs>
|
||||||
|
|
||||||
|
<!-- Tab Panels -->
|
||||||
|
<q-tab-panels v-model="activeTab" class="bg-white rounded-b-xl shadow-sm">
|
||||||
|
<!-- Structure Tab -->
|
||||||
|
<q-tab-panel name="structure" class="p-6">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900">โครงสร้างบทเรียน</h2>
|
||||||
|
<q-btn
|
||||||
|
color="primary"
|
||||||
|
label="จัดการโครงสร้าง"
|
||||||
|
@click="navigateTo(`/instructor/courses/${course.id}/structure`)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chapters -->
|
||||||
|
<div v-if="course.chapters.length === 0" class="text-center py-10 text-gray-500">
|
||||||
|
ยังไม่มีบทเรียน
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-4">
|
||||||
|
<q-card
|
||||||
|
v-for="chapter in course.chapters"
|
||||||
|
:key="chapter.id"
|
||||||
|
flat
|
||||||
|
bordered
|
||||||
|
class="rounded-lg"
|
||||||
|
>
|
||||||
|
<q-card-section>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold text-gray-900">
|
||||||
|
Chapter {{ chapter.sort_order }}: {{ chapter.title.th }}
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 mt-1">
|
||||||
|
{{ chapter.lessons.length }} บทเรียน · {{ getChapterDuration(chapter) }} นาที
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- Lessons -->
|
||||||
|
<q-list separator class="border-t">
|
||||||
|
<q-item
|
||||||
|
v-for="lesson in chapter.lessons"
|
||||||
|
:key="lesson.id"
|
||||||
|
class="py-3"
|
||||||
|
>
|
||||||
|
<q-item-section avatar>
|
||||||
|
<q-icon
|
||||||
|
:name="getLessonIcon(lesson.type)"
|
||||||
|
:color="getLessonIconColor(lesson.type)"
|
||||||
|
/>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label>
|
||||||
|
Lesson {{ chapter.sort_order }}.{{ lesson.sort_order }}: {{ lesson.title.th }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section side>
|
||||||
|
<span class="text-sm text-gray-500">{{ lesson.duration_minutes }} นาที</span>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
</q-card>
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
|
||||||
|
<!-- Students Tab -->
|
||||||
|
<q-tab-panel name="students" class="p-6">
|
||||||
|
<div class="text-center py-10 text-gray-500">
|
||||||
|
<q-icon name="people" size="60px" color="grey-4" class="mb-4" />
|
||||||
|
<p>ยังไม่มีผู้เรียนในหลักสูตรนี้</p>
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
|
||||||
|
<!-- Quiz Results Tab -->
|
||||||
|
<q-tab-panel name="quiz" class="p-6">
|
||||||
|
<div class="text-center py-10 text-gray-500">
|
||||||
|
<q-icon name="quiz" size="60px" color="grey-4" class="mb-4" />
|
||||||
|
<p>ยังไม่มีผลการทดสอบ</p>
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
|
||||||
|
<!-- Announcements Tab -->
|
||||||
|
<q-tab-panel name="announcements" class="p-6">
|
||||||
|
<div class="text-center py-10 text-gray-500">
|
||||||
|
<q-icon name="campaign" size="60px" color="grey-4" class="mb-4" />
|
||||||
|
<p>ยังไม่มีประกาศ</p>
|
||||||
|
</div>
|
||||||
|
</q-tab-panel>
|
||||||
|
</q-tab-panels>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import {
|
||||||
|
instructorService,
|
||||||
|
type CourseDetailResponse,
|
||||||
|
type ChapterResponse
|
||||||
|
} from '~/services/instructor.service';
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'instructor',
|
||||||
|
middleware: ['auth']
|
||||||
|
});
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
// Data
|
||||||
|
const course = ref<CourseDetailResponse | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const activeTab = ref('structure');
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const totalLessons = computed(() => {
|
||||||
|
if (!course.value) return 0;
|
||||||
|
return course.value.chapters.reduce((sum, ch) => sum + ch.lessons.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const fetchCourse = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const courseId = parseInt(route.params.id as string);
|
||||||
|
course.value = await instructorService.getCourseById(courseId);
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'ไม่สามารถโหลดข้อมูลหลักสูตรได้',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
navigateTo('/instructor/courses');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
APPROVED: 'green',
|
||||||
|
PENDING: 'yellow',
|
||||||
|
DRAFT: 'grey',
|
||||||
|
REJECTED: 'red'
|
||||||
|
};
|
||||||
|
return colors[status] || 'grey';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
APPROVED: 'อนุมัติแล้ว',
|
||||||
|
PENDING: 'รอตรวจสอบ',
|
||||||
|
DRAFT: 'แบบร่าง',
|
||||||
|
REJECTED: 'ถูกปฏิเสธ'
|
||||||
|
};
|
||||||
|
return labels[status] || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getChapterDuration = (chapter: ChapterResponse) => {
|
||||||
|
return chapter.lessons.reduce((sum, l) => sum + l.duration_minutes, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLessonIcon = (type: string) => {
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
VIDEO: 'play_circle',
|
||||||
|
QUIZ: 'quiz',
|
||||||
|
DOCUMENT: 'description'
|
||||||
|
};
|
||||||
|
return icons[type] || 'article';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLessonIconColor = (type: string) => {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
VIDEO: 'primary',
|
||||||
|
QUIZ: 'orange',
|
||||||
|
DOCUMENT: 'grey'
|
||||||
|
};
|
||||||
|
return colors[type] || 'grey';
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestApproval = () => {
|
||||||
|
$q.dialog({
|
||||||
|
title: 'ขออนุมัติหลักสูตร',
|
||||||
|
message: 'ยืนยันการขออนุมัติหลักสูตรนี้?',
|
||||||
|
cancel: true,
|
||||||
|
persistent: true
|
||||||
|
}).onOk(async () => {
|
||||||
|
try {
|
||||||
|
const courseId = parseInt(route.params.id as string);
|
||||||
|
await instructorService.sendForReview(courseId);
|
||||||
|
$q.notify({
|
||||||
|
type: 'positive',
|
||||||
|
message: 'ส่งคำขออนุมัติแล้ว',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
// Refresh course data
|
||||||
|
fetchCourse();
|
||||||
|
} catch (error: any) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: error.data?.error?.message || 'ไม่สามารถส่งคำขอได้',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
fetchCourse();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -109,9 +109,9 @@
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<q-input
|
<q-input
|
||||||
v-model="form.thumbnail_url"
|
v-model="form.thumbnail_url"
|
||||||
label="URL รูปภาพปก"
|
label="URL รูปภาพปก *"
|
||||||
outlined
|
outlined
|
||||||
hint="ใส่ URL รูปภาพ (optional)"
|
:rules="[val => !!val || 'กรุณากรอก URL รูปภาพปก']"
|
||||||
>
|
>
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<q-icon name="image" />
|
<q-icon name="image" />
|
||||||
|
|
|
||||||
|
|
@ -236,7 +236,7 @@ const fetchCourses = async () => {
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
APPROVED: 'green',
|
APPROVED: 'green',
|
||||||
PENDING: 'yellow',
|
PENDING: 'orange',
|
||||||
DRAFT: 'grey',
|
DRAFT: 'grey',
|
||||||
REJECTED: 'red'
|
REJECTED: 'red'
|
||||||
};
|
};
|
||||||
|
|
@ -275,12 +275,23 @@ const confirmDelete = (course: CourseResponse) => {
|
||||||
message: `คุณต้องการลบหลักสูตร "${course.title.th}" หรือไม่?`,
|
message: `คุณต้องการลบหลักสูตร "${course.title.th}" หรือไม่?`,
|
||||||
cancel: true,
|
cancel: true,
|
||||||
persistent: true
|
persistent: true
|
||||||
}).onOk(() => {
|
}).onOk(async () => {
|
||||||
|
try {
|
||||||
|
await instructorService.deleteCourse(course.id);
|
||||||
$q.notify({
|
$q.notify({
|
||||||
type: 'positive',
|
type: 'positive',
|
||||||
message: 'ลบหลักสูตรสำเร็จ',
|
message: 'ลบหลักสูตรสำเร็จ',
|
||||||
position: 'top'
|
position: 'top'
|
||||||
});
|
});
|
||||||
|
// Refresh list
|
||||||
|
fetchCourses();
|
||||||
|
} catch (error) {
|
||||||
|
$q.notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: 'ไม่สามารถลบหลักสูตรได้',
|
||||||
|
position: 'top'
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -147,16 +147,119 @@ export const instructorService = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = getAuthToken();
|
const token = getAuthToken();
|
||||||
|
|
||||||
|
// Clean data - remove empty thumbnail_url
|
||||||
|
const cleanedData = { ...data };
|
||||||
|
if (!cleanedData.thumbnail_url) {
|
||||||
|
delete cleanedData.thumbnail_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== CREATE COURSE DEBUG ===');
|
||||||
|
console.log('Body:', JSON.stringify({ data: cleanedData }, null, 2));
|
||||||
|
console.log('===========================');
|
||||||
|
|
||||||
const response = await $fetch<{ code: number; data: CourseResponse }>('/api/instructors/courses', {
|
const response = await $fetch<{ code: number; data: CourseResponse }>('/api/instructors/courses', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
baseURL: config.public.apiBaseUrl as string,
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
},
|
},
|
||||||
|
body: { data: cleanedData }
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCourseById(courseId: number): Promise<CourseDetailResponse> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return MOCK_COURSE_DETAIL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
const response = await $fetch<{ code: number; data: CourseDetailResponse }>(`/api/instructors/courses/${courseId}`, {
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateCourse(courseId: number, data: CreateCourseRequest): Promise<CourseResponse> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return {
|
||||||
|
...MOCK_COURSES[0],
|
||||||
|
id: courseId,
|
||||||
|
...data,
|
||||||
|
price: String(data.price)
|
||||||
|
} as CourseResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
|
||||||
|
// Debug log
|
||||||
|
console.log('=== UPDATE COURSE DEBUG ===');
|
||||||
|
console.log('URL:', `${config.public.apiBaseUrl}/api/instructors/courses/${courseId}`);
|
||||||
|
console.log('Body:', JSON.stringify({ data }, null, 2));
|
||||||
|
console.log('===========================');
|
||||||
|
|
||||||
|
const response = await $fetch<{ code: number; data: CourseResponse }>(`/api/instructors/courses/${courseId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
},
|
||||||
body: { data }
|
body: { data }
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteCourse(courseId: number): Promise<void> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
await $fetch(`/api/instructors/courses/${courseId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendForReview(courseId: number): Promise<void> {
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const useMockData = config.public.useMockData as boolean;
|
||||||
|
|
||||||
|
if (useMockData) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = getAuthToken();
|
||||||
|
await $fetch(`/api/instructors/courses/send-review/${courseId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
baseURL: config.public.apiBaseUrl as string,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -177,3 +280,154 @@ export interface CreateCourseRequest {
|
||||||
is_free: boolean;
|
is_free: boolean;
|
||||||
have_certificate: boolean;
|
have_certificate: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Course detail with chapters and lessons
|
||||||
|
export interface CourseDetailResponse extends CourseResponse {
|
||||||
|
chapters: ChapterResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChapterResponse {
|
||||||
|
id: number;
|
||||||
|
course_id: number;
|
||||||
|
title: { en: string; th: string };
|
||||||
|
description: { en: string; th: string };
|
||||||
|
sort_order: number;
|
||||||
|
is_published: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
lessons: LessonResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LessonResponse {
|
||||||
|
id: number;
|
||||||
|
chapter_id: number;
|
||||||
|
title: { en: string; th: string };
|
||||||
|
content: { en: string; th: string } | null;
|
||||||
|
type: 'VIDEO' | 'QUIZ' | 'DOCUMENT';
|
||||||
|
duration_minutes: number;
|
||||||
|
sort_order: number;
|
||||||
|
is_sequential: boolean;
|
||||||
|
prerequisite_lesson_ids: number[] | null;
|
||||||
|
require_pass_quiz: boolean;
|
||||||
|
is_published: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
quiz: QuizResponse | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizResponse {
|
||||||
|
id: number;
|
||||||
|
lesson_id: number;
|
||||||
|
title: { en: string; th: string };
|
||||||
|
description: { en: string; th: string };
|
||||||
|
passing_score: number;
|
||||||
|
time_limit: number;
|
||||||
|
shuffle_questions: boolean;
|
||||||
|
shuffle_choices: boolean;
|
||||||
|
show_answers_after_completion: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock course detail
|
||||||
|
const MOCK_COURSE_DETAIL: CourseDetailResponse = {
|
||||||
|
...MOCK_COURSES[0],
|
||||||
|
chapters: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
course_id: 1,
|
||||||
|
title: { en: 'Chapter 1: Getting Started', th: 'บทที่ 1: เริ่มต้น' },
|
||||||
|
description: { en: 'Introduction to JavaScript', th: 'แนะนำ JavaScript' },
|
||||||
|
sort_order: 1,
|
||||||
|
is_published: true,
|
||||||
|
created_at: '2024-01-15T00:00:00Z',
|
||||||
|
updated_at: '2024-01-15T00:00:00Z',
|
||||||
|
lessons: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
chapter_id: 1,
|
||||||
|
title: { en: 'What is JavaScript', th: 'JavaScript คืออะไร' },
|
||||||
|
content: { en: 'Introduction', th: 'แนะนำ' },
|
||||||
|
type: 'VIDEO',
|
||||||
|
duration_minutes: 15,
|
||||||
|
sort_order: 1,
|
||||||
|
is_sequential: true,
|
||||||
|
prerequisite_lesson_ids: null,
|
||||||
|
require_pass_quiz: false,
|
||||||
|
is_published: true,
|
||||||
|
created_at: '2024-01-15T00:00:00Z',
|
||||||
|
updated_at: '2024-01-15T00:00:00Z',
|
||||||
|
quiz: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
chapter_id: 1,
|
||||||
|
title: { en: 'Variables', th: 'ตัวแปร' },
|
||||||
|
content: { en: 'Learn variables', th: 'เรียนรู้ตัวแปร' },
|
||||||
|
type: 'VIDEO',
|
||||||
|
duration_minutes: 20,
|
||||||
|
sort_order: 2,
|
||||||
|
is_sequential: true,
|
||||||
|
prerequisite_lesson_ids: null,
|
||||||
|
require_pass_quiz: false,
|
||||||
|
is_published: true,
|
||||||
|
created_at: '2024-01-15T00:00:00Z',
|
||||||
|
updated_at: '2024-01-15T00:00:00Z',
|
||||||
|
quiz: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
chapter_id: 1,
|
||||||
|
title: { en: 'Chapter 1 Quiz', th: 'แบบทดสอบบทที่ 1' },
|
||||||
|
content: null,
|
||||||
|
type: 'QUIZ',
|
||||||
|
duration_minutes: 10,
|
||||||
|
sort_order: 3,
|
||||||
|
is_sequential: true,
|
||||||
|
prerequisite_lesson_ids: null,
|
||||||
|
require_pass_quiz: true,
|
||||||
|
is_published: true,
|
||||||
|
created_at: '2024-01-15T00:00:00Z',
|
||||||
|
updated_at: '2024-01-15T00:00:00Z',
|
||||||
|
quiz: {
|
||||||
|
id: 1,
|
||||||
|
lesson_id: 3,
|
||||||
|
title: { en: 'Chapter 1 Quiz', th: 'แบบทดสอบบทที่ 1' },
|
||||||
|
description: { en: 'Test your knowledge', th: 'ทดสอบความรู้' },
|
||||||
|
passing_score: 70,
|
||||||
|
time_limit: 10,
|
||||||
|
shuffle_questions: true,
|
||||||
|
shuffle_choices: true,
|
||||||
|
show_answers_after_completion: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
course_id: 1,
|
||||||
|
title: { en: 'Chapter 2: Functions', th: 'บทที่ 2: ฟังก์ชัน' },
|
||||||
|
description: { en: 'Learn about functions', th: 'เรียนรู้เกี่ยวกับฟังก์ชัน' },
|
||||||
|
sort_order: 2,
|
||||||
|
is_published: true,
|
||||||
|
created_at: '2024-01-15T00:00:00Z',
|
||||||
|
updated_at: '2024-01-15T00:00:00Z',
|
||||||
|
lessons: [
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
chapter_id: 2,
|
||||||
|
title: { en: 'Creating Functions', th: 'การสร้างฟังก์ชัน' },
|
||||||
|
content: { en: 'How to create functions', th: 'วิธีสร้างฟังก์ชัน' },
|
||||||
|
type: 'VIDEO',
|
||||||
|
duration_minutes: 25,
|
||||||
|
sort_order: 1,
|
||||||
|
is_sequential: true,
|
||||||
|
prerequisite_lesson_ids: null,
|
||||||
|
require_pass_quiz: false,
|
||||||
|
is_published: true,
|
||||||
|
created_at: '2024-01-15T00:00:00Z',
|
||||||
|
updated_at: '2024-01-15T00:00:00Z',
|
||||||
|
quiz: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue