feat: Implement instructor course management pages and service for listing, creating, viewing, and editing courses.

This commit is contained in:
Missez 2026-01-21 09:52:10 +07:00
parent cad3f276f5
commit 1e06041769
5 changed files with 838 additions and 9 deletions

View 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>

View 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>

View file

@ -109,9 +109,9 @@
<div class="mb-6">
<q-input
v-model="form.thumbnail_url"
label="URL รูปภาพปก"
label="URL รูปภาพปก *"
outlined
hint="ใส่ URL รูปภาพ (optional)"
:rules="[val => !!val || 'กรุณากรอก URL รูปภาพปก']"
>
<template v-slot:prepend>
<q-icon name="image" />

View file

@ -236,7 +236,7 @@ const fetchCourses = async () => {
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
APPROVED: 'green',
PENDING: 'yellow',
PENDING: 'orange',
DRAFT: 'grey',
REJECTED: 'red'
};
@ -275,12 +275,23 @@ const confirmDelete = (course: CourseResponse) => {
message: `คุณต้องการลบหลักสูตร "${course.title.th}" หรือไม่?`,
cancel: true,
persistent: true
}).onOk(() => {
$q.notify({
type: 'positive',
message: 'ลบหลักสูตรสำเร็จ',
position: 'top'
});
}).onOk(async () => {
try {
await instructorService.deleteCourse(course.id);
$q.notify({
type: 'positive',
message: 'ลบหลักสูตรสำเร็จ',
position: 'top'
});
// Refresh list
fetchCourses();
} catch (error) {
$q.notify({
type: 'negative',
message: 'ไม่สามารถลบหลักสูตรได้',
position: 'top'
});
}
});
};